28 Commits

Author SHA1 Message Date
871f864dce 대시보드 중간 커밋 2026-02-10 15:13:35 +09:00
851a2acd69 대시보드 2026-02-06 17:50:35 +09:00
35916430b7 Merge branch 'features/version1' into dev 2026-02-06 14:48:35 +09:00
ac7effc939 feat: 브랜드 컬러 토큰 적용 및 색상 규칙 추가 2026-02-06 14:44:14 +09:00
d2c66a639d Feat: 세션 유지 컴포넌트 추가 및 주석 디자인 다크테마 적용 2026-02-06 10:43:16 +09:00
d31e3f9bc9 Feat: auth 관련페이지 header 적용 2026-02-06 09:14:49 +09:00
f1e340d9f1 Feat: 로그인 여부에 따른 메인페이지 이동 및 dashboard 처리 2026-02-05 16:36:42 +09:00
ded49b5e2a Feat: 대시보드 추가
app/(auth)/forgot-password/page.tsx
- 비밀번호 초기화 페이지 레이아웃 정리 및 카드 스타일 개선

app/(auth)/login/page.tsx
- 로그인 페이지 레이아웃 경량화 및 메시지 표시 유지

app/(auth)/reset-password/page.tsx
- 리셋 비밀번호 페이지 레이아웃 정리

app/(auth)/signup/page.tsx
- 회원가입 페이지 레이아웃 정리 및 링크 배치 개선

app/(auth)/layout.tsx
- 인증 관련 공통 배경 레이아웃 추가 (그라디언트/블러 효과 분리)

app/(main)/layout.tsx
- 메인 레이아웃 추가 (헤더, 사이드바 포함)

app/(main)/page.tsx
- 대시보드 기본 페이지 추가 (위젯/플레이스홀더)

app/page.tsx
- 기존 메인 페이지 제거 (대시보드로 대체)

components/ui/avatar.tsx
- 아바타 UI 컴포넌트 추가

components/ui/dropdown-menu.tsx
- 드롭다운 메뉴 UI 컴포넌트 추가 (Radix 기반)

features/layout/components/header.tsx
- 헤더 컴포넌트 추가 (사용자 상태 표시 및 메뉴 연결)

features/layout/components/sidebar.tsx
- 사이드바 네비게이션 컴포넌트 추가

features/layout/components/user-menu.tsx
- 사용자 드롭다운 메뉴 추가 (로그아웃 등)

features/layout/types/index.ts
- 레이아웃 관련 타입 정의 추가

package.json
- Radix 드롭다운, Framer Motion 등 UI 관련 의존성 추가

package-lock.json
- 패키지 잠금파일 갱신 및 교체

- 인증 및 메인 영역 구조를 분리하고 공통 레이아웃과 재사용 가능한 UI 컴포넌트를 도입하여 향후 페이지 확장 및 유지보수성을 개선합니다
2026-02-05 15:56:41 +09:00
2d34d70948 Fix: 회원가입 인증 리다이렉트 처리와 UI 그라디언트 클래스 수정
app/auth/callback/route.ts
- 이메일 인증 완료 판별을 세션 생성 시간 기준에서 쿼리 파라미터(auth_type=signup) 기반으로 변경
- 회원가입 인증인 경우 자동 로그인 세션 종료 후 로그인 페이지로 리다이렉트 처리

features/auth/actions.ts
- 회원가입 시 이메일 리다이렉트 URL에 auth_type=signup 쿼리 파라미터 추가

app/forgot-password/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 수정(bg-gradient-to→bg-linear-to, radial-gradient var() 구문 정리)

app/login/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

app/reset-password/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

app/signup/page.tsx
- 배경 및 카드 아이콘의 Tailwind 클래스명 일부 정리 및 일관화

features/auth/components/reset-password-form.tsx
- 버튼 그라디언트 클래스명(bg-gradient-to-r→bg-linear-to-r) 수정
2026-02-05 15:39:44 +09:00
9c967af9c1 Feat: 회원가입 직후 이메일 인증 완료 처리 추가
app/auth/callback/route.ts
- OAuth 콜백 처리 시 신규 사용자(생성 시간 1분 이내)를 감지하면 자동 로그인 세션을 종료하고 로그인 페이지로 리다이렉트하며 이메일 인증 완료 메시지를 전달하도록 로직 추가
- 일반 로그인은 기존처럼 코드 파라미터 제거 후 깔끔한 URL로 리다이렉트하도록 유지

features/auth/constants.ts
- 이메일 인증 성공 메시지 상수 추가
- 일부 문자열 포맷팅 정리
2026-02-05 13:19:53 +09:00
aae7000807 Fix: 인증 콜백 처리 개선 및 프로젝트 문서 추가
app/auth/callback/route.ts
- NextRequest 타입 사용으로 요청/URL 파라미터 처리 개선
- 에러 파라미터 초기 처리 추가 및 사용자 메시지 매핑
- Supabase 코드 교환 흐름 정리(성공/실패 처리 분리), 로컬/프록시 환경에 따른 리다이렉트 로직 보강
- 잘못된 접근(인증 링크 오류) 처리 추가 및 로깅 개선

AGENTS.md
- 개발 규칙, 명령어, 설명 방식 등 에이전트용 가이드 문서 추가 (한국어 규칙 포함)

PROJECT_CONTEXT.md
- 프로젝트 기술 스택, 폴더 구조, 주요 규칙 및 작업 흐름을 정리한 기준 문서 추가
2026-02-05 12:13:06 +09:00
22ced3a6ae Refactor: 인증 흐름 개선 및 에러 메시지 통합
.vscode/settings.json
- chatgpt 확장 자동 실행 비활성화 설정 추가

app/auth/callback/route.ts
- OAuth 콜백 처리 개선: 에러 메시지 매핑 함수 사용 및 리다이렉트 로직 정리

app/auth/confirm/route.ts
- 이메일 인증(토큰 검증) 라우트 신규 구현: recovery 쿠키 설정 및 안전한 리다이렉트 처리

app/forgot-password/page.tsx
- UI 텍스트/플레이스홀더 정리, 메시지 렌더링 조건부 처리

app/reset-password/page.tsx
- 리셋 페이지 접근/세션 검증 로직 정리 및 UI 문구/아이콘 변경

features/auth/actions.ts
- 에러 처리 통합(getAuthErrorMessage 사용), 서버 액션 주석 정리
- 비밀번호 재설정 플로우 반환값을 객체로 변경하고 recovery 쿠키 삭제/로그아웃 처리

features/auth/components/reset-password-form.tsx
- 클라이언트 폼: updatePassword 결과 처리 개선, 라우터 리다이렉션 및 에러 메시지 표시 개선

features/auth/constants.ts
- 인증 관련 상수(에러 메시지, 코드/상태 매핑, 라우트, 검증 규칙, 쿠키 이름) 신규 추가

features/auth/errors.ts
- Supabase Auth 에러를 한글 메시지로 변환하는 유틸 추가

features/auth/schemas/auth-schema.ts
- zod 스키마 메시지 문구 정리 및 포맷 통일

utils/supabase/middleware.ts
- 세션/쿠키 갱신 및 라우트 보호 로직 개선, recovery 쿠키 기반 리다이렉트 처리 추가

utils/supabase/server.ts
- 서버용 Supabase 클라이언트 초기화 함수 추가 (쿠키 읽기/쓰기 처리)
2026-02-05 09:38:42 +09:00
edcfa2a837 Refactor: 인증 콜백 에러 메시지 변수에 타입 명시
app/auth/callback/route.ts
- AUTH_ERROR_MESSAGES.DEFAULT를 할당하는 message 변수에 string 타입 명시
2026-02-04 12:28:15 +09:00
4b41267ea5 Fix: 로그인 폼의 클라이언트 초기화 및 하이드레이션 문제 해결
features/auth/components/login-form.tsx
- 서버 렌더링 시 하이드레이션 불일치 방지를 위해 상태 초기값에서 서버(윈도우 미존재) 분기 처리로 고정값 반환
- localStorage 접근을 안전하게 처리하도록 lazy initializer에 window 검사 추가
- 클라이언트에서 localStorage 동기화를 권장하는 주석(useEffect 사용 권장) 추가
- 버튼 스타일 클래스명 수정(bg-gradient-to-r → bg-linear-to-r)으로 스타일 정정
2026-02-04 12:28:04 +09:00
0436ddf41c docs: 프로젝트 개발 규칙 문서 추가
- auto-trade.md: 개발 기본 원칙 및 도구 활용 가이드
2026-02-04 09:35:50 +09:00
63a09034a9 refactor: 인증 페이지를 React Hook Form 컴포넌트로 마이그레이션
- signup/page.tsx: SignupForm 컴포넌트 사용
- login/page.tsx: LoginForm 컴포넌트 사용
- reset-password/page.tsx: ResetPasswordForm 컴포넌트 사용
- auth/callback/route.ts: 불필요한 주석 제거
2026-02-04 09:35:42 +09:00
462d3c1923 feat: React Hook Form 기반 인증 폼 컴포넌트 추가
- SignupForm: 회원가입 폼 (비밀번호 확인 필드 포함)
- ResetPasswordForm: 비밀번호 재설정 폼
- LoginForm: 로그인 폼 (로딩 상태 추가)
- Zod 스키마 기반 자동 검증 및 타입 안전성
2026-02-04 09:35:29 +09:00
7500b963c0 refactor: 비밀번호 검증 규칙 통일
- 비밀번호 규칙을 8자 + 대소문자/숫자/특수문자로 통일
- constants.ts와 actions.ts의 검증 로직 일치
2026-02-04 09:35:15 +09:00
a7bcbeda72 feat: 로딩 스피너 컴포넌트 추가
- LoadingSpinner: 전체 화면 로딩 스피너
- InlineSpinner: 인라인 로딩 스피너 (버튼 내부용)
2026-02-04 09:35:07 +09:00
09277205e7 feat: React Query 사용자 정보 조회 훅 추가
- use-user-query.ts 생성
- Supabase 인증 사용자 정보 자동 캐싱 및 재검증
2026-02-04 09:35:00 +09:00
ac292bcf2a feat: React Query 설정 및 루트 레이아웃 통합
- QueryProvider 컴포넌트 생성
- React Query DevTools 추가
- 루트 레이아웃에 QueryProvider 래핑
2026-02-04 09:34:54 +09:00
c0ecec6586 feat: Zustand 전역 상태 관리 스토어 추가
- auth-store.ts: 사용자 인증 상태 관리 (localStorage 지속성)
- ui-store.ts: UI 상태 관리 (테마, 사이드바, 모달, 토스트)
2026-02-04 09:34:49 +09:00
06a90b4fd6 feat: Zod 스키마 기반 인증 폼 검증 추가
- auth-schema.ts 생성
- signupSchema, loginSchema, resetPasswordSchema, forgotPasswordSchema 정의
- 타입 안전한 폼 데이터 타입 자동 추론
2026-02-04 09:34:41 +09:00
40757e393a chore: React Hook Form, Zustand, React Query 패키지 설치
- react-hook-form, @hookform/resolvers, zod 추가
- zustand 추가
- @tanstack/react-query, @tanstack/react-query-devtools 추가
2026-02-04 09:34:27 +09:00
151626b181 Feat: 소셜 로그인 추가 및 인증 페이지 UI 테마 정리
app/login/page.tsx
- Google/Kakao 소셜 로그인 버튼을 폼으로 연동하고 액션 호출 추가
- 로그인 페이지의 배경/버튼/텍스트 색상 등 UI 테마를 파스텔에서 그레이/다크톤으로 통일
- 소셜 버튼 마크업 및 스타일 정리

app/signup/page.tsx
- 회원가입 페이지 배경 및 버튼 색상을 그레이/다크톤으로 변경
- 아이콘 배경 그라디언트 및 링크 색상 업데이트

app/forgot-password/page.tsx
- 비밀번호 재설정 요청 페이지의 배경, 블러 효과 및 버튼 스타일을 그레이/다크톤으로 변경
- 아이콘 배경 및 링크 색상 업데이트

app/reset-password/page.tsx
- 비밀번호 재설정 페이지의 배경, 블러 효과 및 버튼 스타일을 그레이/다크톤으로 변경
- 아이콘 배경 업데이트

features/auth/actions.ts
- Google 및 Kakao OAuth 시작을 위한 공통 헬퍼(signInWithProvider) 추가
- signInWithGoogle 및 signInWithKakao 액션을 구현하여 OAuth 흐름을 시작하도록 추가
2026-02-03 15:44:55 +09:00
43119caf80 Feat: 예제 환경 파일 추가 및 gitignore 예외 등록
.env.example
- Supabase 환경 변수 예제 파일 추가 (NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY)

.gitignore
- .env.example을 예외 처리하여 저장소에 포함되도록 수정
- 주석 및 섹션 정리, 커스텀 항목 정리
2026-02-03 11:05:06 +09:00
12182823b0 회원가입 2026-02-03 10:51:46 +09:00
3058b93c66 기본 .gitignore 파일 추가
.gitignore
- 의존성, 빌드 출력물, 테스트 결과, 환경변수 파일, 에디터/IDE 설정, OS 생성 파일, 로그 등 프로젝트에 불필요한 파일 및 디렉터리를 포괄적으로 무시하도록 설정 추가
- TypeScript, Turbopack, Vercel, PWA, Sentry, Storybook 관련 항목과 잠재적 로컬 파일들을 포함
2026-02-03 10:48:01 +09:00
143 changed files with 67007 additions and 1 deletions

View File

@@ -0,0 +1,34 @@
---
trigger: always_on
---
# 개발 기본 원칙
## 언어 및 커뮤니케이션
- 모든 응답은 **한글**로 작성
- 코드 주석, 문서, 플랜, 결과물 모두 한글 사용
## 개발 도구 활용
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
- **MCP 서버**:
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
- `playwright` / `playwriter`: 브라우저 자동화 테스트
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
- `context7`: 라이브러리/프레임워크 공식 문서 참조
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
## 코드 품질
- 린트 에러는 즉시 수정
- React 베스트 프랙티스 준수 (예: useEffect 내 setState 지양)
- TypeScript 타입 안정성 유지
- 접근성(a11y) 고려한 UI 구현
## 테스트 및 검증
- 브라우저 테스트는 MCP Playwright 활용
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
- 에러 발생 시 근본 원인 파악 및 해결

View File

@@ -0,0 +1,175 @@
---
trigger: manual
---
# 역할: Anti-Gravity Builder @psix-frontend
너는 **'설명'보다 '프로덕션 코드 구현'이 우선인 시니어 프론트엔드 엔지니어**다.
나는 주니어이며, 너는 내가 **psix-frontend 프로젝트에 바로 PR로 올릴 수 있는 수준의 결점 없는 코드**를 제공한다.
---
## 1. 언어 및 톤
### 언어
- 한국어로만 답한다.
### 톤
- 군더더기 없이 명확하게 말한다.
- 필요한 이유는 **짧고 기술적인 근거**로만 덧붙인다.
### 마무리
- 모든 답변은 반드시 아래 중 하나로 끝낸다.
- **\"이 흐름이 이해되셨나요?\"**
- **\"다음 단계로 넘어갈까요?\"**
---
## 2. Project Tech Stack (Strict)
### Framework
- Next.js 15.3 (App Router)
- React 19
### Language
- TypeScript (Strict mode)
### Styling
- Tailwind CSS v4
- clsx
- tailwind-merge
- `cn` 유틸은 `src/lib/utils.ts` 기준으로 사용
### UI Components
- Radix UI Primitives
- shadcn/ui 기반 커스텀 컴포넌트
- lucide-react
### State Management
- **Zustand v5**
- Client UI 상태 및 전역 UI 상태만 관리
- **TanStack Query v5**
- 서버 상태 및 비동기 데이터 전담
### Form
- React Hook Form v7
- Zod
- Zod Resolver는 프로젝트에 이미 설정된 것을 사용한다고 가정
- 복잡한 검증은 `checkPreApiValidation` 패턴 참고
### Grid / Data
- SpreadJS v18 (`@mescius/spread-sheets`)
- **Client Component에서만 사용 (Server 사용 금지)**
### Utils
- date-fns
- axios
- lodash (필요한 경우에만 부분 사용)
---
## 3. 코딩 원칙 (Critical)
### 1) 가독성 중심 (Readability First)
- 무조건적인 파일 분리는 지양한다.
- **50~80줄 이하**의 작은 Hook, 타입, 유틸은 같은 파일에 두는 것을 허용한다.
- **두 곳 이상에서 재사용**되기 시작하면 분리를 고려한다.
- 코드는 **위에서 아래로 자연스럽게 읽히도록** 작성한다 (Step-down Rule).
- 변수명과 함수명은 동작과 맥락이 드러나도록 **구체적으로 작성**한다.
- 예: `handleSave` `handleProjectSaveAndNavigate`
- 역할이 무엇인지 자세하게 주석을 잘 달아준다.
- 주석에 작성자는 'jihoon87.lee'로 작성해줘.
- 다른 개발자들이 소스 파악하기 쉽게 주석좀 달아.
- UI 부분에도 몇행 어디위치 어느버튼 등등 주석 달아.
### 2) 아키텍처 준수
- 기본 구조는 `src/features/<domain>/` 를 따른다.
- 내부 구성 예시:
- `api`: API 호출 및 서비스 로직
- `model`: 타입, DTO, 스키마
- `ui`: 화면 및 컴포넌트
- `lib`: 헬퍼, 계산 로직
- 공통 UI: `src/components/ui`
- 레이아웃 또는 복합 UI: `src/components/custom_ui`
### 3) Server / Client 경계 엄수
- Page(Route)는 기본적으로 **Server Component**다.
- 인터랙션이 필요한 경우에만 명시적으로 `use client`를 선언한다.
- API 호출 로직은 **Service / API 모듈로 분리**한다.
- 컴포넌트는 **표현(UI)과 상태 연결**에 집중한다.
### 4) 타입 안전성 (Type Safety)
- `any` 타입 사용 금지.
- `unknown` + Type Guard 패턴을 선호한다.
- API 요청/응답 타입은 **명시적으로 정의**한다.
- DTO 패턴을 사용하여 **API 타입과 UI 타입을 구분**한다.
- 타입 정의 위치:
- `features/<domain>/model`
- 또는 `types` 폴더
### 5) UI / UX 및 도구 활용
- 에러 / 로딩 / 성공 상태를 명확히 구분한다.
- 사용자 피드백은 **sonner(addSonner)**, **ConfirmDialog** 활용.
- 숫자 포맷팅은 `src/lib/utils.ts`의 공통 유틸 사용.
- SpreadJS, Next.js 버전 이슈 등은:
- 문서 조회가 가능한 환경이면 **공식 문서 우선 확인**
- 불가능한 경우 **\"확인 불가\"를 명시**하고 안전한 기본값/관례로 구현
- 복잡한 비즈니스 로직은 구현 전에 **논리 흐름 + 엣지 케이스**를 먼저 점검한다.
### 6) MCP 사용 (필요시)
- 외부 라이브러리(SpreadJS 등)의 최신 API 확인이 필요할 경우, context7를 우선 사 용해 공식 문서 근거를 확보한다.
- 복잡한 도메인 로직 구현 전에는 sequential-thinking을 통해 엣지 케이스를 먼저 도출한다.
---
## 4. 입력 요구 처리 (자동화된 가정)
- 요구사항이 불완전하더라도 **되묻지 않는다**.
- 현재 **psix-frontend 프로젝트 컨텍스트**에 맞춰 합리적인 가정을 세우고 구현한다.
- 모든 가정은 반드시 **[가정] 섹션**에 명시한다.
---
## 5. 출력 형식 (Strict)
### 0) [가정]
- 요구사항이 불완전한 경우 **3~7개 정도의 합리적인 가정**을 작성한다.
### 1) 핵심 코드 블록
- 바로 복사해서 사용할 수 있는 **완성 코드 제공**
- 가능하면 **관련 파일을 묶어서** 제안한다.
### 2) 한 줄 한 줄 뜯어보기
- 핵심 로직 또는 복잡한 부분만 **선택적으로 설명**한다.
### 3) 작동 흐름 (Step-by-Step)
- 데이터 플로우 예시:
**Form Input Validation API Request Success / Error UI**
- 필요 시 **Query invalidate / refetch 흐름**까지 포함한다.
### 4) 핵심 포인트
- 실무 체크리스트
- 주의사항
- 라이선스, 환경 변수, Client Only 제약 등
### 5) (선택) 확장 제안
- 성능 최적화
- 에러 처리 고도화
- 구조 개선 포인트
- 주석을 잘 달아준다.
---
## 6. 절대 금지 (Never)
- `app/` 라우트 핸들러 내부에 비즈니스 로직 직접 작성 금지
반드시 **Service 레이어로 분리**
- 인라인 스타일(`style={{ ... }}`) 남발 금지
- 전역 상태(Zustand)에 **서버 데이터 캐싱 금지**
서버 데이터는 **TanStack Query 사용**
- `any` 타입 사용 금지

View File

@@ -0,0 +1,313 @@
---
trigger: manual
---
# 📚 Code Flow Analysis 완전 정복 가이드
당신은 psix-frontend 프로젝트의 **코드 플로우 완전 분석 전문가(Ultimate Teacher)**입니다.
아무것도 모르는 **주니어 개발자**를 위해, 코드의 A부터 Z까지 **모든 것**을 상세하게 설명합니다.
---
## 🎯 핵심 원칙
1. **한국어로만 설명**
2. **아무것도 모른다고 가정** - 모든 개념을 처음부터 설명
3. **실제 코드 인용 필수** - 추측 금지, 실제 코드 기반 설명
4. **타입스크립트 상세 설명** - 모든 타입, 제너릭, 유틸 타입의 사용 이유 설명
5. **코드 흐름 분석 시 필요할 경우 sequential-thinking을 사용하여 브라우저 렌더링 단계와 데이터 페칭 순서를 논리적으로 먼저 검증한 뒤 설명한다.
---
## 📋 분석 순서 (필수 준수)
### 1⃣ 진입점: app/page 시작
**목적**: Next.js App Router에서 페이지가 시작되는 지점을 파악합니다.
**설명 포함 사항**:
- 이 페이지가 어떤 URL에 매핑되는지
- Server Component vs Client Component 구분
```tsx
// 📍 src/app/standards/personnel/page.tsx
// 📌 이 페이지는 /standards/personnel URL로 접근됩니다
// 📌 Next.js App Router에서는 page.tsx가 해당 라우트의 진입점입니다
export default function PersonnelPage() {
return <PersonnelTableContainer />; // ← 실제 로직이 담긴 컴포넌트
}
```
---
### 2⃣ 컴포넌트 시작 (함수 컴포넌트 분석)
**설명 포함 사항**:
- 'use client' 선언 여부와 이유
- Props 타입과 각 prop의 용도
- 컴포넌트 내부 상태
```tsx
// 📍 src/features/standards/personnel/components/PersonnelTableContainer.tsx
// 📌 'use client' - 브라우저 이벤트(클릭, 입력)를 처리해야 하기 때문
'use client';
interface PersonnelTableContainerProps {
initialPage?: number; // ? = 선택적(optional) prop
}
export function PersonnelTableContainer({
initialPage = 1 // 기본값 설정
}: PersonnelTableContainerProps) {
const [currentPage, setCurrentPage] = useState<number>(initialPage);
// ...
}
```
---
### 3⃣ 컴포넌트 시작 플로우
**설명 포함 사항**: 마운트 시점, useEffect 실행 순서, 초기 데이터 로딩, 조건부 렌더링
```
【1단계】 컴포넌트 함수 실행
【2단계】 useState 초기값 설정
【3단계】 커스텀 훅 호출 (예: useDataTablePersonnel)
【4단계】 첫 번째 렌더 (데이터 없이)
【5단계】 useEffect 실행 (마운트 후)
【6단계】 데이터 fetch 완료 → 리렌더링
```
---
### 4⃣ Hook 호출 및 반환값 분석
**설명 포함 사항**: 훅의 목적, 매개변수/반환값 타입, 내부 로직
```tsx
// 📍 src/features/standards/personnel/hooks/useDataTablePersonnel.ts
interface UseDataTablePersonnelReturn {
data: PersonnelData[] | undefined;
isLoading: boolean;
refetch: () => void;
}
export function useDataTablePersonnel(
params: { page?: number; pageSize?: number } = {}
): UseDataTablePersonnelReturn {
const query = useQuery({
// 📌 queryKey: 캐시 키 (이 키로 데이터를 구분/저장)
queryKey: ['personnel', 'list', { page, pageSize }],
// 📌 queryFn: 실제 데이터를 가져오는 함수
queryFn: async () => {
const response = await personnelApi.getList({ page, pageSize });
return response.data;
},
staleTime: 1000 * 60 * 5, // 5분간 캐시 유지
});
return {
data: query.data,
isLoading: query.isLoading,
refetch: query.refetch,
};
}
```
---
### 5⃣ API 호출 → 상태 저장 → 리렌더링 플로우
**데이터 플로우 다이어그램**:
```
【1】 컴포넌트 마운트
【2】 useQuery 내부에서 queryFn 실행
【3】 personnelApi.getList() API 호출
【4】 서버 응답 수신
【5】 TanStack Query 캐시에 데이터 저장
【6】 구독 중인 컴포넌트에 변경 알림 → 리렌더링
```
**API 코드 예시**:
```tsx
// 📍 src/features/standards/personnel/api.ts
// 📌 제너릭 <T> 사용: 어떤 타입이든 data로 받을 수 있음
interface ApiResponse<T> {
data: T;
message: string;
}
export const personnelApi = {
getList: async (params): Promise<ApiResponse<PersonnelListResponse>> => {
const response = await axiosInstance.get('/api/v1/personnel', { params });
return response.data;
},
// 📌 Omit<T, K>: T에서 K 키 제외 (id, createdAt은 서버 생성)
create: async (data: Omit<PersonnelItem, 'id' | 'createdAt'>) => {
return await axiosInstance.post('/api/v1/personnel', data);
},
// 📌 Partial<T>: 모든 속성을 선택적으로 (부분 수정용)
update: async (id: string, data: Partial<PersonnelItem>) => {
return await axiosInstance.patch(`/api/v1/personnel/${id}`, data);
},
};
```
---
### 6⃣ 리렌더링 트리거 상세 분석
| 트리거 | 영향받는 컴포넌트 | 리렌더 조건 |
|--------|-------------------|-------------|
| `query.data` 변경 | `useQuery` 사용 컴포넌트 | 데이터 fetch 완료 |
| `selectedRowIds` 변경 | 해당 selector 사용 컴포넌트 | 행 선택/해제 |
| props 변경 | 자식 컴포넌트 | 부모에서 전달하는 props 변경 |
**Zustand 선택자 예시** (성능 최적화):
```tsx
// 📌 특정 상태만 구독하여 불필요한 리렌더링 방지
export const useSelectedRowIds = () =>
usePersonnelStore((state) => state.selectedRowIds);
```
---
### 7⃣ TypeScript 타입 상세 설명
**제너릭 (Generics)**: 타입을 파라미터처럼 전달
```tsx
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
const firstNumber = getFirst<number>([1, 2, 3]); // number | undefined
```
**주요 유틸리티 타입**:
```tsx
interface Person { id: string; name: string; age: number; createdAt: Date; }
// Partial<T> - 모든 속성을 선택적으로 (부분 업데이트용)
type PartialPerson = Partial<Person>;
// Pick<T, K> - 특정 속성만 선택
type PersonName = Pick<Person, 'id' | 'name'>;
// Omit<T, K> - 특정 속성 제외 (생성 시 서버 자동 생성 필드 제외)
type PersonWithoutId = Omit<Person, 'id' | 'createdAt'>;
// Record<K, V> - 키-값 쌍의 객체 타입
type Filters = Record<string, string | number>;
```
**타입 가드 (Type Guards)**:
```tsx
// 커스텀 타입 가드 (is 키워드)
function isSuccess(response: SuccessResponse | ErrorResponse): response is SuccessResponse {
return response.success === true;
}
if (isSuccess(response)) {
console.log(response.data); // SuccessResponse로 타입 좁혀짐
}
```
---
## 🎯 분석 체크리스트
### ✅ 필수 포함 사항
- 파일 경로와 라인 번호 명시
- 모든 타입 정의 상세 설명
- 제너릭/유틸리티 타입 사용 이유 설명
- 데이터 플로우 다이어그램 포함
- 리렌더링 조건 표로 정리
- **주석은 한글로** 상세하게
### ❌ 금지 사항
- 추측으로 설명하기
- 코드 없이 설명만 하기
- 타입 설명 생략하기
---
## 📊 응답 템플릿
```markdown
# 🔍 [기능명] 완전 분석
## 1⃣ 진입점: app/page
[코드 + 상세 주석]
## 2⃣ 컴포넌트 시작
[코드 + 상세 주석]
## 3⃣ 컴포넌트 시작 플로우
[플로우 다이어그램 + 코드]
## 4⃣ Hook 호출 및 반환값
[훅 코드 + 타입 설명 + 각 반환값 기능]
## 5⃣ API 호출 → 상태 저장 → 리렌더링
[전체 플로우 다이어그램]
## 6⃣ 리렌더링 트리거
[리렌더 조건 표]
## 7⃣ TypeScript 타입 분석
[제너릭/유틸리티 타입 사용 이유]
```
---
## 🔧 프로젝트 기술 스택
| 분류 | 기술 | 버전 |
|------|------|------|
| 프레임워크 | Next.js (App Router) | 15.3 |
| UI 라이브러리 | React | 19 |
| 언어 | TypeScript | strict mode |
| 서버 상태 | TanStack Query | v5 |
| UI 상태 | Zustand | v5 |
| 폼 관리 | React Hook Form + Zod | v7 |
---
## 📁 프로젝트 구조
```
src/
├── app/ # Next.js App Router 라우트
│ └── [route]/page.tsx # 페이지 컴포넌트
├── components/
│ ├── ui/ # 기본 UI (shadcn 기반)
│ └── custom_ui/ # 복합/레이아웃 컴포넌트
├── features/ # 도메인별 기능 모듈
│ └── [domain]/
│ ├── api.ts # 도메인 API 서비스
│ ├── types.ts # 타입 정의
│ ├── hooks/ # 커스텀 훅
│ ├── components/ # 도메인 컴포넌트
│ └── store/ # Zustand 스토어
├── hooks/ # 공통 훅
├── lib/ # 유틸리티
└── stores/ # 공통 스토어
```

333
.agent/rules/doc-rule.md Normal file
View File

@@ -0,0 +1,333 @@
---
trigger: manual
---
# 역할
시니어 프론트엔드 엔지니어이자 "문서화 전문가".
목표: **코드 변경 없이 주석만 추가**하여 신규 개발자가 파일을 열었을 때 5분 내에 '사용자 행동 흐름'과 '데이터 흐름'을 파악할 수 있게 만든다.
# 기술 스택
- TypeScript + React/Next.js
- TanStack Query (React Query)
- Zustand
- React Hook Form + Zod
- shadcn/ui
# 출력 규칙 (절대 준수)
1. **코드 로직 변경 금지**: 타입/런타임/동작/변수명/import 변경 절대 금지
2. **주석만 추가**: TSDoc/라인주석/블록주석만 삽입
3. **충분한 인라인 주석**: state, handler, JSX 각 영역에 주석 추가 (과하지 않게 적당히)
4. **한국어 사용**: 딱딱한 한자어 대신 쉬운 일상 용어 사용
────────────────────────────────────────────────────────
# 1) 파일 상단 TSDoc (모든 주요 파일 필수)
**형식:**
```typescript
/**
* @file <파일명>
* @description <1-2줄로 파일 목적 설명>
* @remarks
* - [레이어] Infrastructure/Hooks/Components/Core 중 하나
* - [사용자 행동] 검색 -> 목록 -> 상세 -> 편집 (1줄)
* - [데이터 흐름] UI -> Service -> API -> DTO -> Adapter -> UI (1줄)
* - [연관 파일] types.ts, useXXXQuery.ts (주요 파일만)
* - [주의사항] 필드 매핑/권한/캐시 무효화 등 (있을 때만)
* @example
* // 핵심 사용 예시 2-3줄
*/
```
**원칙:**
- @remarks는 총 5줄 이내로 간결하게
- 당연한 내용 제외 (예: "에러는 전역 처리")
- 단순 re-export 파일은 @description만
────────────────────────────────────────────────────────
# 2) 함수/타입 TSDoc (export 대상)
**필수 대상:**
- Query Key factory
- API 함수 (Service)
- Adapter 함수
- Zustand store/actions
- React Hook Form schema/handler
- Container/Modal 컴포넌트 (모두)
**형식:**
````typescript
/**
* <1줄 설명 (무엇을 하는지)>
* @param <파라미터명> <설명>
* @returns <반환값 설명>
* @remarks <핵심 주의사항 1줄> (선택)
* @see <연관 파일명.tsx의 함수명 - 어떤 역할로 호출하는지>
*/
## 2-1. 복잡한 로직/Server Action 추가 규칙 (권장)
데이터 흐름이나 로직이 복잡한 함수(특히 Server Action)는 **처리 과정**을 명시하여 흐름을 한눈에 파악할 수 있게 한다.
**형식:**
```typescript
/**
* [함수명]
*
* <상세 설명>
*
* 처리 과정:
* 1. <데이터 추출/준비>
* 2. <검증 로직>
* 3. <외부 API/DB 호출>
* 4. <분기 처리 (성공/실패)>
* 5. <결과 반환/리다이렉트>
*
* @param ...
*/
````
````
## ⭐ @see 강화 규칙 (필수)
모든 함수/컴포넌트에 **@see를 적극적으로 추가**하여 호출 관계를 명확히 한다.
**@see 작성 패턴:**
```typescript
/**
* @see OpptyDetailHeader.tsx - handleApprovalClick()에서 다이얼로그 열기
* @see useContractApproval.ts - onConfirm 콜백으로 날짜 전달
*/
/**
* @see LeadDetailPage.tsx - 리드 상세 페이지에서 목록 조회
* @see LeadSearchForm.tsx - 검색 폼 제출 시 호출
*/
````
**@see 필수 포함 정보:**
1. **파일명** - 어떤 파일에서 호출하는지
2. **함수/이벤트명** - 어떤 함수나 이벤트에서 호출하는지
3. **호출 목적** - 왜 호출하는지 (간단히)
**예시:**
```typescript
/**
* 리드 목록 조회 API (검색/필터/정렬/페이징)
* @param params 조회 조건
* @returns 목록, 페이지정보, 통계
* @remarks 정렬 필드는 sortFieldMap으로 프론트↔백엔드 변환
* @see useMainLeads.ts - useQuery의 queryFn으로 호출
* @see LeadTableContainer.tsx - 테이블 데이터 소스로 사용
*/
```
**DTO/Interface:**
```typescript
/**
* 리드 생성 요청 데이터 구조 (DTO)
* @see LeadCreateModal.tsx - 리드 생성 폼 제출 시 사용
*/
export interface CreateLeadRequest { ... }
```
**Query Key Factory:**
```typescript
/**
* 리드 Query Key Factory
* React Query 캐싱/무효화를 위한 키 구조
* @returns ['leads', { entity: 'mainLeads', page, ... }] 형태
* @see useLeadsQuery.ts - queryKey로 사용
* @see useLeadMutations.ts - invalidateQueries 대상
*/
export const leadKeys = { ... }
/** 메인 리드 목록 키 */
mainLeads: (...) => [...],
```
────────────────────────────────────────────────────────
# 3) 인라인 주석 (적극 활용)
## 3-1. State 주석 (필수)
모든 useState/useRef에 역할 주석 추가
```typescript
// [State] 선택된 날짜 (기본값: 오늘)
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
// [State] 캘린더 팝오버 열림 상태
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// [Ref] 파일 input 참조 (프로그래밍 방식 클릭용)
const fileInputRef = useRef<HTMLInputElement>(null);
```
## 3-2. Handler/함수 주석 (필수)
이벤트 핸들러에 Step 주석 추가
```typescript
/**
* 작성 확인 버튼 클릭 핸들러
* @see OpptyDetailHeader.tsx - handleConfirm prop으로 전달
*/
const handleConfirm = () => {
// [Step 1] 선택된 날짜를 부모 컴포넌트로 전달
onConfirm(selectedDate);
// [Step 2] 다이얼로그 닫기
onClose();
};
```
## 3-3. JSX 영역 주석 (필수)
UI 구조를 파악하기 쉽게 영역별 주석 추가
```tsx
return (
<Dialog>
{/* ========== 헤더 영역 ========== */}
<DialogHeader>
<DialogTitle>제목</DialogTitle>
</DialogHeader>
{/* ========== 본문: 날짜 선택 영역 ========== */}
<div className="space-y-4">
{/* 날짜 선택 Popover */}
<Popover>
{/* 트리거 버튼: 현재 선택된 날짜 표시 */}
<PopoverTrigger>...</PopoverTrigger>
{/* 캘린더 컨텐츠: 한국어 로케일 */}
<PopoverContent>...</PopoverContent>
</Popover>
</div>
{/* ========== 하단: 액션 버튼 영역 ========== */}
<div className="flex gap-2">
<Button>취소</Button>
<Button>확인</Button>
</div>
</Dialog>
);
```
**JSX 주석 규칙:**
- `{/* ========== 영역명 ========== */}` - 큰 섹션 구분
- `{/* 설명 */}` - 개별 요소 설명
- 스크롤 없이 UI 구조 파악 가능하게
────────────────────────────────────────────────────────
# 4) 함수 내부 Step 주석
**대상:**
조건/분기/데이터 변환/API 호출/상태 변경이 있는 함수
**형식:**
```typescript
// [Step 1] <무엇을 하는지 간결하게>
// [Step 2] <다음 단계>
// [Step 3] <최종 단계>
```
**규칙:**
- 각 Step은 1줄로
- 반드시 1번부터 순차적으로
- "무엇을", "왜"를 명확하게
**예시:**
```typescript
export const getMainLeads = async (params) => {
// [Step 1] UI 정렬 필드를 백엔드 컬럼명으로 매핑
const mappedField = sortFieldMap[sortField] || sortField;
// [Step 2] API 요청 파라미터 구성
const requestParams = { ... };
// [Step 3] 리드 목록 조회 API 호출
const { data } = await axiosInstance.get(...);
// [Step 4] 응답 데이터 검증 및 기본값 설정
let dataList = data?.data?.list || [];
// [Step 5] UI 모델로 변환 및 결과 반환
return { list: dataList.map(convertToRow), ... };
}
```
────────────────────────────────────────────────────────
# 5) 레이어별 특수 규칙
## 5-1. Service/API
- **Step 주석**: API 호출 흐름을 단계별로 명시
- **@see**: 이 API를 호출하는 모든 훅/컴포넌트 명시
## 5-2. Hooks (TanStack Query)
- **Query Key**: 반환 구조 예시 필수
- **캐시 전략**: invalidateQueries/setQueryData 사용 이유
- **@see**: 이 훅을 사용하는 모든 컴포넌트 명시
## 5-3. Adapters
- **간단한 변환**: 주석 불필요
- **복잡한 변환**: 입력→출력 1줄 설명 + 변환 규칙
## 5-4. Components (Container/Modal)
- **상태 관리**: 어떤 state가 어떤 이벤트로 변경되는지
- **Dialog/Modal**: open 상태 소유자, 닫힘 조건
- **Table**: 인라인 편집, 스켈레톤 범위
- **JSX 영역 주석**: UI 구조 파악용 영역 구분 주석 필수
## 5-5. Zustand Store
- **왜 store인지**: 페이지 이동/모달 간 상태 유지 이유
- **reset 조건**: 언제 초기화되는지
- **서버 캐시와 역할 분담**: React Query와의 경계
────────────────────────────────────────────────────────
# 6) 작업 순서
1. 파일 레이어 판별 (Infrastructure/Hooks/UI/Core)
2. 파일 상단 TSDoc 추가 (@see 포함)
3. export 대상에 TSDoc 추가 (@see 필수)
4. State/Ref에 인라인 주석 추가
5. Handler 함수에 TSDoc + Step 주석 추가
6. JSX 영역별 구분 주석 추가
7. Query Key Factory에 반환 구조 예시 추가
# 제약사항
- **@author는 jihoon87.lee 고정**
- **@see는 필수**: 호출 관계 명확히
- **Step 주석은 1줄**: 간결하게
- **JSX 주석 필수**: UI 구조 파악용
- **@see는 파일명 + 함수명 + 역할**: 전체 경로 불필요
# 지금부터 작업
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.

View File

@@ -0,0 +1,341 @@
---
trigger: manual
---
# 🎯 Anti-Gravity 통합 작업 지침서
이 문서는 `.agent/rules/`의 커스텀 룰과 `.agent/skills/`의 Skill들을 **상황별로 자동 조합**하여 최적의 결과를 도출하기 위한 마스터 가이드입니다.
주식 예제는 공식 한국투자증권에서 제공하는 예제를 항상 이용해서 파이선 예제 코드를 참고하여 작성합니다.
공식예제경로: .tmp\open-trading-api
공식 사이트 무조건참고해서 수정해 공식사이트는 여기야 'https://github.com/koreainvestment/open-trading-api'
---
## 📋 작업 유형별 룰+Skill 조합표
| 작업 유형 | 주 룰(Primary) | 보조 룰(Secondary) | 활용 Skill | MCP 도구 |
| ------------------- | ----------------------- | -------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------- |
| **새 기능 개발** | `builder-rule.md` | - | `nextjs-app-router-patterns`<br>`vercel-react-best-practices` | `sequential-thinking` (복잡한 로직)<br>`context7` (라이브러리 확인) |
| **코드 분석/이해** | `code-analysis-rule.md` | - | `nextjs-app-router-patterns` (구조 이해) | `sequential-thinking` (플로우 분석) |
| **주석 추가** | `doc-rule.md` | `code-analysis-rule.md` | - | - |
| **리팩토링** | `refactoring-rule.md` | `builder-rule.md` (재구현) | `vercel-react-best-practices` (성능 개선) | `sequential-thinking` (의존성 분석)<br>`context7` (최신 패턴 확인) |
| **성능 최적화** | `builder-rule.md` | `refactoring-rule.md` | `vercel-react-best-practices` | `context7` (최신 최적화 기법) |
| **주석 + 리팩토링** | `refactoring-rule.md` | `doc-rule.md` | `vercel-react-best-practices` | `sequential-thinking` |
---
## 🔄 작업 흐름별 세부 가이드
### 1⃣ 새 기능 개발 (Feature Development)
**트리거 키워드**: "새로운 기능", "컴포넌트 추가", "API 연동", "페이지 생성"
**작업 순서**:
```
[1단계] builder-rule.md 기준으로 구조 설계
[2단계] nextjs-app-router-patterns로 Next.js App Router 패턴 확인
↓ (복잡한 로직이 있다면)
[2-1] sequential-thinking으로 로직 검증
↓ (SpreadJS 등 외부 라이브러리 사용 시)
[2-2] context7로 공식 문서 조회
[3단계] vercel-react-best-practices로 성능 최적화 패턴 적용
[4단계] builder-rule.md의 출력 형식대로 코드 제공
- [가정] 섹션
- 핵심 코드 블록
- 한 줄 한 줄 뜯어보기
- 작동 흐름
- 핵심 포인트
```
**구체적 통합 예시**:
```markdown
# [예시] CreateLeadDialog 컴포넌트 개발
## [1단계] builder-rule.md 적용
- Tech Stack 확인: Next.js 15.3, React 19, TanStack Query v5, Zustand v5
- 폴더 구조: `src/features/leads/components/CreateLeadDialog.tsx`
- 'use client' 필요 (Form 인터랙션)
## [2단계] nextjs-app-router-patterns 참고
- Client Component는 'use client' 선언
- Server Action 사용 시 "use server" 분리
- Suspense 경계 설정
## [2-1] sequential-thinking (복잡한 검증 로직이 있는 경우)
- Form 제출 → 사전 검증 → API 호출 → 성공/실패 처리
- 엣지 케이스: 중복 제출, 네트워크 오류, 필수값 누락
## [3단계] vercel-react-best-practices 적용
- `rerender-memo`: 무거운 Form 로직은 memo로 감싸기
- `client-swr-dedup`: TanStack Query로 중복 요청 방지
- `rendering-conditional-render`: 조건부 렌더링은 삼항 연산자 사용
## [4단계] 최종 코드 출력
- builder-rule.md의 출력 형식 준수
- 주석은 한글로, 작성자 'jihoon87.lee'
```
---
### 2⃣ 코드 분석/이해 (Code Analysis)
**트리거 키워드**: "코드 분석", "흐름 설명", "어떻게 작동", "플로우 파악"
**작업 순서**:
```
[1단계] code-analysis-rule.md의 분석 순서 준수
- 진입점 (app/page)
- 컴포넌트 시작
- Hook 호출
- API → 상태 → 리렌더
- TypeScript 타입 설명
↓ (복잡한 흐름인 경우)
[1-1] sequential-thinking으로 논리적 단계 검증
[2단계] nextjs-app-router-patterns로 Next.js 구조 매핑
- Server Component vs Client Component
- Parallel Routes, Intercepting Routes 등
[3단계] code-analysis-rule.md 응답 템플릿 사용
- 한글 주석
- 플로우 다이어그램
- 리렌더링 조건 표
```
**통합 예시**:
```markdown
# [예시] PersonnelTableContainer 분석
## [적용 룰]
- code-analysis-rule.md: 분석 순서 및 템플릿
- nextjs-app-router-patterns: Server/Client 구분
- sequential-thinking: 데이터 페칭 순서 검증
## [분석 결과]
### 1⃣ 진입점
- URL: /standards/personnel
- Server Component (page.tsx) → Client Component (PersonnelTableContainer)
### 3⃣ 컴포넌트 시작 플로우 (sequential-thinking 검증)
【1단계】useState 초기값 설정
【2단계】useDataTablePersonnel 훅 호출
【3단계】TanStack Query가 queryFn 실행
【4단계】personnelApi.getList() 호출
【5단계】응답 데이터를 Query 캐시에 저장
【6단계】컴포넌트 리렌더링
```
---
### 3⃣ 주석 추가 (Documentation)
**트리거 키워드**: "주석 추가", "문서화", "JSDoc 작성"
**작업 순서**:
```
[1단계] code-analysis-rule.md로 코드 흐름 파악
[2단계] doc-rule.md 규칙 적용
- 파일 상단 TSDoc
- 함수/타입 TSDoc
- Step 주석 (복잡한 함수만)
[3단계] 코드 변경 없이 주석만 추가
```
**통합 예시**:
```typescript
/**
* @file PersonnelTableContainer.tsx
* @description 인사 기준정보 목록 조회 및 관리 컨테이너
* @author jihoon87.lee
* @remarks
* - [레이어] Components (UI)
* - [사용자 행동] 목록 조회 → 검색/필터 → 상세 → 편집/삭제
* - [데이터 흐름] UI → useDataTablePersonnel → personnelApi → TanStack Query 캐시 → UI
* - [연관 파일] useDataTablePersonnel.ts, personnelApi.ts
*/
"use client";
// (code-analysis-rule로 분석 → doc-rule로 주석 추가)
```
---
### 4⃣ 리팩토링 (Refactoring)
**트리거 키워드**: "리팩토링", "구조 개선", "폴더 정리", "성능 개선"
**작업 순서**:
```
[1단계] refactoring-rule.md의 워크플로우 적용
[1-1] sequential-thinking으로 의존성 지도 작성
- 파일 이동 전 영향 범위 분석
- import 경로 변경 목록 작성
[1-2] context7로 최신 폴더 구조 패턴 확인
- TanStack Query v5 권장 구조
- Next.js 15 App Router 최적화
[2단계] refactoring-rule.md의 표준 구조로 재구성
- apis/, hooks/, types/, stores/, components/
[3단계] vercel-react-best-practices로 성능 최적화
- bundle-barrel-imports: 직접 import
- rerender-memo: 불필요한 리렌더 방지
[4단계] builder-rule.md로 재구현 (필요 시)
```
**통합 예시**:
```markdown
# [예시] work-execution 기능 리팩토링
## [1단계] sequential-thinking 의존성 분석
- 현재 파일: workExecutionOld.tsx (800줄, 단일 파일)
- 의존하는 외부 파일: app/standards/work-execution/page.tsx
- 영향받는 import: 3개 파일
## [1-2] context7 최신 패턴 조회
- TanStack Query v5: queryKeys를 별도 파일로 분리 권장
- Next.js 15: Parallel Routes로 loading 상태 분리 가능
## [2단계] refactoring-rule 표준 구조 적용
src/features/standards/work-execution/
├── apis/
│ ├── workExecution.api.ts
│ └── workExecutionForm.adapter.ts
├── hooks/
│ ├── queryKeys.ts
│ └── useWorkExecutionList.ts
├── types/
│ └── workExecution.types.ts
├── stores/
│ └── workExecutionStore.ts
└── components/
├── WorkExecutionContainer.tsx
└── WorkExecutionModal.tsx
## [3단계] vercel-react-best-practices 적용
- bundle-barrel-imports: index.ts 제거, 직접 경로 사용
- rerender-memo: WorkExecutionModal을 React.memo로 감싸기
- async-parallel: API 호출을 Promise.all로 병렬화
```
---
## 🛠️ MCP 도구 활용 가이드
### Sequential Thinking 사용 시점
1. **복잡한 비즈니스 로직 구현 전**
- 예: 다단계 Form 검증, 복잡한 상태 머신
- 목적: 엣지 케이스 사전 도출
2. **리팩토링 시 의존성 분석**
- 예: 파일 이동 시 영향 범위 파악
- 목적: Broken Import 방지
3. **코드 분석 시 데이터 플로우 검증**
- 예: 브라우저 렌더링 단계, 데이터 페칭 순서
- 목적: 논리적 흐름 명확화
### Context7 사용 시점
1. **외부 라이브러리 최신 API 확인**
- 예: SpreadJS v18, TanStack Query v5
- 목적: 공식 문서 기반 정확한 구현
2. **리팩토링 시 최신 패턴 확인**
- 예: Next.js 15 App Router 권장 구조
- 목적: 최신 표준과 프로젝트 룰 결합
3. **성능 최적화 검증**
- 예: React 19 신규 Hook 활용법
- 목적: 최신 기법 적용
---
## 📌 실전 적용 예시
### 예시 1: 새 기능 개발 요청
**사용자 요청**: "리드 생성 모달을 만들어줘"
**AI 작업 프로세스**:
1. `builder-rule.md` 로드 → Tech Stack 확인
2. `nextjs-app-router-patterns` 참고 → Client Component 패턴 확인
3. `vercel-react-best-practices` 적용 → `rerender-memo`, `rendering-conditional-render`
4. `builder-rule.md` 출력 형식으로 코드 제공
### 예시 2: 코드 플로우 분석 요청
**사용자 요청**: "PersonnelTableContainer가 어떻게 작동하는지 설명해줘"
**AI 작업 프로세스**:
1. `code-analysis-rule.md` 로드 → 분석 순서 준수
2. `sequential-thinking` 사용 → 데이터 페칭 순서 검증
3. `nextjs-app-router-patterns` 참고 → Server/Client 구분 설명
4. `code-analysis-rule.md` 템플릿으로 결과 출력
### 예시 3: 리팩토링 요청
**사용자 요청**: "work-execution 폴더를 정리해줘"
**AI 작업 프로세스**:
1. `refactoring-rule.md` 로드 → 워크플로우 확인
2. `sequential-thinking` 사용 → 의존성 지도 작성
3. `context7` 조회 → TanStack Query v5 권장 구조 확인
4. `refactoring-rule.md` 표준 구조로 재구성
5. `vercel-react-best-practices` 적용 → `bundle-barrel-imports`, `rerender-memo`
---
## ✅ 체크리스트
작업 시작 전 항상 확인:
- [ ] 작업 유형이 무엇인가? (개발/분석/주석/리팩토링)
- [ ] 주 룰(Primary)과 보조 룰(Secondary)은?
- [ ] 어떤 Skill을 참고해야 하는가?
- [ ] MCP 도구(sequential-thinking, context7)가 필요한가?
- [ ] 출력 형식은 어떤 룰을 따르는가?
---
## 🎓 학습 자료
- **builder-rule.md**: Tech Stack, 코딩 원칙, 출력 형식
- **code-analysis-rule.md**: 분석 순서, TypeScript 타입 설명
- **doc-rule.md**: TSDoc 형식, Step 주석 규칙
- **refactoring-rule.md**: 폴더 구조, 워크플로우
- **nextjs-app-router-patterns**: Next.js 패턴, Server/Client 구분
- **vercel-react-best-practices**: 성능 최적화 57개 룰

View File

@@ -0,0 +1,94 @@
---
trigger: manual
---
# 역할: Anti-Gravity Refactoring Specialist
당신은 psix-frontend 프로젝트의 **구조적 개선 및 리팩토링 전문가**입니다.
기존 스파게티 코드나 레거시 구조를 **모던하고 유지보수 가능한 표준 구조**로 재설계합니다.
---
## 📥 입력 (Input)
- **FEATURE_ROOT**: 리팩토링할 기능의 루트 폴더 경로
- 예) `src/features/standards/work-execution`
---
## 🎯 목표 (Goal)
1. **표준 폴더 구조 지향**: `apis`, `components`, `hooks`, `stores`, `types` 5대 폴더를 기본으로 구성한다.
2. **유연성 허용**: 필요에 따라 `utils`(유틸리티), `lib`(라이브러리 래퍼), `constants`(상수) 등 보조 폴더 생성을 허용한다.
3. **단일 파일 분해**: 거대한 파일은 기능 단위로 쪼개야 함
4. **배럴 파일 제거**: `index.ts`를 사용한 re-export 패턴을 제거하고 직접 경로(`.../components/MyComponent`)를 사용
---
## 🛠️ MCP 도구 활용 (분석 및 설계 지침)
### 1. Sequential Thinking 활용 (의존성 및 리스크 분석)
- **적용 시점**: `1) FEATURE_ROOT의 기존 구조와 import 의존성 분석` 단계에서 필수 사용
- **수행 작업**:
- 실제 파일을 옮기기 전, `sequential-thinking`을 사용하여 이동할 파일들의 의존성 지도(Dependency Map)를 먼저 그린다.
- 파일 이동 시 영향받는 외부 파일(page.tsx 등)의 리스트를 미리 확보한다.
- 수정해야 할 import 경로가 많은 경우, 논리적 순차 단계를 설정하여 하나씩 해결함으로써 경로 오류(Broken Import)를 방지한다.
### 2. Context7 활용 (기술 표준 검증)
- **적용 시점**: 폴더 구조 재구성 중 최신 라이브러리 패턴이 가이드와 충돌하거나 모호할 때 사용
- **수행 작업**:
- TanStack Query의 최신 v5 권장 폴더 구조나 Next.js 15의 App Router 최적화 기법이 필요할 경우 `context7`을 통해 공식 문서를 조회한다.
- 조회된 최신 표준과 본 룰의 구조(`apis`, `hooks`, `types` 등)를 결합하여 개발자가 유지보수하기 가장 편한 최적의 경로를 도출한다.
---
## 📋 작업 지시 (Workflow)
1. **분석**: `FEATURE_ROOT` 내의 기존 파일 구조와 외부 의존성(import)을 파악한다. (MCP 활용)
2. **구조 설계**:
- **기본 폴더**: `apis`, `hooks`, `types`, `stores`, `components`
- **선택 폴더**: `utils` (순수 함수), `lib` (설정/래퍼), `constants` (상수 데이터)
- 위 기준에 맞춰 파일 분류 계획을 세운다.
3. **이동 및 생성**: 파일을 계획된 폴더로 이동하거나 분리 생성한다.
4. **경로 수정**: 이동된 파일에 맞춰 모든 `import` 경로를 업데이트한다.
5. **청소**: 불필요해진 폴더(구조상 매핑되지 않는 옛 폴더)와 `index.ts` 파일을 삭제한다.
6. **진입점 갱신**: `page.tsx` 등 외부에서 해당 기능을 사용하는 곳의 import 경로를 수정한다.
---
## 🏗️ 권장 파일 구조 (Standard Structure)
```text
<FEATURE_ROOT>/
├── apis/
│ ├── apiError.ts
│ ├── <feature>.api.ts # API 호출 로직
│ ├── <feature>Form.adapter.ts # Form <-> API 변환
│ └── <feature>List.adapter.ts # List <-> API 변환
├── hooks/
│ ├── queryKeys.ts # Query Key Factory
│ ├── use<Feature>List.ts # 목록 조회 Hooks
│ ├── use<Feature>Mutations.ts # CUD Hooks
│ └── use<Feature>Form.ts # Form Logic Hooks
├── types/
│ ├── api.types.ts # 공통 API 응답 규격
│ ├── <feature>.types.ts # 도메인 Entity
│ └── selectOption.types.ts # 공통 Select Option
├── stores/
│ └── <feature>Store.ts # Zustand Store
├── components/
│ ├── <Feature>Container.tsx # 메인 컨테이너
│ └── <Feature>Modal.tsx # 모달 컴포넌트
├── utils/ # (Optional)
│ └── <feature>Utils.ts # 순수 헬퍼 함수
└── constants/ # (Optional)
└── <feature>.constants.ts # 상수 (components 내부에 둬도 무방)
```
---
## ⚠️ 규칙 (Rules)
1. **로직 변경 금지**: 오직 파일 위치와 구조만 변경하며, 비즈니스 로직은 건드리지 않는다.
2. **Naming Convention**:
- 파일명은 **ASCII 영문**만 사용 (한글 금지)
- UI 컴포넌트 파일: `PascalCase` (예: `WorkExecutionContainer.tsx`)
- Hooks 및 일반 파일: `camelCase` (예: `useWorkExecutionList.ts`)
3. **Clean Import**: import 시 불필요한 별칭(alias)보다는 명확한 상대/절대 경로를 사용한다.

View File

@@ -0,0 +1,96 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", or "is there a skill that can help with X".
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** <https://skills.sh/>
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Search for Skills
Run the find command with a relevant query:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 3: Present Recommendations
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The installation command
3. A link to the skill's page
**Example response:**
> I found a skill that might help!
>
> **vercel-react-best-practices**
> Vercel's official React performance guidelines for AI agents.
>
> To install it:
> `npx skills add vercel-labs/agent-skills@vercel-react-best-practices`
>
> Learn more: <https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices>
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add vercel-labs/agent-skills@vercel-react-best-practices
```
### Step 4: Verify Installation (Optional)
After installing, you can verify it was installed correctly:
```bash
npx skills list
```
## When No Skills Are Found
1. Try a broader search term
2. Check the [skills.sh](https://skills.sh/) website manually if you suspect a network issue
3. Suggest the user could create their own skill with `npx skills init`

View File

@@ -0,0 +1,65 @@
---
name: nextjs-app-router-patterns
description: Best practices and patterns for building applications with Next.js App Router (v13+).
---
# Next.js App Router Patterns
## Core Principles
### Server-First by Default
- **Use Server Components** for everything possible (data fetching, layout, static content).
- **Use Client Components** (`"use client"`) only when interactivity (hooks, event listeners) is needed.
- **Pass Data Down**: Fetch data in Server Components and pass it as props to Client Components.
- **Composition**: Wrap Client Components around Server Components to avoid "rendering undefined" issues or waterfall de-opts.
### Routing & Layouts
- **File Structure**:
- `page.tsx`: Route UI.
- `layout.tsx`: Shared UI (wraps pages).
- `loading.tsx`: Loading state (Suspense).
- `error.tsx`: Error boundary.
- `not-found.tsx`: 404 UI.
- `template.tsx`: Layout that re-mounts on navigation.
- **Parallel Routes**: Use `@folder` for parallel UI (e.g. dashboards).
- **Intercepting Routes**: Use `(..)` to intercept navigation (e.g. modals).
- **Route Groups**: Use `(group)` to organize routes without affecting the URL path.
## Data Fetching Patterns
### Server Side
- **Direct Async/Await**: `const data = await fetch(...)` inside the component.
- **Request Memoization**: `fetch` is automatically memoized. For DB calls, use `React.cache`.
- **Data Caching**:
- `fetch(url, { next: { revalidate: 3600 } })` for ISR.
- `fetch(url, { cache: 'no-store' })` for SSR.
- Use `unstable_cache` for caching DB results.
### Client Side
- Use **SWR** or **TanStack Query** for client-side fetching.
- Avoid `useEffect` for data fetching to prevent waterfalls.
- Prefetch data using `queryClient.prefetchQuery` in Server Components and hydrate on client.
## Server Actions
- Use **Server Actions** (`"use server"`) for mutations (form submissions, button clicks).
- Define actions in separate files (e.g. `actions.ts`) for better organization and security.
- Use `useFormState` (or `useActionState` in React 19) to handle loading/error states.
## Optimization
- **Images**: Use `next/image` for automatic resizing and format conversion.
- **Fonts**: Use `next/font` to eliminate layout shift (CLS).
- **Scripts**: Use `next/script` with `strategy="afterInteractive"`.
- **Streaming**: Use `<Suspense>` to stream parts of the UI (e.g. slow data fetches).
## Common Anti-Patterns to Avoid
1. **Fetching in Client Components without cache lib**: Leads to waterfalls.
2. **"use client" at top level layout**: Forces the entire tree to be client-side.
3. **Prop Drilling**: specialized `Context` should be used sparingly; prefer Composition.
4. **Large Barrel Files**: Avoid `index.ts` exporting everything; import directly to aid tree-shaking.

View File

@@ -0,0 +1,95 @@
---
name: vercel-react-best-practices
description: React and Next.js performance optimization guidelines from Vercel Engineering. This skill should be used when writing, reviewing, or refactoring React/Next.js code to ensure optimal performance patterns. Triggers on tasks involving React components, Next.js pages, data fetching, bundle optimization, or performance improvements.
license: MIT
metadata:
author: vercel
version: "1.0.0"
---
# Vercel React Best Practices
Comprehensive performance optimization guide for React and Next.js applications, maintained by Vercel. Contains 57 rules across 8 categories, prioritized by impact to guide automated refactoring and code generation.
## When to Apply
Reference these guidelines when:
- Writing new React components or Next.js pages
- Implementing data fetching (client or server-side)
- Reviewing code for performance issues
- Refactoring existing React/Next.js code
- Optimizing bundle size or load times
## Rule Categories by Priority
| Priority | Category | Impact | Prefix |
| -------- | ------------------------- | ----------- | ------------ |
| 1 | Eliminating Waterfalls | CRITICAL | `async-` |
| 2 | Bundle Size Optimization | CRITICAL | `bundle-` |
| 3 | Server-Side Performance | HIGH | `server-` |
| 4 | Client-Side Data Fetching | MEDIUM-HIGH | `client-` |
| 5 | Re-render Optimization | MEDIUM | `rerender-` |
| 6 | Rendering Performance | MEDIUM | `rendering-` |
| 7 | JavaScript Performance | LOW-MEDIUM | `js-` |
| 8 | Advanced Patterns | LOW | `advanced-` |
## Quick Reference
### 1. Eliminating Waterfalls (CRITICAL)
- `async-defer-await` - Move await into branches where actually used
- `async-parallel` - Use Promise.all() for independent operations
- `async-dependencies` - Use better-all for partial dependencies
- `async-api-routes` - Start promises early, await late in API routes
- `async-suspense-boundaries` - Use Suspense to stream content
### 2. Bundle Size Optimization (CRITICAL)
- `bundle-barrel-imports` - Avoid large barrel files; use direct imports
- `bundle-large-libraries` - Optimize heavy deps (e.g. framer-motion, lucide)
- `bundle-conditional` - Lazy load conditional components
- `bundle-route-split` - Split huge page components
- `bundle-dynamic-imports` - Use next/dynamic for heavy client components
### 3. Server-Side Performance (HIGH)
- `server-cache-react` - Use React.cache() for per-request deduplication
- `server-cache-next` - Use unstable_cache for data coaching
- `server-only-utils` - Mark server-only code with 'server-only' package
- `server-component-boundaries` - Keep client components at leaves
- `server-image-optimization` - Use next/image with proper sizing
### 4. Client-Side Data Fetching (MEDIUM-HIGH)
- `client-use-swr` - Use SWR/TanStack Query for client-side data
- `client-no-effect-fetch` - Avoid fetching in useEffect without caching libraries
- `client-prefetch-link` - Use next/link prefetching
- `client-caching-headers` - Respect cache-control headers
### 5. Re-render Optimization (MEDIUM)
- `rerender-memo-props` - Memoize complex props
- `rerender-dependencies` - Use primitive dependencies in effects
- `rerender-functional-setstate` - Use functional setState for stable callbacks
- `rerender-context-split` - Split context to avoid wide re-renders
### 6. Rendering Performance (MEDIUM)
- `rendering-image-priority` - Priority load LCP images
- `rendering-list-virtualization` - Virtualize long lists
- `rendering-content-visibility` - Use content-visibility for long lists
- `rendering-hoist-jsx` - Extract static JSX outside components
- `rendering-hydration-no-flicker` - Use inline script for client-only data
### 7. JavaScript Performance (LOW-MEDIUM)
- `js-batch-dom-css` - Group CSS changes
- `js-index-maps` - Build Map for repeated lookups
- `js-combine-iterations` - Combine multiple filter/map into one loop
- `js-set-map-lookups` - Use Set/Map for O(1) lookups
### 8. Advanced Patterns (LOW)
- `advanced-event-handler-refs` - Store event handlers in refs
- `advanced-init-once` - Initialize app once per app load

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Supabase 환경 설정 예제 파일
# 이 파일을 .env.local로 복사한 뒤 실제 값을 채워 주세요.
# 값 확인: https://supabase.com/dashboard/project/_/settings/api
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# 세션 타임아웃(분 단위)
NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES=30
# KIS 거래 모드: real(실전) | mock(모의)
KIS_TRADING_ENV=real
# 서버 기본 키를 쓰고 싶은 경우(선택)
KIS_APP_KEY_REAL=
KIS_APP_SECRET_REAL=
KIS_BASE_URL_REAL=https://openapi.koreainvestment.com:9443
KIS_WS_URL_REAL=ws://ops.koreainvestment.com:21000
KIS_APP_KEY_MOCK=
KIS_APP_SECRET_MOCK=
KIS_BASE_URL_MOCK=https://openapivts.koreainvestment.com:29443
KIS_WS_URL_MOCK=ws://ops.koreainvestment.com:31000

126
.gitignore vendored Normal file
View File

@@ -0,0 +1,126 @@
# ========================================
# Dependencies (의존성)
# ========================================
node_modules/
.pnp/
.pnp.js
# ========================================
# Build outputs (빌드 출력물)
# ========================================
.next/
out/
build/
dist/
# ========================================
# Testing (테스트)
# ========================================
coverage/
.nyc_output/
# ========================================
# Environment files (환경변수 파일)
# ========================================
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*.local
# ★ 예제 파일은 공유해야 하므로 예외 처리 (깃에 올라감)
!.env.example
# ========================================
# IDE & Editor (에디터 설정)
# ========================================
.idea/
.vscode/
*.swp
*.swo
*~
# ========================================
# OS generated files (OS 생성 파일)
# ========================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# ========================================
# Debug logs (디버그 로그)
# ========================================
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
lerna-debug.log*
# ========================================
# TypeScript (타입스크립트)
# ========================================
*.tsbuildinfo
next-env.d.ts
# ========================================
# Turbopack (터보팩)
# ========================================
.turbo/
# ========================================
# Vercel (배포 관련)
# ========================================
.vercel/
# ========================================
# PWA files (PWA 관련)
# ========================================
public/sw.js
public/workbox-*.js
public/worker-*.js
public/sw.js.map
public/workbox-*.js.map
# ========================================
# Misc (기타)
# ========================================
*.pem
*.log
*.pid
*.seed
*.pid.lock
# ========================================
# Lock files (선택 - 협업 시 주석 해제)
# ========================================
# package-lock.json
# yarn.lock
# pnpm-lock.yaml
# ========================================
# Sentry (에러 모니터링)
# ========================================
.sentryclirc
# ========================================
# Storybook (스토리북)
# ========================================
storybook-static/
# ========================================
# Local files (로컬 전용)
# ========================================
*.local
.cache/
node_modules
# ========================================
# Custom
# ========================================
.playwright-mcp/

View File

@@ -0,0 +1 @@
{"real:7777b1c958636e65539aadf6c4273a0dfa33c17e55d1beb7dbfd033d49457f6e":{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ0b2tlbiIsImF1ZCI6ImFkMGFjN2Y3LWY5YTYtNDRlNy1iOGRjLWU0ZjRhY2Q0YmQ2NyIsInByZHRfY2QiOiIiLCJpc3MiOiJ1bm9ndyIsImV4cCI6MTc3MDcwNzkzMiwiaWF0IjoxNzcwNjIxNTMyLCJqdGkiOiJQUzA2TG12SndjRHl1MDZLVXpPRjVsSHJacWR6ZWJKTXhCVWIifQ.P1_T4XoIxPDyNIxS3rx73IqlNLpZRmKKXOtan74A7D19On9JlsIQIpTY8bVGPFfRxFkC0vfCZ1qDX-xpxGi6SA","expiresAt":1770707932000}}

1
.tmp/open-trading-api Submodule

Submodule .tmp/open-trading-api added at aea5e779da

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"chatgpt.openOnStartup": false
}

45
AGENTS.md Normal file
View File

@@ -0,0 +1,45 @@
# AGENTS.md (auto-trade)
## 기본 원칙
- 모든 응답과 설명은 한국어로 작성.
- 쉬운 말로 설명. 어려운 용어는 괄호로 짧게 뜻을 덧붙임.
- 요청이 모호하면 먼저 질문 1~3개로 범위를 확인.
## 프로젝트 요약
- Next.js 16 App Router, React 19, TypeScript
- 상태 관리: zustand
- 데이터: Supabase
- 폼 및 검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI (components.json 사용)
## 명령어
- 개발 서버: (포트는 3001번이야)
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
## 코드 및 문서 규칙
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수 및 컴포넌트 JSDoc에 @see 필수 (호출 파일, 함수 또는 이벤트 이름, 목적 포함)
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
## 브랜드 색상 규칙
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `brand` 팔레트를 사용.
- 새 UI 작성 시 `indigo/purple/pink` 하드코딩 대신 `brand-*` 토큰 사용. 예: `bg-brand-500`, `text-brand-600`, `from-brand-500 to-brand-700`.
- 기본 액션 색(버튼/포커스)은 `primary`를 사용하고, `primary``app/globals.css``brand` 팔레트와 같은 톤으로 유지.
- 색상 변경이 필요하면 컴포넌트 개별 수정보다 먼저 `app/globals.css` 토큰을 수정.
## 설명 방식
- 단계별로 짧게, 예시는 1개만.
- 사용자가 요청한 변경과 이유를 함께 설명.
- 파일 경로는 pp/...처럼 코드 형식으로 표기.
## 여러 도구를 함께 쓸 때 (쉬운 설명)
- 기준 설명을 한 군데에 모아두고, 그 파일만 계속 업데이트하는 것이 핵심.
- 예를 들어 PROJECT_CONTEXT.md에 스택, 폴더 구조, 규칙을 적어둔다.
- 그리고 각 도구의 규칙 파일에 PROJECT_CONTEXT.md를 참고라고 써 둔다.
- 이렇게 하면 어떤 도구를 쓰든 같은 파일을 읽게 되어, 매번 다시 설명할 일이 줄어든다.

48
PROJECT_CONTEXT.md Normal file
View File

@@ -0,0 +1,48 @@
# PROJECT_CONTEXT.md
이 파일은 프로젝트 설명의 기준(원본)입니다.
여기만 업데이트하고, 다른 도구 규칙에서는 이 파일을 참고하도록 연결하세요.
## 한 줄 요약
- 자동매매(오토 트레이드) 웹 앱
## 기술 스택
- Next.js 16 (App Router)
- React 19, TypeScript
- 상태 관리: zustand
- 데이터: Supabase
- 폼/검증: react-hook-form, zod
- UI: Tailwind CSS v4, Radix UI
## 폴더 구조(핵심만)
- pp/ 라우팅 및 페이지
- eatures/ 도메인별 기능
- components/ 공용 UI
- lib/ 유틸/클라이언트
- utils/ 헬퍼
## 주요 규칙(요약)
- JSX 섹션 주석 형식: {/* ========== SECTION NAME ========== */}
- 함수/컴포넌트 JSDoc에 @see 필수
- 파일 상단에 @author jihoon87.lee
- 상태/이벤트/복잡 로직에 인라인 주석 충분히 작성
## 작업 흐름
- 개발 서버:
pm run dev
- 린트:
pm run lint
- 빌드:
pm run build
- 실행:
pm run start
## 자주 하는 설명 템플릿
- 변경 이유: (왜 바꾸는지)
- 변경 내용: (무엇을 바꾸는지)
- 영향 범위: (어디에 영향이 있는지)
## 업데이트 가이드
- 새 규칙/패턴이 생기면 여기에 먼저 추가
- 문장이 길어지면 더 짧게 요약
- 도구별 규칙 파일에는 PROJECT_CONTEXT.md 참고만 적기

View File

@@ -1 +1,36 @@
# auto-trade
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,86 @@
import FormMessage from "@/components/form-message";
import { requestPasswordReset } from "@/features/auth/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
/**
* [비밀번호 찾기 페이지]
*
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
* - 이메일 입력 폼 제공
* - 서버 액션(requestPasswordReset)과 연동
*/
export default async function ForgotPasswordPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
<span className="text-sm font-semibold">MAIL</span>
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
<br />
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<form className="space-y-5">
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
autoComplete="email"
required
className="h-11 transition-all duration-200"
/>
</div>
<Button
formAction={requestPasswordReset}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
>
</Button>
</form>
<div className="text-center">
<Link
href={AUTH_ROUTES.LOGIN}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
>
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

33
app/(auth)/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import { Header } from "@/features/layout/components/header";
import { createClient } from "@/utils/supabase/server";
export default async function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden bg-linear-to-br from-gray-50 via-white to-gray-100 dark:from-black dark:via-gray-950 dark:to-gray-900">
{/* ========== 헤더 (홈 이동용) ========== */}
<Header user={user} />
{/* ========== 배경 그라디언트 레이어 ========== */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-gray-200/30 via-gray-100/15 to-transparent dark:from-gray-800/30 dark:via-gray-900/20" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-gray-300/30 via-gray-200/15 to-transparent dark:from-gray-700/30 dark:via-gray-800/20" />
{/* ========== 애니메이션 블러 효과 ========== */}
<div className="absolute left-1/4 top-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-300/20 blur-3xl dark:bg-gray-700/20" />
<div className="absolute bottom-1/4 right-1/4 h-64 w-64 animate-pulse rounded-full bg-gray-400/20 blur-3xl delay-700 dark:bg-gray-600/20" />
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
{children}
</main>
</div>
);
}

62
app/(auth)/login/page.tsx Normal file
View File

@@ -0,0 +1,62 @@
import FormMessage from "@/components/form-message";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import LoginForm from "@/features/auth/components/login-form";
/**
* [로그인 페이지 컴포넌트]
*
* Modern UI with glassmorphism effect (유리 형태 디자인)
* - 투명 배경 + 블러 효과로 깊이감 표현
* - 그라디언트 배경으로 생동감 추가
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
*
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
*/
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ message: string }>;
}) {
// URL에서 메시지 파라미터 추출 (로그인 실패 시 에러 메시지 표시)
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 에러/성공 메시지 표시 영역 */}
{/* URL 파라미터에 message가 있으면 표시됨 */}
<FormMessage message={message} />
{/* ========== 로그인 카드 (Glassmorphism) ========== */}
{/* bg-white/70: 70% 투명도의 흰색 배경 */}
{/* backdrop-blur-xl: 배경 블러 효과 (유리 느낌) */}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
{/* ========== 카드 헤더 영역 ========== */}
<CardHeader className="space-y-3 text-center">
{/* 아이콘 배경: 그라디언트 원형 */}
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
<span className="text-4xl">👋</span>
</div>
{/* 페이지 제목 */}
<CardTitle className="text-3xl font-bold tracking-tight">
!
</CardTitle>
{/* 페이지 설명 */}
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
{/* ========== 카드 콘텐츠 영역 (폼) ========== */}
<CardContent>
<LoginForm />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import FormMessage from "@/components/form-message";
import ResetPasswordForm from "@/features/auth/components/reset-password-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
/**
* [비밀번호 재설정 페이지]
*
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
* - URL에 포함된 토큰 검증은 Middleware 및 Auth Confirm Route에서 선행됩니다.
* - 유효한 세션(Recovery Mode)이 없으면 로그인 페이지로 리다이렉트됩니다.
*/
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ message?: string }>;
}) {
const params = await searchParams;
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect(`/login`);
}
const { message } = params;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{message && <FormMessage message={message} />}
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
<span className="text-sm font-semibold">PW</span>
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ResetPasswordForm />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
import FormMessage from "@/components/form-message";
import SignupForm from "@/features/auth/components/signup-form";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default async function SignupPage({
searchParams,
}: {
searchParams: Promise<{ message: string }>;
}) {
const { message } = await searchParams;
return (
<div className="relative z-10 w-full max-w-md animate-in fade-in slide-in-from-bottom-4 duration-700">
{/* 메시지 알림 */}
<FormMessage message={message} />
<Card className="border-white/20 bg-white/70 shadow-2xl backdrop-blur-xl dark:border-white/10 dark:bg-gray-900/70">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto mb-2 flex h-16 w-16 items-center justify-center rounded-2xl bg-linear-to-br from-gray-800 to-black shadow-lg dark:from-white dark:to-gray-200">
<span className="text-4xl">🚀</span>
</div>
<CardTitle className="text-3xl font-bold tracking-tight">
</CardTitle>
<CardDescription className="text-base">
.
</CardDescription>
</CardHeader>
{/* ========== 폼 영역 ========== */}
<CardContent className="space-y-6">
<SignupForm />
{/* ========== 로그인 링크 ========== */}
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
?{" "}
<Link
href={AUTH_ROUTES.LOGIN}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
>
</Link>
</p>
</CardContent>
</Card>
</div>
);
}

167
app/(home)/page.tsx Normal file
View File

@@ -0,0 +1,167 @@
/**
* @file app/(home)/page.tsx
* @description 서비스 메인 랜딩 페이지(Server Component)
*/
import Link from "next/link";
import { ArrowRight, BarChart3, ShieldCheck, Sparkles, Zap } from "lucide-react";
import { Header } from "@/features/layout/components/header";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import ShaderBackground from "@/components/ui/shader-background";
import { createClient } from "@/utils/supabase/server";
/**
* 홈 메인 랜딩 페이지
* @returns 랜딩 UI
* @see features/layout/components/header.tsx blendWithBackground 모드 헤더를 함께 사용
*/
export default async function HomePage() {
// [로그인 상태 조회] 사용자 유무에 따라 CTA 링크를 분기합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div className="flex min-h-screen flex-col overflow-x-hidden bg-transparent">
<Header user={user} showDashboardLink={true} blendWithBackground={true} />
<main className="relative isolate flex-1 pt-16">
{/* ========== SHADER BACKGROUND SECTION ========== */}
<ShaderBackground opacity={1} className="-z-20" />
{/* ========== HERO SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-10 pt-16 md:pt-24">
<div className="p-2 md:p-6">
<div className="mx-auto max-w-4xl text-center">
<span className="inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-xs font-semibold text-brand-200 [text-shadow:0_2px_24px_rgba(0,0,0,0.65)]">
<Sparkles className="h-3.5 w-3.5" />
Shader Background Landing
</span>
<h1 className="mt-5 text-4xl font-black tracking-tight text-white [text-shadow:0_4px_30px_rgba(0,0,0,0.6)] md:text-6xl">
<br />
<span className="bg-linear-to-r from-brand-300 via-brand-400 to-brand-500 bg-clip-text text-transparent">
</span>
</h1>
<p className="mx-auto mt-5 max-w-2xl text-sm leading-relaxed text-white/80 [text-shadow:0_2px_16px_rgba(0,0,0,0.5)] md:text-lg">
, ,
. .
</p>
<div className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
{/* [분기 렌더] 로그인 사용자는 대시보드, 비로그인 사용자는 가입/로그인 동선을 노출합니다. */}
{user ? (
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
<Link href={AUTH_ROUTES.DASHBOARD}>
<ArrowRight className="ml-1.5 h-4 w-4" />
</Link>
</Button>
) : (
<>
<Button asChild size="lg" className="h-12 rounded-full bg-primary px-8 text-base hover:bg-primary/90">
<Link href={AUTH_ROUTES.SIGNUP}>
<ArrowRight className="ml-1.5 h-4 w-4" />
</Link>
</Button>
<Button
asChild
variant="outline"
size="lg"
className="h-12 rounded-full border-white/40 bg-transparent px-8 text-base text-white hover:bg-white/10 hover:text-white"
>
<Link href={AUTH_ROUTES.LOGIN}></Link>
</Button>
</>
)}
</div>
</div>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<p className="text-xs text-white/70"> </p>
<p className="mt-1 text-lg font-bold">Low Latency</p>
</div>
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<p className="text-xs text-white/70"></p>
<p className="mt-1 text-lg font-bold"> </p>
</div>
<div className="rounded-2xl p-4 text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.35)]">
<p className="text-xs text-white/70"> </p>
<p className="mt-1 text-lg font-bold"> </p>
</div>
</div>
</div>
</section>
{/* ========== FEATURE SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 bg-transparent text-white shadow-none">
<CardHeader>
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<BarChart3 className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
<Card className="border-0 bg-transparent text-white shadow-none">
<CardHeader>
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<Zap className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
<Card className="border-0 bg-transparent text-white shadow-none">
<CardHeader>
<div className="mb-2 inline-flex h-10 w-10 items-center justify-center rounded-xl bg-brand-400/20 text-brand-200">
<ShieldCheck className="h-5 w-5" />
</div>
<CardTitle className="text-white [text-shadow:0_2px_12px_rgba(0,0,0,0.4)]"> </CardTitle>
</CardHeader>
<CardContent className="text-sm leading-relaxed text-white/75">
.
</CardContent>
</Card>
</div>
</section>
{/* ========== CTA SECTION ========== */}
<section className="container mx-auto max-w-7xl px-4 pb-20">
<div className="p-2 md:p-4">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
<div>
<p className="text-sm font-semibold text-brand-200 [text-shadow:0_2px_18px_rgba(0,0,0,0.45)]"> </p>
<h2 className="mt-1 text-2xl font-bold tracking-tight text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.45)] md:text-3xl">
AutoTrade에서
</h2>
</div>
<Button asChild className="h-11 rounded-full bg-primary px-7 hover:bg-primary/90">
<Link href={user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP}>
{user ? "대시보드 열기" : "회원가입 시작"}
<ArrowRight className="ml-1.5 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</section>
</main>
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* @file app/(main)/dashboard/page.tsx
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
*/
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
/**
* 대시보드 페이지
* @returns DashboardContainer UI
* @see features/dashboard/components/DashboardContainer.tsx 클라이언트 상호작용(검색/시세/차트)은 해당 컴포넌트가 담당합니다.
*/
export default async function DashboardPage() {
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) redirect("/login");
return <DashboardContainer />;
}

24
app/(main)/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import { Header } from "@/features/layout/components/header";
import { Sidebar } from "@/features/layout/components/sidebar";
import { createClient } from "@/utils/supabase/server";
export default async function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const {
data: { user },
} = await supabase.auth.getUser();
return (
<div className="flex min-h-screen flex-col bg-zinc-50 dark:bg-black">
<Header user={user} />
<div className="flex flex-1 pt-16">
<Sidebar />
<main className="flex-1 w-full p-6 md:p-8 lg:p-10">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import type {
DashboardChartTimeframe,
DashboardStockChartResponse,
} from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticChart } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
const VALID_TIMEFRAMES: DashboardChartTimeframe[] = [
"1m",
"30m",
"1h",
"1d",
"1w",
];
/**
* @file app/api/kis/domestic/chart/route.ts
* @description 국내주식 차트(분봉/일봉/주봉) 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
const timeframe = (
searchParams.get("timeframe") ?? "1d"
).trim() as DashboardChartTimeframe;
const cursor = (searchParams.get("cursor") ?? "").trim() || undefined;
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
if (!VALID_TIMEFRAMES.includes(timeframe)) {
return NextResponse.json(
{ error: "지원하지 않는 timeframe입니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 차트를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
try {
const chart = await getDomesticChart(
symbol,
timeframe,
credentials,
cursor,
);
const response: DashboardStockChartResponse = {
symbol,
timeframe,
candles: chart.candles,
nextCursor: chart.nextCursor,
hasMore: chart.hasMore,
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "KIS 차트 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { executeOrderCash } from "@/lib/kis/trade";
import {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/order-cash/route.ts
* @description 국내주식 현금 주문 API
*/
export async function POST(request: NextRequest) {
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const body = (await request.json()) as DashboardStockCashOrderRequest;
// TODO: Validate body fields (symbol, quantity, price, etc.)
if (
!body.symbol ||
!body.accountNo ||
!body.accountProductCode ||
body.quantity <= 0
) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message:
"주문 정보가 올바르지 않습니다. (종목코드, 계좌번호, 수량 확인)",
},
{ status: 400 },
);
}
const output = await executeOrderCash(
{
symbol: body.symbol,
side: body.side,
orderType: body.orderType,
quantity: body.quantity,
price: body.price,
accountNo: body.accountNo,
accountProductCode: body.accountProductCode,
},
credentials,
);
const response: DashboardStockCashOrderResponse = {
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "주문이 전송되었습니다.",
orderNo: output.ODNO,
orderTime: output.ORD_TMD,
orderOrgNo: output.KRX_FWDG_ORD_ORGNO,
};
return NextResponse.json(response);
} catch (error) {
const message =
error instanceof Error
? error.message
: "주문 전송 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
},
{ status: 500 },
);
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import {
getDomesticOrderBook,
KisDomesticOrderBookOutput,
} from "@/lib/kis/domestic";
import { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import {
KisCredentialInput,
hasKisConfig,
normalizeTradingEnv,
} from "@/lib/kis/config";
/**
* @file app/api/kis/domestic/orderbook/route.ts
* @description 국내주식 호가 조회 API
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json(
{ error: "symbol은 6자리 숫자여야 합니다." },
{ status: 400 },
);
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error: "KIS API 키 설정이 필요합니다.",
},
{ status: 400 },
);
}
try {
const raw = await getDomesticOrderBook(symbol, credentials);
const levels = Array.from({ length: 10 }, (_, i) => {
const idx = i + 1;
return {
askPrice: readOrderBookNumber(raw, `askp${idx}`),
bidPrice: readOrderBookNumber(raw, `bidp${idx}`),
askSize: readOrderBookNumber(raw, `askp_rsqn${idx}`),
bidSize: readOrderBookNumber(raw, `bidp_rsqn${idx}`),
};
});
const response: DashboardStockOrderBookResponse = {
symbol,
source: "kis",
levels,
totalAskSize: readOrderBookNumber(raw, "total_askp_rsqn"),
totalBidSize: readOrderBookNumber(raw, "total_bidp_rsqn"),
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "호가 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(
headers.get("x-kis-trading-env") ?? undefined,
);
return {
appKey,
appSecret,
tradingEnv,
};
}
/**
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
*/
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, key: string) {
const record = raw as Record<string, unknown>;
const direct = record[key];
const upper = record[key.toUpperCase()];
const value = direct ?? upper ?? "0";
const normalized =
typeof value === "string"
? value.replaceAll(",", "").trim()
: String(value ?? "0");
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : 0;
}

View File

@@ -0,0 +1,78 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import type { DashboardStockOverviewResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getDomesticOverview } from "@/lib/kis/domestic";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/domestic/overview/route.ts
* @description 국내주식 종목 상세(현재가 + 차트) API
*/
/**
* 국내주식 종목 상세 API
* @param request query string의 symbol(6자리 종목코드) 사용
* @returns 대시보드 상세 모델
*/
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const symbol = (searchParams.get("symbol") ?? "").trim();
if (!/^\d{6}$/.test(symbol)) {
return NextResponse.json({ error: "symbol은 6자리 숫자여야 합니다." }, { status: 400 });
}
const credentials = readKisCredentialsFromHeaders(request.headers);
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
error:
"대시보드 상단에서 KIS API 키를 입력하고 검증해 주세요. 키 정보가 없어서 시세를 조회할 수 없습니다.",
},
{ status: 400 },
);
}
const fallbackMeta = KOREAN_STOCK_INDEX.find((item) => item.symbol === symbol);
try {
const overview = await getDomesticOverview(symbol, fallbackMeta, credentials);
const response: DashboardStockOverviewResponse = {
stock: overview.stock,
source: "kis",
priceSource: overview.priceSource,
marketPhase: overview.marketPhase,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
fetchedAt: new Date().toISOString(),
};
return NextResponse.json(response, {
headers: {
"cache-control": "no-store",
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "KIS 조회 중 오류가 발생했습니다.";
return NextResponse.json({ error: message }, { status: 500 });
}
}
/**
* 요청 헤더에서 KIS 키를 읽어옵니다.
* @param headers 요청 헤더
* @returns credentials
*/
function readKisCredentialsFromHeaders(headers: Headers): KisCredentialInput {
const appKey = headers.get("x-kis-app-key")?.trim();
const appSecret = headers.get("x-kis-app-secret")?.trim();
const tradingEnv = normalizeTradingEnv(headers.get("x-kis-trading-env") ?? undefined);
return {
appKey,
appSecret,
tradingEnv,
};
}

View File

@@ -0,0 +1,108 @@
import { KOREAN_STOCK_INDEX } from "@/features/dashboard/data/korean-stocks";
import type {
DashboardStockSearchItem,
DashboardStockSearchResponse,
KoreanStockIndexItem,
} from "@/features/dashboard/types/dashboard.types";
import { NextRequest, NextResponse } from "next/server";
const SEARCH_LIMIT = 10;
/**
* @file app/api/kis/domestic/search/route.ts
* @description 국내주식 종목명/종목코드 검색 API
* @remarks
* - [레이어] API Route
* - [사용자 행동] 대시보드 검색창 엔터/검색 버튼 클릭 시 호출
* - [데이터 흐름] dashboard-main.tsx -> /api/kis/domestic/search -> KOREAN_STOCK_INDEX 필터/정렬 -> JSON 응답
* - [연관 파일] features/dashboard/data/korean-stocks.ts, features/dashboard/components/dashboard-main.tsx
* @author jihoon87.lee
*/
/**
* 국내주식 검색 API
* @param request query string의 q(검색어) 사용
* @returns 종목 검색 결과 목록
* @see features/dashboard/components/dashboard-main.tsx 검색 폼에서 호출합니다.
*/
export async function GET(request: NextRequest) {
// [Step 1] query string에서 검색어(q)를 읽고 공백을 제거합니다.
const { searchParams } = new URL(request.url);
const query = (searchParams.get("q") ?? "").trim();
// [Step 2] 검색어가 없으면 빈 목록을 즉시 반환해 불필요한 계산을 줄입니다.
if (!query) {
const response: DashboardStockSearchResponse = {
query,
items: [],
total: 0,
};
return NextResponse.json(response);
}
const normalized = normalizeKeyword(query);
// [Step 3] 인덱스에서 코드/이름 포함 여부로 1차 필터링 후 점수를 붙입니다.
const ranked = KOREAN_STOCK_INDEX.filter((item) => {
const symbol = item.symbol;
const name = normalizeKeyword(item.name);
return symbol.includes(normalized) || name.includes(normalized);
})
.map((item) => ({
item,
score: getSearchScore(item, normalized),
}))
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
if (a.item.market !== b.item.market) return a.item.market.localeCompare(b.item.market);
return a.item.name.localeCompare(b.item.name, "ko");
});
// [Step 4] UI에서 필요한 최소 필드만 남겨 SEARCH_LIMIT 만큼 반환합니다.
const items: DashboardStockSearchItem[] = ranked.slice(0, SEARCH_LIMIT).map(({ item }) => ({
symbol: item.symbol,
name: item.name,
market: item.market,
}));
const response: DashboardStockSearchResponse = {
query,
items,
total: ranked.length,
};
// [Step 5] DashboardStockSearchResponse 형태로 응답합니다.
return NextResponse.json(response);
}
/**
* 검색어 정규화(공백 제거 + 소문자)
* @param value 원본 문자열
* @returns 정규화 문자열
* @see app/api/kis/domestic/search/route.ts 한글/영문 검색 비교 정확도를 높입니다.
*/
function normalizeKeyword(value: string) {
return value.replaceAll(/\s+/g, "").toLowerCase();
}
/**
* 검색 결과 점수 계산
* @param item 종목 인덱스 항목
* @param normalizedQuery 정규화된 검색어
* @returns 높은 값일수록 우선순위 상위
* @see app/api/kis/domestic/search/route.ts 검색 결과 정렬 기준으로 사용합니다.
*/
function getSearchScore(item: KoreanStockIndexItem, normalizedQuery: string) {
const normalizedName = normalizeKeyword(item.name);
const normalizedSymbol = item.symbol.toLowerCase();
if (normalizedSymbol === normalizedQuery) return 120;
if (normalizedName === normalizedQuery) return 110;
if (normalizedSymbol.startsWith(normalizedQuery)) return 100;
if (normalizedName.startsWith(normalizedQuery)) return 90;
if (normalizedName.includes(normalizedQuery)) return 70;
if (normalizedSymbol.includes(normalizedQuery)) return 60;
return 0;
}

View File

@@ -0,0 +1,64 @@
import type { DashboardKisRevokeResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { revokeKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/revoke/route.ts
* @description 사용자 입력 KIS API 키 기반 접근토큰 폐기 라우트
*/
/**
* KIS API 접근토큰 폐기
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 폐기 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis - 접근 폐기 버튼 클릭 이벤트
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisRevokeRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisRevokeResponse,
{ status: 400 },
);
}
try {
const message = await revokeKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 접근 폐기 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisRevokeResponse,
{ status: 401 },
);
}
}
interface DashboardKisRevokeRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,65 @@
import type { DashboardKisValidateResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisAccessToken } from "@/lib/kis/token";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/validate/route.ts
* @description 사용자 입력 KIS API 키 검증 라우트
*/
/**
* KIS API 키 검증
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns 검증 성공/실패 정보
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis - 검증 버튼 클릭 시 호출
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisValidateRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisValidateResponse,
{ status: 400 },
);
}
try {
// 검증 단계는 토큰 발급 성공 여부만 확인합니다.
await getKisAccessToken(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
} satisfies DashboardKisValidateResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "API 키 검증 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisValidateResponse,
{ status: 401 },
);
}
}
interface DashboardKisValidateRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

View File

@@ -0,0 +1,67 @@
import type { DashboardKisWsApprovalResponse } from "@/features/dashboard/types/dashboard.types";
import type { KisCredentialInput } from "@/lib/kis/config";
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
import { NextRequest, NextResponse } from "next/server";
/**
* @file app/api/kis/ws/approval/route.ts
* @description KIS 웹소켓 approval key 발급 라우트
*/
/**
* 실시간 웹소켓 승인키 발급
* @param request appKey/appSecret/tradingEnv JSON 본문
* @returns approval key + ws url
* @see features/dashboard/components/dashboard-main.tsx connectKisRealtimePrice - 실시간 체결가 구독 진입점
*/
export async function POST(request: NextRequest) {
const body = (await request.json()) as Partial<DashboardKisWsApprovalRequest>;
const credentials: KisCredentialInput = {
appKey: body.appKey?.trim(),
appSecret: body.appSecret?.trim(),
tradingEnv: normalizeTradingEnv(body.tradingEnv),
};
if (!hasKisConfig(credentials)) {
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message: "앱 키와 앱 시크릿을 모두 입력해 주세요.",
} satisfies DashboardKisWsApprovalResponse,
{ status: 400 },
);
}
try {
const approvalKey = await getKisApprovalKey(credentials);
const wsUrl = resolveKisWebSocketUrl(credentials);
return NextResponse.json({
ok: true,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
approvalKey,
wsUrl,
message: "KIS 실시간 웹소켓 승인키 발급이 완료되었습니다.",
} satisfies DashboardKisWsApprovalResponse);
} catch (error) {
const message = error instanceof Error ? error.message : "웹소켓 승인키 발급 중 오류가 발생했습니다.";
return NextResponse.json(
{
ok: false,
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
message,
} satisfies DashboardKisWsApprovalResponse,
{ status: 401 },
);
}
}
interface DashboardKisWsApprovalRequest {
appKey: string;
appSecret: string;
tradingEnv: "real" | "mock";
}

121
app/auth/callback/route.ts Normal file
View File

@@ -0,0 +1,121 @@
import { createClient } from "@/utils/supabase/server";
import { type NextRequest, NextResponse } from "next/server"; // NextRequest 추가
import { AUTH_ERROR_MESSAGES, AUTH_ROUTES } from "@/features/auth/constants";
import { getAuthErrorMessage } from "@/features/auth/errors";
/**
* OAuth/이메일 인증 콜백 처리
*
* Supabase 인증 후 리다이렉트되는 라우트입니다.
* - 인증 코드를 세션으로 교환합니다.
* - 인증 에러를 처리합니다.
* - 최종 목적지(Next URL)로 리다이렉트합니다.
*/
export async function GET(request: NextRequest) {
// --------------------------------------------------------------------------
// 1. 요청 파라미터 및 URL 준비
// --------------------------------------------------------------------------
const requestUrl = request.nextUrl.clone(); // URL 조작을 위해 복제
const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next") ?? AUTH_ROUTES.HOME;
// 에러 파라미터 확인
const error = requestUrl.searchParams.get("error");
const error_code = requestUrl.searchParams.get("error_code");
const error_description = requestUrl.searchParams.get("error_description");
const origin = requestUrl.origin;
// --------------------------------------------------------------------------
// 2. 초기 에러 처리 (Provider 레벨 에러)
// --------------------------------------------------------------------------
if (error) {
console.error("Auth callback error parameter:", {
error,
error_code,
error_description,
});
let message: string = AUTH_ERROR_MESSAGES.DEFAULT;
if (error === "access_denied") {
message = AUTH_ERROR_MESSAGES.OAUTH_ACCESS_DENIED;
} else if (error === "server_error") {
message = AUTH_ERROR_MESSAGES.OAUTH_SERVER_ERROR;
}
// 로그인 페이지로 에러와 함께 이동
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
);
}
// --------------------------------------------------------------------------
// 3. 인증 코드 교환 (Supabase 공식 패턴 적용)
// --------------------------------------------------------------------------
if (code) {
const supabase = await createClient();
// 코드 교환 실행
const { error: exchangeError } =
await supabase.auth.exchangeCodeForSession(code);
if (!exchangeError) {
// ----------------------------------------------------------------------
// 3-1. 교환 성공: 리다이렉트 처리
// code 교환으로 세션이 생성된 상태입니다.
// ----------------------------------------------------------------------
// 회원가입 인증 여부 확인 (쿼리 파라미터 기반)
// actions.ts의 signup 함수에서 emailRedirectTo에 auth_type=signup을 추가해서 보냅니다.
const authType = requestUrl.searchParams.get("auth_type");
const isSignupVerification = authType === "signup";
// 회원가입 인증인 경우:
// 이메일 인증만 완료하고, 자동 로그인된 세션은 종료시킨 뒤 로그인 페이지로 보냅니다.
if (isSignupVerification) {
await supabase.auth.signOut();
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(
AUTH_ERROR_MESSAGES.EMAIL_VERIFIED_SUCCESS,
)}`,
);
}
// 그 외 일반적인 로그인/인증인 경우:
// 코드 파라미터 등을 제거하고 깨끗한 URL로 이동합니다.
const forwardedHost = request.headers.get("x-forwarded-host"); // 로드밸런서 지원
const isLocalEnv = process.env.NODE_ENV === "development";
// 리다이렉트할 최종 URL 설정
if (isLocalEnv) {
// 로컬 개발 환경
return NextResponse.redirect(`${origin}${next}`);
} else if (forwardedHost) {
// 프로덕션 환경 (Vercel 등 프록시 뒤)
return NextResponse.redirect(`https://${forwardedHost}${next}`);
} else {
// 기본
return NextResponse.redirect(`${origin}${next}`);
}
}
// ------------------------------------------------------------------------
// 3-2. 교환 실패: 에러 처리
// ------------------------------------------------------------------------
console.error("Auth exchange error:", exchangeError.message);
const message = getAuthErrorMessage(exchangeError);
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${encodeURIComponent(message)}`,
);
}
// --------------------------------------------------------------------------
// 4. 잘못된 접근 처리
// --------------------------------------------------------------------------
const errorMessage = encodeURIComponent(
AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK,
);
return NextResponse.redirect(
`${origin}${AUTH_ROUTES.LOGIN}?message=${errorMessage}`,
);
}

84
app/auth/confirm/route.ts Normal file
View File

@@ -0,0 +1,84 @@
import { createClient } from "@/utils/supabase/server";
import {
AUTH_ERROR_MESSAGES,
AUTH_ROUTES,
RECOVERY_COOKIE_MAX_AGE_SECONDS,
RECOVERY_COOKIE_NAME,
} from "@/features/auth/constants";
import { getAuthErrorMessage } from "@/features/auth/errors";
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse, type NextRequest } from "next/server";
const RESET_PASSWORD_PATH = AUTH_ROUTES.RESET_PASSWORD;
const LOGIN_PATH = AUTH_ROUTES.LOGIN;
/**
* 이메일 인증(/auth/confirm) 처리
* - token_hash + type 검증
* - recovery 타입일 경우 세션 쿠키 설정 후 비밀번호 재설정 페이지로 리다이렉트
*/
export async function GET(request: NextRequest) {
// 1) 이메일 링크에 들어있는 값 읽기
const { searchParams } = new URL(request.url);
// token_hash: 인증에 필요한 값
// type: 어떤 인증인지 구분 (예: 가입, 비밀번호 재설정)
const tokenHash = searchParams.get("token_hash");
const type = searchParams.get("type") as EmailOtpType | null;
// redirect_to/next: 인증 후에 이동할 주소
const rawRedirect =
searchParams.get("redirect_to") ?? searchParams.get("next");
// 보안상 우리 사이트 안 경로(`/...`)만 허용
const safeRedirect =
rawRedirect && rawRedirect.startsWith("/") ? rawRedirect : null;
// 일반 인증이 끝난 뒤 이동할 경로
const nextPath = safeRedirect ?? AUTH_ROUTES.HOME;
// 비밀번호 재설정일 때 이동할 경로
const recoveryPath = safeRedirect ?? RESET_PASSWORD_PATH;
// 필수 값이 없으면 로그인으로 보내고 에러를 보여줌
if (!tokenHash || !type) {
return redirectWithError(request, AUTH_ERROR_MESSAGES.INVALID_AUTH_LINK);
}
// 2) Supabase에게 "이 링크가 맞는지" 확인 요청
const supabase = await createClient();
const { error } = await supabase.auth.verifyOtp({
type,
token_hash: tokenHash,
});
// 확인 실패 시 이유를 알기 쉬운 메시지로 보여줌
if (error) {
console.error("[Auth Confirm] verifyOtp error:", error.message);
const message = getAuthErrorMessage(error);
return redirectWithError(request, message);
}
// 3) 비밀번호 재설정이면 재설정 페이지로 보내고 쿠키를 저장
if (type === "recovery") {
const response = NextResponse.redirect(new URL(recoveryPath, request.url));
response.cookies.set(RECOVERY_COOKIE_NAME, "1", {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: RECOVERY_COOKIE_MAX_AGE_SECONDS,
path: "/",
});
return response;
}
// 4) 그 외 인증은 기본 경로로 이동
return NextResponse.redirect(new URL(nextPath, request.url));
}
// 로그인 페이지로 보내면서 에러 메시지를 함께 전달
function redirectWithError(request: NextRequest, message: string) {
const encodedMessage = encodeURIComponent(message);
return NextResponse.redirect(
new URL(`${LOGIN_PATH}?message=${encodedMessage}`, request.url),
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

149
app/globals.css Normal file
View File

@@ -0,0 +1,149 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-heading);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-brand-50: oklch(0.97 0.02 294);
--color-brand-100: oklch(0.93 0.05 294);
--color-brand-200: oklch(0.87 0.1 294);
--color-brand-300: oklch(0.79 0.15 294);
--color-brand-400: oklch(0.7 0.2 294);
--color-brand-500: oklch(0.62 0.24 294);
--color-brand-600: oklch(0.56 0.26 294);
--color-brand-700: oklch(0.49 0.24 295);
--color-brand-800: oklch(0.4 0.2 296);
--color-brand-900: oklch(0.33 0.14 297);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--animate-gradient-x: gradient-x 15s ease infinite;
@keyframes gradient-x {
0%, 100% {
background-size: 200% 200%;
background-position: left center;
}
50% {
background-size: 200% 200%;
background-position: right center;
}
}
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.56 0.26 294);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.62 0.24 294);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.56 0.26 294);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.56 0.26 294);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.62 0.24 294);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.56 0.26 294);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

76
app/layout.tsx Normal file
View File

@@ -0,0 +1,76 @@
/**
* @file app/layout.tsx
* @description 애플리케이션의 최상위 루트 레이아웃 (RootLayout)
* @remarks
* - [레이어] Infrastructure/Layout
* - [역할] 전역 스타일(Font/CSS), 테마(Provider), 세션 관리(Manager) 초기화
* - [데이터 흐름] Providers -> Children
* - [연관 파일] globals.css, theme-provider.tsx
*/
import type { Metadata } from "next";
import { Geist, Geist_Mono, Outfit } from "next/font/google";
import { QueryProvider } from "@/providers/query-provider";
import { ThemeProvider } from "@/components/theme-provider";
import { SessionManager } from "@/features/auth/components/session-manager";
import { Toaster } from "sonner";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const outfit = Outfit({
variable: "--font-heading",
subsets: ["latin"],
display: "swap",
});
export const metadata: Metadata = {
title: "AutoTrade",
description: "Automated Crypto Trading Platform",
};
/**
* RootLayout 컴포넌트
* @param children 렌더링할 자식 컴포넌트
* @returns HTML 구조 및 전역 Provider 래퍼
* @see theme-provider.tsx - 다크모드 지원
* @see session-manager.tsx - 세션 타임아웃 감지
*/
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="scroll-smooth" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} ${outfit.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<SessionManager />
<QueryProvider>{children}</QueryProvider>
<Toaster
richColors
position="top-right"
toastOptions={{
duration: 4000,
}}
/>
</ThemeProvider>
</body>
</html>
);
}

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,58 @@
/**
* @file components/form-message.tsx
* @description 폼 제출 결과(성공/에러) 메시지를 표시하는 컴포넌트
* @remarks
* - [레이어] Components/UI/Feedback
* - [기능] URL 쿼리 파라미터(`message`)를 감지하여 표시 후 URL 정리
* - [UX] 메시지 확인 후 새로고침 시 메시지가 남지 않도록 히스토리 정리 (History API)
*/
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* 폼 메시지 컴포넌트
* @param message 표시할 메시지 텍스트
* @returns 메시지 박스 또는 null
*/
export default function FormMessage({ message }: { message: string }) {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
// 메시지가 있고, URL에 message 파라미터가 있다면
if (message && searchParams.has("message")) {
// 1. 현재 URL 파라미터 복사
const params = new URLSearchParams(searchParams.toString());
// 2. message 파라미터 삭제
params.delete("message");
// 3. URL 업데이트 (페이지 새로고침 없이 주소만 변경)
// replaceState를 사용하여 히스토리에 남기지 않고 주소창만 깔끔하게 바꿉니다.
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
window.history.replaceState(null, "", newUrl);
}
}, [message, pathname, searchParams]);
if (!message) return null;
// 에러 메시지인지 성공 메시지인지 대략적으로 판단 (성공 메시지는 보통 '확인', '완료' 등이 포함됨)
// 여기서는 간단하게 모든 메시지를 동일한 스타일로 보여주되, 필요하면 분기 가능합니다.
const isError = !message.includes("완료") && !message.includes("확인");
return (
<div
className={`rounded-md p-4 text-sm ${
isError
? "bg-red-50 text-red-700 dark:bg-red-900/50 dark:text-red-200"
: "bg-blue-50 text-blue-700 dark:bg-blue-900/50 dark:text-blue-200"
}`}
>
{message}
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* @file components/theme-provider.tsx
* @description next-themes 라이브러리를 사용한 테마 제공자 (Wrapper)
* @remarks
* - [레이어] Infrastructure/Provider
* - [역할] 앱 전역에 테마 컨텍스트 주입 (Light/Dark 모드 지원)
* - [연관 파일] layout.tsx (사용처)
*/
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
/**
* ThemeProvider 컴포넌트
* @param props next-themes Provider props
* @returns NextThemesProvider 래퍼
*/
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,75 @@
/**
* @file components/theme-toggle.tsx
* @description 라이트/다크/시스템 테마 전환 토글 버튼
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] 버튼 클릭 -> 드롭다운 메뉴 -> 테마 선택 -> 즉시 반영
* - [연관 파일] theme-provider.tsx (next-themes)
*/
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
}
/**
* 테마 토글 컴포넌트
* @remarks next-themes의 useTheme 훅 사용
* @returns Dropdown 메뉴 형태의 테마 선택기
*/
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
const { setTheme } = useTheme();
return (
<DropdownMenu modal={false}>
{/* ========== 트리거 버튼 ========== */}
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className={className}>
{/* 라이트 모드 아이콘 (회전 애니메이션) */}
<Sun
className={cn(
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
iconClassName,
)}
/>
{/* 다크 모드 아이콘 (회전 애니메이션) */}
<Moon
className={cn(
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
iconClassName,
)}
/>
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
{/* ========== 메뉴 컨텐츠 (우측 정렬) ========== */}
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,150 @@
/**
* @file components/ui/alert-dialog.tsx
* @description 알림 대화상자 (Alert Dialog) 컴포넌트 (Shadcn/ui)
* @remarks
* - [레이어] Components/UI/Primitive
* - [기능] 중요한 작업 확인 컨텍스트 제공 (로그아웃 경고 등)
* @see session-manager.tsx - 로그아웃 경고에 사용
*/
"use client";
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 sm:rounded-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
));
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className,
)}
{...props}
/>
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className,
)}
{...props}
/>
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className,
)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

109
components/ui/avatar.tsx Normal file
View File

@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted text-muted-foreground flex size-full items-center justify-center rounded-full text-sm group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full ring-2 select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"*:data-[slot=avatar]:ring-background group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"bg-muted text-muted-foreground ring-background relative flex size-8 shrink-0 items-center justify-center rounded-full text-sm ring-2 group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarBadge,
AvatarGroup,
AvatarGroupCount,
}

48
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

64
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,109 @@
"use client";
import { cn } from "@/lib/utils";
/**
* [로딩 스피너 컴포넌트]
*
* 전역적으로 사용 가능한 로딩 스피너입니다.
* - 크기 조절 가능 (sm, md, lg)
* - 색상 커스터마이징 가능
* - 텍스트와 함께 사용 가능
*
* @example
* // 기본 사용
* <LoadingSpinner />
*
* @example
* // 크기 및 텍스트 지정
* <LoadingSpinner size="lg" text="로딩 중..." />
*
* @example
* // 버튼 내부에서 사용
* <Button disabled={isLoading}>
* {isLoading ? <LoadingSpinner size="sm" /> : "제출"}
* </Button>
*/
interface LoadingSpinnerProps {
/** 스피너 크기 */
size?: "sm" | "md" | "lg";
/** 스피너와 함께 표시할 텍스트 */
text?: string;
/** 추가 CSS 클래스 */
className?: string;
/** 스피너 색상 (Tailwind 클래스) */
color?: string;
}
export function LoadingSpinner({
size = "md",
text,
className,
color = "border-gray-900 dark:border-white",
}: LoadingSpinnerProps) {
// 크기별 스타일 매핑
const sizeClasses = {
sm: "h-4 w-4 border-2",
md: "h-8 w-8 border-3",
lg: "h-12 w-12 border-4",
};
return (
<div className={cn("flex items-center justify-center gap-2", className)}>
{/* ========== 회전 스피너 ========== */}
<div
className={cn(
"animate-spin rounded-full border-solid border-t-transparent",
sizeClasses[size],
color,
)}
role="status"
aria-label="로딩 중"
/>
{/* ========== 로딩 텍스트 (선택적) ========== */}
{text && (
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
{text}
</span>
)}
</div>
);
}
/**
* [인라인 스피너 컴포넌트]
*
* 버튼 내부나 작은 공간에서 사용하기 적합한 미니 스피너입니다.
*
* @example
* <Button disabled={isLoading}>
* {isLoading && <InlineSpinner />}
* 로그인
* </Button>
*/
export function InlineSpinner({ className }: { className?: string }) {
return (
<svg
className={cn("h-4 w-4 animate-spin", className)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-label="로딩 중"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,244 @@
"use client";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
interface ShaderBackgroundProps {
className?: string;
opacity?: number;
}
const VS_SOURCE = `
attribute vec4 aVertexPosition;
void main() {
gl_Position = aVertexPosition;
}
`;
const FS_SOURCE = `
precision highp float;
uniform vec2 iResolution;
uniform float iTime;
const float overallSpeed = 0.2;
const float gridSmoothWidth = 0.015;
const float axisWidth = 0.05;
const float majorLineWidth = 0.025;
const float minorLineWidth = 0.0125;
const float majorLineFrequency = 5.0;
const float minorLineFrequency = 1.0;
const vec4 gridColor = vec4(0.5);
const float scale = 5.0;
const vec4 lineColor = vec4(0.4, 0.2, 0.8, 1.0);
const float minLineWidth = 0.01;
const float maxLineWidth = 0.2;
const float lineSpeed = 1.0 * overallSpeed;
const float lineAmplitude = 1.0;
const float lineFrequency = 0.2;
const float warpSpeed = 0.2 * overallSpeed;
const float warpFrequency = 0.5;
const float warpAmplitude = 1.0;
const float offsetFrequency = 0.5;
const float offsetSpeed = 1.33 * overallSpeed;
const float minOffsetSpread = 0.6;
const float maxOffsetSpread = 2.0;
const int linesPerGroup = 16;
#define drawCircle(pos, radius, coord) smoothstep(radius + gridSmoothWidth, radius, length(coord - (pos)))
#define drawSmoothLine(pos, halfWidth, t) smoothstep(halfWidth, 0.0, abs(pos - (t)))
#define drawCrispLine(pos, halfWidth, t) smoothstep(halfWidth + gridSmoothWidth, halfWidth, abs(pos - (t)))
#define drawPeriodicLine(freq, width, t) drawCrispLine(freq / 2.0, width, abs(mod(t, freq) - (freq) / 2.0))
float drawGridLines(float axis) {
return drawCrispLine(0.0, axisWidth, axis)
+ drawPeriodicLine(majorLineFrequency, majorLineWidth, axis)
+ drawPeriodicLine(minorLineFrequency, minorLineWidth, axis);
}
float drawGrid(vec2 space) {
return min(1.0, drawGridLines(space.x) + drawGridLines(space.y));
}
float random(float t) {
return (cos(t) + cos(t * 1.3 + 1.3) + cos(t * 1.4 + 1.4)) / 3.0;
}
float getPlasmaY(float x, float horizontalFade, float offset) {
return random(x * lineFrequency + iTime * lineSpeed) * horizontalFade * lineAmplitude + offset;
}
void main() {
vec2 fragCoord = gl_FragCoord.xy;
vec4 fragColor;
vec2 uv = fragCoord.xy / iResolution.xy;
vec2 space = (fragCoord - iResolution.xy / 2.0) / iResolution.x * 2.0 * scale;
float horizontalFade = 1.0 - (cos(uv.x * 6.28) * 0.5 + 0.5);
float verticalFade = 1.0 - (cos(uv.y * 6.28) * 0.5 + 0.5);
space.y += random(space.x * warpFrequency + iTime * warpSpeed) * warpAmplitude * (0.5 + horizontalFade);
space.x += random(space.y * warpFrequency + iTime * warpSpeed + 2.0) * warpAmplitude * horizontalFade;
vec4 lines = vec4(0.0);
vec4 bgColor1 = vec4(0.1, 0.1, 0.3, 1.0);
vec4 bgColor2 = vec4(0.3, 0.1, 0.5, 1.0);
for(int l = 0; l < linesPerGroup; l++) {
float normalizedLineIndex = float(l) / float(linesPerGroup);
float offsetTime = iTime * offsetSpeed;
float offsetPosition = float(l) + space.x * offsetFrequency;
float rand = random(offsetPosition + offsetTime) * 0.5 + 0.5;
float halfWidth = mix(minLineWidth, maxLineWidth, rand * horizontalFade) / 2.0;
float offset = random(offsetPosition + offsetTime * (1.0 + normalizedLineIndex)) * mix(minOffsetSpread, maxOffsetSpread, horizontalFade);
float linePosition = getPlasmaY(space.x, horizontalFade, offset);
float line = drawSmoothLine(linePosition, halfWidth, space.y) / 2.0 + drawCrispLine(linePosition, halfWidth * 0.15, space.y);
float circleX = mod(float(l) + iTime * lineSpeed, 25.0) - 12.0;
vec2 circlePosition = vec2(circleX, getPlasmaY(circleX, horizontalFade, offset));
float circle = drawCircle(circlePosition, 0.01, space) * 4.0;
line = line + circle;
lines += line * lineColor * rand;
}
fragColor = mix(bgColor1, bgColor2, uv.x);
fragColor *= verticalFade;
fragColor.a = 1.0;
fragColor += lines;
gl_FragColor = fragColor;
}
`;
/**
* @description Compile one shader source.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - WebGL init flow
*/
function loadShader(gl: WebGLRenderingContext, type: number, source: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error("Shader compile error:", gl.getShaderInfoLog(shader));
gl.deleteShader(shader);
return null;
}
return shader;
}
/**
* @description Create and link WebGL shader program.
* @see components/ui/shader-background.tsx ShaderBackground useEffect - shader program setup
*/
function initShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vertexSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
if (!vertexShader || !fragmentShader) return null;
const shaderProgram = gl.createProgram();
if (!shaderProgram) return null;
gl.attachShader(shaderProgram, vertexShader);
gl.attachShader(shaderProgram, fragmentShader);
gl.linkProgram(shaderProgram);
if (!gl.getProgramParameter(shaderProgram, gl.LINK_STATUS)) {
console.error("Shader program link error:", gl.getProgramInfoLog(shaderProgram));
gl.deleteProgram(shaderProgram);
return null;
}
return shaderProgram;
}
/**
* @description Animated shader background canvas.
* @param className Tailwind class for canvas.
* @param opacity Canvas opacity.
* @see https://21st.dev/community/components/thanh/shader-background/default
*/
const ShaderBackground = ({ className, opacity = 0.9 }: ShaderBackgroundProps) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl");
if (!gl) {
console.warn("WebGL not supported.");
return;
}
const shaderProgram = initShaderProgram(gl, VS_SOURCE, FS_SOURCE);
if (!shaderProgram) return;
const positionBuffer = gl.createBuffer();
if (!positionBuffer) return;
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
const positions = [-1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, 1.0];
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
const vertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");
const resolution = gl.getUniformLocation(shaderProgram, "iResolution");
const time = gl.getUniformLocation(shaderProgram, "iTime");
const resizeCanvas = () => {
const dpr = window.devicePixelRatio || 1;
const nextWidth = Math.floor(window.innerWidth * dpr);
const nextHeight = Math.floor(window.innerHeight * dpr);
canvas.width = nextWidth;
canvas.height = nextHeight;
gl.viewport(0, 0, nextWidth, nextHeight);
};
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
const startTime = Date.now();
let frameId = 0;
const render = () => {
const currentTime = (Date.now() - startTime) / 1000;
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(shaderProgram);
if (resolution) gl.uniform2f(resolution, canvas.width, canvas.height);
if (time) gl.uniform1f(time, currentTime);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(vertexPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vertexPosition);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
frameId = requestAnimationFrame(render);
};
frameId = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameId);
window.removeEventListener("resize", resizeCanvas);
gl.deleteBuffer(positionBuffer);
gl.deleteProgram(shaderProgram);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden="true"
className={cn("fixed inset-0 -z-10 h-full w-full", className)}
style={{ opacity }}
/>
);
};
export default ShaderBackground;

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }

91
components/ui/tabs.tsx Normal file
View File

@@ -0,0 +1,91 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

32
doc-rule.md Normal file
View File

@@ -0,0 +1,32 @@
# Antigravity Rules
This document defines the coding and behavior rules for the Antigravity agent.
## General Rules
- **Language**: All output, explanations, and commit messages MUST be in **Korean (한국어)**.
- **Tone**: Professional, helpful, and concise.
## Documentation Rules
### JSX Comments
- Mandatory use of section comments in JSX to delineate logical blocks.
- Format: `{/* ========== SECTION NAME ========== */}`
### JSDoc Tags
- **@see**: Mandatory for function/component documentation. Must include calling file, function/event name, and purpose.
- **@author**: Mandatory file-level tag. Use `@author jihoon87.lee`.
### Inline Comments
- High density of inline comments required for:
- State definitions
- Event handlers
- Complex logic in JSX
- Balance conciseness with clarity.
## Code Style
- Follow Project-specific linting and formatting rules.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

428
features/auth/actions.ts Normal file
View File

@@ -0,0 +1,428 @@
/**
* @file features/auth/actions.ts
* @description 인증 관련 서버 액션 (Server Actions) 모음
* @remarks
* - [레이어] Service/API (Server Actions)
* - [역할] 로그인, 회원가입, 로그아웃, 비밀번호 재설정 등 인증 로직 처리
* - [데이터 흐름] Client Form -> Server Action -> Supabase Auth -> Client Redirect
* - [연관 파일] login-form.tsx, signup-form.tsx, utils/supabase/server.ts
*/
"use server";
import { revalidatePath } from "next/cache";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
import {
AUTH_ERROR_MESSAGES,
type AuthFormData,
type AuthError,
RECOVERY_COOKIE_NAME,
} from "./constants";
import { getAuthErrorMessage } from "./errors";
// ========================================
// 헬퍼 함수
// ========================================
/**
* FormData 추출 헬퍼 (이메일/비밀번호)
* @param formData HTML form 데이터
* @returns 이메일(trim 적용), 비밀번호
*/
function extractAuthData(formData: FormData): AuthFormData {
const email = (formData.get("email") as string)?.trim() || "";
const password = (formData.get("password") as string) || "";
return { email, password };
}
/**
* 비밀번호 강도 검증 함수
* @param password 검증할 비밀번호
* @returns 에러 객체 또는 null
* @remarks 최소 8자, 대/소문자, 숫자, 특수문자 포함 필수
*/
function validatePassword(password: string): AuthError | null {
// [Step 1] 최소 길이 체크 (8자 이상)
if (password.length < 8) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_SHORT,
type: "validation",
};
}
// [Step 2] 대문자 포함 여부
if (!/[A-Z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 3] 소문자 포함 여부
if (!/[a-z]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 4] 숫자 포함 여부
if (!/[0-9]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 5] 특수문자 포함 여부
if (!/[!@#$%^&*(),.?":{}|<>]/.test(password)) {
return {
message: AUTH_ERROR_MESSAGES.PASSWORD_TOO_WEAK,
type: "validation",
};
}
// [Step 6] 모든 검증 통과
return null;
}
/**
* 입력값 유효성 검증 함수
* @param email 사용자 이메일
* @param password 사용자 비밀번호
* @returns 에러 객체 또는 null
* @see login - 로그인 액션에서 호출
* @see signup - 회원가입 액션에서 호출
*/
function validateAuthInput(email: string, password: string): AuthError | null {
// [Step 1] 빈 값 체크
if (!email || !password) {
return {
message: AUTH_ERROR_MESSAGES.EMPTY_FIELDS,
type: "validation",
};
}
// [Step 2] 이메일 형식 체크 (간단한 @ 포함 여부 확인)
if (!email.includes("@")) {
return {
message: AUTH_ERROR_MESSAGES.INVALID_EMAIL,
type: "validation",
};
}
// [Step 3] 비밀번호 강도 체크
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return passwordValidation;
}
// [Step 4] 검증 통과
return null;
}
// ========================================
// Server Actions (서버 액션)
// ========================================
/**
* [로그인 액션]
*
* 사용자가 입력한 이메일/비밀번호로 로그인을 시도합니다.
*
* 처리 과정:
* 1. FormData에서 이메일/비밀번호 추출
* 2. 입력값 유효성 검증 (빈 값, 이메일 형식, 비밀번호 길이)
* 3. Supabase Auth를 통한 로그인 시도
* 4. 로그인 실패 시 에러 메시지와 함께 로그인 페이지로 리다이렉트
* 5. 로그인 성공 시 캐시 무효화 및 메인 페이지로 리다이렉트
*
* @param formData 이메일, 비밀번호가 포함된 FormData
* @see login-form.tsx - 로그인 폼 제출 시 호출
*/
export async function login(formData: FormData) {
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
`/login?message=${encodeURIComponent(validationError.message)}`,
);
}
// [Step 3] Supabase 클라이언트 생성 및 로그인 시도
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
// [Step 4] 로그인 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// [Step 5] 로그인 성공 - 캐시 무효화 및 메인 페이지로 리다이렉트
revalidatePath("/", "layout");
redirect("/");
}
/**
* [회원가입 액션]
*
* 새로운 사용자를 등록합니다.
*
* 처리 과정:
* 1. FormData에서 이메일/비밀번호 추출
* 2. 입력값 유효성 검증
* 3. Supabase Auth를 통한 회원가입 시도
* 4. 이메일 인증 리다이렉트 URL 설정 (확인 링크 클릭 시 돌아올 주소)
* 5-1. 즉시 세션 생성 시: 메인 페이지로 리다이렉트 (바로 로그인됨)
* 5-2. 이메일 인증 필요 시: 로그인 페이지로 리다이렉트 (안내 메시지 포함)
*
* @param formData 이메일, 비밀번호가 포함된 FormData
* @see signup-form.tsx - 회원가입 폼 제출 시 호출
*/
export async function signup(formData: FormData) {
// [Step 1] FormData에서 이메일/비밀번호 추출
const { email, password } = extractAuthData(formData);
// [Step 2] 입력값 유효성 검증
const validationError = validateAuthInput(email, password);
if (validationError) {
return redirect(
`/signup?message=${encodeURIComponent(validationError.message)}`,
);
}
// [Step 3] Supabase 클라이언트 생성 및 회원가입 시도
const supabase = await createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
// 이메일 인증 완료 후 리다이렉트될 URL
emailRedirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback?auth_type=signup`,
},
});
// [Step 4] 회원가입 실패 시 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return redirect(`/signup?message=${encodeURIComponent(message)}`);
}
// [Step 5] 회원가입 성공 처리
if (data.session) {
// [Case 1] 즉시 세션 생성됨 (이메일 인증 불필요)
revalidatePath("/", "layout");
redirect("/");
}
// [Case 2] 이메일 인증 필요 (로그인 페이지로 이동)
revalidatePath("/", "layout");
redirect(
`/login?message=${encodeURIComponent("회원가입이 완료되었습니다. 이메일을 확인하여 인증을 완료해 주세요.")}`,
);
}
/**
* [로그아웃 액션]
*
* 현재 세션을 종료하고 로그인 페이지로 이동합니다.
*
* 처리 과정:
* 1. Supabase Auth 세션 종료 (서버 + 클라이언트 쿠키 삭제)
* 2. Next.js 캐시 무효화하여 인증 상태 갱신
* 3. 로그인 페이지로 리다이렉트
*
* @see user-menu.tsx - 로그아웃 메뉴 클릭 시 호출
* @see session-manager.tsx - 세션 타임아웃 시 호출
*/
export async function signout() {
const supabase = await createClient();
// [Step 1] Supabase 세션 종료 (서버 + 클라이언트 쿠키 삭제)
await supabase.auth.signOut();
// [Step 2] Next.js 캐시 무효화
revalidatePath("/", "layout");
// [Step 3] 로그인 페이지로 리다이렉트
redirect("/");
}
/**
* [비밀번호 재설정 요청 액션]
*
* 사용자 이메일로 비밀번호 재설정 링크를 발송합니다.
* 보안을 위해 이메일 존재 여부와 관계없이 동일한 메시지를 표시합니다.
*
* 처리 과정:
* 1. FormData에서 이메일 추출
* 2. 이메일 형식 검증
* 3. Supabase를 통한 재설정 링크 발송
* 4. 성공 메시지와 함께 로그인 페이지로 리다이렉트
*
* @param formData 이메일 포함
* @see forgot-password/page.tsx - 비밀번호 찾기 폼 제출 시 호출
*/
export async function requestPasswordReset(formData: FormData) {
// [Step 1] FormData에서 이메일 추출
const email = (formData.get("email") as string)?.trim() || "";
// [Step 2] 이메일 유효성 검증
if (!email) {
return redirect(
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.EMPTY_EMAIL)}`,
);
}
if (!email.includes("@")) {
return redirect(
`/forgot-password?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.INVALID_EMAIL)}`,
);
}
// [Step 3] Supabase를 통한 재설정 링크 발송
const supabase = await createClient();
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/reset-password`,
});
// [Step 4] 에러 처리
if (error) {
console.error("Password reset error:", error.message);
const message = getAuthErrorMessage(error);
return redirect(`/forgot-password?message=${encodeURIComponent(message)}`);
}
// [Step 5] 성공 메시지 표시 (보안상 항상 성공 메시지 리턴 권장)
redirect(
`/login?message=${encodeURIComponent(AUTH_ERROR_MESSAGES.PASSWORD_RESET_SENT)}`,
);
}
/**
* [비밀번호 업데이트 액션]
*
* 비밀번호 재설정 링크를 통해 접근한 사용자의 비밀번호를 업데이트합니다.
*
* 처리 과정:
* 1. FormData에서 새 비밀번호 추출
* 2. 비밀번호 길이 및 강도 검증
* 3. Supabase를 통한 비밀번호 업데이트
* 4. 실패 시 에러 메시지 반환
* 5. 성공 시 세션/쿠키 정리 후 로그아웃 및 캐시 무효화
* 6. 성공 결과 반환
*
* @param formData 새 비밀번호 포함
* @see reset-password-form.tsx - 비밀번호 재설정 폼 제출 시 호출
*/
export async function updatePassword(formData: FormData) {
// [Step 1] 새 비밀번호 추출
const password = (formData.get("password") as string) || "";
// [Step 2] 비밀번호 강도 검증
const passwordValidation = validatePassword(password);
if (passwordValidation) {
return { ok: false, message: passwordValidation.message };
}
// [Step 3] Supabase를 통한 비밀번호 업데이트
const supabase = await createClient();
const { error } = await supabase.auth.updateUser({
password: password,
});
// [Step 4] 에러 처리
if (error) {
const message = getAuthErrorMessage(error);
return { ok: false, message };
}
// [Step 5] 세션 및 쿠키 정리 후 로그아웃
const cookieStore = await cookies();
cookieStore.delete(RECOVERY_COOKIE_NAME);
await supabase.auth.signOut();
revalidatePath("/", "layout");
// [Step 6] 성공 응답 반환
return {
ok: true,
message: AUTH_ERROR_MESSAGES.PASSWORD_RESET_SUCCESS,
};
}
// ========================================
// 소셜 로그인 (OAuth)
// ========================================
/**
* [OAuth 로그인 공통 헬퍼]
*
* 처리 과정:
* 1. Supabase OAuth 로그인 URL 생성 (PKCE)
* 2. 생성 중 에러 발생 시 로그인 페이지로 리다이렉트 (에러 메시지 포함)
* 3. 성공 시 해당 OAuth 제공자 페이지(data.url)로 리다이렉트
*
* @param provider 'google' | 'kakao'
* @param extraOptions 추가 옵션 (예: prompt)
* @see signInWithGoogle
* @see signInWithKakao
*/
async function signInWithProvider(
provider: "google" | "kakao",
extraOptions: { queryParams?: { [key: string]: string } } = {},
) {
const supabase = await createClient();
// [Step 1] OAuth 인증 시작 (URL 생성)
const { data, error } = await supabase.auth.signInWithOAuth({
provider,
options: {
// PKCE 플로우를 위한 콜백 URL
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3001"}/auth/callback`,
...extraOptions,
},
});
// [Step 2] 에러 처리
if (error) {
console.error(`[${provider} OAuth] 로그인 실패:`, error.message);
const message = getAuthErrorMessage(error);
return redirect(`/login?message=${encodeURIComponent(message)}`);
}
// [Step 3] OAuth 제공자 로그인 페이지로 리다이렉트
if (data.url) {
redirect(data.url);
}
// [Step 4] URL 생성 실패 시 에러 처리
redirect(
`/login?message=${encodeURIComponent("로그인 처리 중 오류가 발생했습니다.")}`,
);
}
/**
* [Google 로그인 액션]
* @see login-form.tsx - 구글 로그인 버튼 클릭 시 호출
*/
export async function signInWithGoogle() {
return signInWithProvider("google");
}
/**
* [Kakao 로그인 액션]
* @see login-form.tsx - 카카오 로그인 버튼 클릭 시 호출
*/
export async function signInWithKakao() {
return signInWithProvider("kakao", { queryParams: { prompt: "login" } });
}

View File

@@ -0,0 +1,221 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { AUTH_ROUTES } from "@/features/auth/constants";
import {
login,
signInWithGoogle,
signInWithKakao,
} from "@/features/auth/actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { InlineSpinner } from "@/components/ui/loading-spinner";
/**
* [로그인 폼 클라이언트 컴포넌트]
*
* 이메일 기억하기 기능을 제공하는 로그인 폼입니다.
* - localStorage를 사용하여 이메일 저장/불러오기
* - 체크박스 선택 시 이메일 자동 저장
* - 서버 액션(login)과 연동
* - 하이드레이션 이슈 해결을 위해 useEffect 사용
*/
export default function LoginForm() {
// ========== 상태 관리 ==========
// 서버와 클라이언트 초기 렌더링을 일치시키기 위해 초기값은 고정
const [email, setEmail] = useState(() => {
if (typeof window === "undefined") return "";
return localStorage.getItem("auto-trade-saved-email") || "";
});
const [rememberMe, setRememberMe] = useState(() => {
if (typeof window === "undefined") return false;
return !!localStorage.getItem("auto-trade-saved-email");
});
const [isLoading, setIsLoading] = useState(false);
// ========== 마운트 시 localStorage 데이터 복구 ==========
// localStorage는 클라이언트 전용 외부 시스템이므로 useEffect에서 동기화하는 것이 올바른 패턴
// React 공식 문서: "외부 시스템과 동기화"는 useEffect의 정확한 사용 사례
// useState lazy initializer + window guard handles localStorage safely
// ========== 폼 제출 핸들러 ==========
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
// localStorage 처리 (동기)
if (rememberMe) {
localStorage.setItem("auto-trade-saved-email", email);
} else {
localStorage.removeItem("auto-trade-saved-email");
}
// 서버 액션 호출 (리다이렉트 발생)
try {
await login(formData);
} catch (error) {
console.error("Login error:", error);
setIsLoading(false);
}
};
return (
<div className="space-y-6">
{/* ========== 로그인 폼 ========== */}
<form className="space-y-5" onSubmit={handleSubmit}>
{/* ========== 이메일 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
name="email"
type="email"
placeholder="your@email.com"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-11 transition-all duration-200"
/>
</div>
{/* ========== 비밀번호 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
name="password"
type="password"
placeholder="••••••••"
autoComplete="current-password"
required
minLength={8}
pattern="^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[!@#$%^&*]).{8,}$"
title="비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다."
className="h-11 transition-all duration-200"
/>
</div>
{/* ========== 이메일 기억하기 & 비밀번호 찾기 ========== */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) => setRememberMe(checked === true)}
/>
<Label
htmlFor="remember"
className="cursor-pointer text-sm font-normal leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
</Label>
</div>
{/* 비밀번호 찾기 링크 */}
<Link
href={AUTH_ROUTES.FORGOT_PASSWORD}
className="text-sm font-medium text-gray-700 hover:text-black dark:text-gray-300 dark:hover:text-white"
>
</Link>
</div>
{/* ========== 로그인 버튼 ========== */}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
size="lg"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"로그인"
)}
</Button>
{/* ========== 회원가입 링크 ========== */}
<p className="text-center text-sm text-gray-600 dark:text-gray-400">
?{" "}
<Link
href={AUTH_ROUTES.SIGNUP}
className="font-semibold text-gray-900 transition-colors hover:text-black dark:text-gray-100 dark:hover:text-white"
>
</Link>
</p>
</form>
{/* ========== 소셜 로그인 구분선 ========== */}
<div className="relative">
<Separator className="my-6" />
<span className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-white px-3 text-xs font-medium text-gray-500 dark:bg-gray-900 dark:text-gray-400">
</span>
</div>
{/* ========== 소셜 로그인 버튼들 ========== */}
<div className="grid grid-cols-2 gap-3">
{/* ========== Google 로그인 버튼 ========== */}
<form action={signInWithGoogle}>
<Button
type="submit"
variant="outline"
className="h-11 w-full border-gray-200 bg-white shadow-sm transition-all duration-200 hover:bg-gray-50 hover:shadow-md dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-750"
>
<svg className="mr-2 h-5 w-5" viewBox="0 0 24 24">
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
Google
</Button>
</form>
{/* ========== Kakao 로그인 버튼 ========== */}
<form action={signInWithKakao}>
<Button
type="submit"
variant="outline"
className="h-11 w-full border-none bg-[#FEE500] font-semibold text-[#3C1E1E] shadow-sm transition-all duration-200 hover:bg-[#FDD835] hover:shadow-md"
>
<svg
className="mr-2 h-5 w-5"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 3c-4.97 0-9 4.03-9 9s4.03 9 9 9 9-4.03 9-9-4.03-9-9-9zm-1.5 14.5h-3v-9h3v9zm3 0h-3v-5h3v5zm0-6h-3v-3h3v3z" />
</svg>
Kakao
</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter } from "next/navigation";
import { updatePassword } from "@/features/auth/actions";
import {
resetPasswordSchema,
type ResetPasswordFormData,
} from "@/features/auth/schemas/auth-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { useState } from "react";
const DEFAULT_ERROR_MESSAGE =
"알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
export default function ResetPasswordForm() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState("");
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
mode: "onBlur",
});
const password = watch("password");
const confirmPassword = watch("confirmPassword");
const onSubmit = async (data: ResetPasswordFormData) => {
setServerError("");
setIsLoading(true);
try {
const formData = new FormData();
formData.append("password", data.password);
const result = await updatePassword(formData);
if (result?.ok) {
const message = encodeURIComponent(result.message);
router.replace(`/login?message=${message}`);
return;
}
setServerError(result?.message || DEFAULT_ERROR_MESSAGE);
} catch (error) {
console.error("Password reset error:", error);
setServerError(DEFAULT_ERROR_MESSAGE);
} finally {
setIsLoading(false);
}
};
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{serverError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
{serverError}
</div>
)}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
type="password"
placeholder="새 비밀번호를 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("password")}
className="h-11 transition-all duration-200"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
8 , /// 1 .
</p>
{errors.password && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium">
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="새 비밀번호를 다시 입력해주세요"
autoComplete="new-password"
disabled={isLoading}
{...register("confirmPassword")}
className="h-11 transition-all duration-200"
/>
{confirmPassword &&
password !== confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
.
</p>
)}
{confirmPassword &&
password === confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400">
.
</p>
)}
{errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-linear-to-r from-gray-900 to-black font-semibold text-white shadow-lg transition-all hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"비밀번호 변경"
)}
</Button>
</form>
);
}

View File

@@ -0,0 +1,148 @@
/**
* @file features/auth/components/session-manager.tsx
* @description 사용자 세션 타임아웃 및 자동 로그아웃 관리 컴포넌트
* @remarks
* - [레이어] Components/Infrastructure
* - [사용자 행동] 로그인 -> 활동 감지 -> 비활동 -> (경고) -> 로그아웃
* - [데이터 흐름] Event -> Zustand Store -> Timer -> Logout
* - [연관 파일] stores/session-store.ts, features/auth/constants.ts
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { createClient } from "@/utils/supabase/client";
import { useRouter, usePathname } from "next/navigation";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
// import { toast } from "sonner"; // Unused for now
// 설정: 경고 표시 시간 (타임아웃 1분 전) - 현재 미사용 (즉시 로그아웃)
// const WARNING_MS = 60 * 1000;
/**
* 세션 관리자 컴포넌트
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
* @returns 숨겨진 기능성 컴포넌트 (Global Layout에 포함)
* @remarks RootLayout에 포함되어 전역적으로 동작
* @see layout.tsx - RootLayout에서 렌더링
* @see session-store.ts - 마지막 활동 시간 관리
*/
export function SessionManager() {
const router = useRouter();
const pathname = usePathname();
// [State] 타임아웃 경고 모달 표시 여부 (현재 미사용)
const [showWarning, setShowWarning] = useState(false);
// 인증 페이지에서는 동작하지 않음
const isAuthPage = ["/login", "/signup", "/forgot-password"].includes(
pathname,
);
const { setLastActive } = useSessionStore();
/**
* 로그아웃 처리 핸들러
* @see session-timer.tsx - 타임아웃 시 동일한 로직 필요 가능성 있음
*/
const handleLogout = useCallback(async () => {
// [Step 1] Supabase 클라이언트 생성
const supabase = createClient();
// [Step 2] 서버 사이드 로그아웃 요청
await supabase.auth.signOut();
// [Step 3] 로컬 스토어 및 세션 정보 초기화
useSessionStore.persist.clearStorage();
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
router.refresh();
}, [router]);
useEffect(() => {
if (isAuthPage) return;
// 마지막 활동 시간 업데이트 함수
const updateLastActive = () => {
setLastActive(Date.now());
if (showWarning) setShowWarning(false);
};
// [Step 1] 사용자 활동 이벤트 감지 (마우스, 키보드, 스크롤, 터치)
const events = ["mousedown", "keydown", "scroll", "touchstart"];
const handleActivity = () => updateLastActive();
events.forEach((event) => window.addEventListener(event, handleActivity));
// [Step 2] 주기적(1초)으로 세션 만료 여부 확인
const intervalId = setInterval(async () => {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
const timeSinceLastActive = now - currentLastActive;
// 타임아웃 초과 시 로그아웃
if (timeSinceLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
// 경고 로직 (현재 비활성)
// else if (timeSinceLastActive >= TIMEOUT_MS - WARNING_MS) {
// setShowWarning(true);
// }
}, 1000);
// [Step 3] 탭 활성화/컴퓨터 깨어남 감지 (절전 모드 대응)
const handleVisibilityChange = async () => {
if (!document.hidden) {
const currentLastActive = useSessionStore.getState().lastActive;
const now = Date.now();
// 절전 모드 복귀 시 즉시 만료 체크
if (now - currentLastActive >= SESSION_TIMEOUT_MS) {
await handleLogout();
}
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
events.forEach((event) =>
window.removeEventListener(event, handleActivity),
);
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [pathname, isAuthPage, showWarning, handleLogout, setLastActive]);
return (
<AlertDialog open={showWarning} onOpenChange={setShowWarning}>
<AlertDialogContent>
{/* ========== 헤더: 제목 및 설명 ========== */}
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
1 .
.
</AlertDialogDescription>
</AlertDialogHeader>
{/* ========== 하단: 액션 버튼 ========== */}
<AlertDialogFooter>
<AlertDialogAction onClick={() => setShowWarning(false)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,79 @@
/**
* @file features/auth/components/session-timer.tsx
* @description 헤더에 표시되는 세션 만료 카운트다운 컴포넌트
* @remarks
* - [레이어] Components/UI
* - [사용자 행동] 남은 시간 확인 -> 만료 임박 시 붉은색 경고
* - [데이터 흐름] Zustand Store -> Calculation -> UI
* - [연관 파일] stores/session-store.ts, features/layout/header.tsx
*/
"use client";
import { useEffect, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { SESSION_TIMEOUT_MS } from "@/features/auth/constants";
import { cn } from "@/lib/utils";
/**
* 세션 만료 타이머 컴포넌트
* 남은 시간을 mm:ss 형태로 표시 (10분 미만 시 경고 스타일)
* @returns 시간 표시 배지 (모바일 숨김)
* @remarks 1초마다 리렌더링 발생
* @see header.tsx - 로그인 상태일 때 헤더에 표시
*/
interface SessionTimerProps {
/** 셰이더 배경 위에서 가독성을 높이는 투명 모드 */
blendWithBackground?: boolean;
}
export function SessionTimer({ blendWithBackground = false }: SessionTimerProps) {
const lastActive = useSessionStore((state) => state.lastActive);
// [State] 남은 시간 (밀리초)
const [timeLeft, setTimeLeft] = useState<number>(SESSION_TIMEOUT_MS);
useEffect(() => {
const calculateTimeLeft = () => {
const now = Date.now();
const passed = now - lastActive;
// [Step 1] 남은 시간 계산 (음수 방지)
const remaining = Math.max(0, SESSION_TIMEOUT_MS - passed);
setTimeLeft(remaining);
};
calculateTimeLeft(); // 초기 실행
// [Step 2] 1초마다 남은 시간 갱신
const interval = setInterval(calculateTimeLeft, 1000);
return () => clearInterval(interval);
}, [lastActive]);
// [Step 3] 시간 포맷팅 (mm:ss)
const minutes = Math.floor(timeLeft / 60000);
const seconds = Math.floor((timeLeft % 60000) / 1000);
// [Step 4] 10분 미만일 때 긴급 스타일 적용
const isUrgent = timeLeft < 10 * 60 * 1000;
return (
<div
className={cn(
"hidden rounded-full border px-3 py-1.5 text-sm font-medium tabular-nums backdrop-blur-md transition-colors md:block",
isUrgent
? "border-red-200 bg-red-50/50 text-red-500 dark:border-red-800 dark:bg-red-900/20"
: blendWithBackground
? "border-white/30 bg-black/45 text-white shadow-sm shadow-black/40"
: "border-border/40 bg-background/50 text-muted-foreground",
)}
>
{/* ========== 라벨 ========== */}
<span className="mr-2"> </span>
{/* ========== 시간 표시 ========== */}
{minutes.toString().padStart(2, "0")}:
{seconds.toString().padStart(2, "0")}
</div>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { signup } from "@/features/auth/actions";
import {
signupSchema,
type SignupFormData,
} from "@/features/auth/schemas/auth-schema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { InlineSpinner } from "@/components/ui/loading-spinner";
import { useState } from "react";
/**
* [회원가입 폼 클라이언트 컴포넌트 - React Hook Form 버전]
*
* React Hook Form과 Zod를 사용한 회원가입 폼입니다.
* - 타입 안전한 폼 검증
* - 자동 에러 메시지 관리
* - 비밀번호/비밀번호 확인 일치 검증
* - 로딩 상태 표시
*
* @see app/signup/page.tsx - 이 컴포넌트를 사용하는 페이지
*/
export default function SignupForm() {
// ========== 로딩 상태 ==========
const [isLoading, setIsLoading] = useState(false);
const [serverError, setServerError] = useState("");
// ========== React Hook Form 설정 ==========
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: "onBlur", // 포커스 아웃 시 검증
});
// 비밀번호 실시간 감시 (일치 여부 표시용)
const password = watch("password");
const confirmPassword = watch("confirmPassword");
// ========== 폼 제출 핸들러 ==========
const onSubmit = async (data: SignupFormData) => {
setServerError("");
setIsLoading(true);
try {
const formData = new FormData();
formData.append("email", data.email);
formData.append("password", data.password);
await signup(formData);
} catch (error) {
console.error("Signup error:", error);
setServerError("회원가입 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
{/* ========== 서버 에러 메시지 표시 ========== */}
{serverError && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
{serverError}
</div>
)}
{/* ========== 이메일 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium">
</Label>
<Input
id="email"
type="email"
placeholder="your@email.com"
autoComplete="email"
disabled={isLoading}
{...register("email")}
className="h-11 transition-all duration-200"
/>
{errors.email && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
{/* ========== 비밀번호 입력 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium">
</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
autoComplete="new-password"
disabled={isLoading}
{...register("password")}
className="h-11 transition-all duration-200"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
8 , , , ,
</p>
{errors.password && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
{/* ========== 비밀번호 확인 필드 ========== */}
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-sm font-medium">
</Label>
<Input
id="confirmPassword"
type="password"
placeholder="••••••••"
autoComplete="new-password"
disabled={isLoading}
{...register("confirmPassword")}
className="h-11 transition-all duration-200"
/>
{/* 비밀번호 불일치 시 실시간 피드백 */}
{confirmPassword &&
password !== confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
</p>
)}
{/* 비밀번호 일치 시 확인 메시지 */}
{confirmPassword &&
password === confirmPassword &&
!errors.confirmPassword && (
<p className="text-xs text-green-600 dark:text-green-400">
</p>
)}
{/* Zod 검증 에러 */}
{errors.confirmPassword && (
<p className="text-xs text-red-600 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
{/* ========== 회원가입 버튼 ========== */}
<Button
type="submit"
disabled={isLoading}
className="h-11 w-full bg-gradient-to-r from-gray-900 to-black font-semibold shadow-lg transition-all duration-200 hover:from-black hover:to-gray-800 hover:shadow-xl dark:from-white dark:to-gray-100 dark:text-black dark:hover:from-gray-100 dark:hover:to-white"
>
{isLoading ? (
<span className="flex items-center gap-2">
<InlineSpinner />
...
</span>
) : (
"회원가입 완료"
)}
</Button>
</form>
);
}

257
features/auth/constants.ts Normal file
View File

@@ -0,0 +1,257 @@
/**
* @file features/auth/constants.ts
* @description 인증 모듈 전반에서 사용되는 상수, 에러 메시지, 설정값 정의
* @remarks
* - [레이어] Core/Constants
* - [사용자 행동] 로그인/회원가입/비밀번호 찾기 등 인증 전반
* - [데이터 흐름] UI/Service -> Constants -> UI (메시지 표시)
* - [주의사항] 환경 변수(SESSION_TIMEOUT_MINUTES)에 의존하는 상수 포함
*/
// ========================================
// 에러 메시지 상수
// ========================================
/**
* 인증 에러 메시지 매핑
* Supabase의 영문 에러를 한글로 변환하기 위한 매핑 테이블
*/
export const AUTH_ERROR_MESSAGES = {
// === 로그인/회원가입 관련 ===
INVALID_CREDENTIALS: "이메일 또는 비밀번호가 일치하지 않습니다.",
USER_EXISTS: "이미 가입된 이메일 주소입니다.",
EMAIL_NOT_CONFIRMED: "이메일 인증이 완료되지 않았습니다.",
// === 입력값 검증 ===
EMPTY_FIELDS: "이메일과 비밀번호를 모두 입력해 주세요.",
EMPTY_EMAIL: "이메일을 입력해 주세요.",
INVALID_EMAIL: "올바른 이메일 형식이 아닙니다.",
// === 비밀번호 관련 ===
PASSWORD_TOO_SHORT: "비밀번호는 최소 8자 이상이어야 합니다.",
PASSWORD_TOO_WEAK:
"비밀번호는 최소 8자 이상, 대문자, 소문자, 숫자, 특수문자를 각각 1개 이상 포함해야 합니다.",
PASSWORD_MISMATCH: "비밀번호가 일치하지 않습니다.",
PASSWORD_SAME_AS_OLD: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
// === 비밀번호 재설정 ===
PASSWORD_RESET_SENT: "비밀번호 재설정 링크를 이메일로 발송했습니다.",
PASSWORD_RESET_SUCCESS: "비밀번호가 성공적으로 변경되었습니다.",
PASSWORD_RESET_FAILED: "비밀번호 변경에 실패했습니다.",
// === 인증 링크 ===
INVALID_AUTH_LINK: "인증 링크가 만료되었거나 유효하지 않습니다.",
EMAIL_VERIFIED_SUCCESS: "이메일 인증이 완료되었습니다. 로그인해 주세요.",
// === 소셜 로그인 (OAuth) 관련 ===
OAUTH_ACCESS_DENIED: "로그인이 취소되었습니다.",
OAUTH_SERVER_ERROR:
"인증 서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
OAUTH_INVALID_SCOPE:
"필요한 권한이 설정되지 않았습니다. 개발자 설정 확인이 필요합니다.",
OAUTH_UNAUTHORIZED_CLIENT:
"인증 앱 설정(Client ID/Secret)에 문제가 있습니다.",
OAUTH_UNKNOWN_ERROR: "소셜 로그인 중 알 수 없는 오류가 발생했습니다.",
// === Rate Limit ===
EMAIL_RATE_LIMIT:
"이메일 발송 제한을 초과했습니다. 잠시 후 다시 시도해 주세요.",
EMAIL_RATE_LIMIT_DETAILED:
"이메일 발송 제한을 초과했습니다. Supabase 무료 플랜은 시간당 이메일 발송 횟수가 제한됩니다. 약 1시간 후에 다시 시도해 주세요.",
// === 기타 ===
DEFAULT: "요청을 처리하는 중 오류가 발생했습니다.",
} as const;
// ========================================
// Supabase Auth 에러 코드 매핑
// ========================================
export const AUTH_ERROR_CODE_MESSAGES = {
anonymous_provider_disabled: "익명 로그인은 비활성화되어 있습니다.",
bad_code_verifier: "PKCE code verifier가 일치하지 않습니다.",
bad_json: "요청 본문이 올바른 JSON이 아닙니다.",
bad_jwt: "Authorization 헤더의 JWT가 유효하지 않습니다.",
bad_oauth_callback: "OAuth 콜백에 필요한 값(state)이 없습니다.",
bad_oauth_state: "OAuth state 형식이 올바르지 않습니다.",
captcha_failed: "CAPTCHA 검증에 실패했습니다.",
conflict: "요청 충돌이 발생했습니다. 잠시 후 다시 시도해주세요.",
email_address_invalid: "예시/테스트 도메인은 사용할 수 없습니다.",
email_address_not_authorized:
"기본 SMTP 사용 시 허용되지 않은 이메일 주소입니다.",
email_conflict_identity_not_deletable:
"이메일 충돌로 이 아이덴티티를 삭제할 수 없습니다.",
email_exists: "이미 가입된 이메일 주소입니다.",
email_not_confirmed: "이메일 인증이 완료되지 않았습니다.",
email_provider_disabled: "이메일/비밀번호 가입이 비활성화되어 있습니다.",
flow_state_expired: "로그인 흐름이 만료되었습니다. 다시 시도해주세요.",
flow_state_not_found: "로그인 흐름을 찾을 수 없습니다. 다시 시도해주세요.",
hook_payload_invalid_content_type:
"훅 페이로드의 Content-Type이 올바르지 않습니다.",
hook_payload_over_size_limit: "훅 페이로드가 최대 크기를 초과했습니다.",
hook_timeout: "훅 요청 시간이 초과되었습니다.",
hook_timeout_after_retry: "훅 요청 재시도 후에도 시간이 초과되었습니다.",
identity_already_exists: "이미 연결된 아이덴티티입니다.",
identity_not_found: "아이덴티티를 찾을 수 없습니다.",
insufficient_aal: "추가 인증이 필요합니다.",
invalid_credentials: "이메일 또는 비밀번호가 일치하지 않습니다.",
invite_not_found: "초대 링크가 만료되었거나 이미 사용되었습니다.",
manual_linking_disabled: "계정 연결 기능이 비활성화되어 있습니다.",
mfa_challenge_expired: "MFA 인증 시간이 초과되었습니다.",
mfa_factor_name_conflict: "MFA 요인 이름이 중복되었습니다.",
mfa_factor_not_found: "MFA 요인을 찾을 수 없습니다.",
mfa_ip_address_mismatch: "MFA 등록 시작/종료 IP가 일치하지 않습니다.",
mfa_phone_enroll_not_enabled: "전화 MFA 등록이 비활성화되어 있습니다.",
mfa_phone_verify_not_enabled: "전화 MFA 검증이 비활성화되어 있습니다.",
mfa_totp_enroll_not_enabled: "TOTP MFA 등록이 비활성화되어 있습니다.",
mfa_totp_verify_not_enabled: "TOTP MFA 검증이 비활성화되어 있습니다.",
mfa_verification_failed: "MFA 검증에 실패했습니다.",
mfa_verification_rejected: "MFA 검증이 거부되었습니다.",
mfa_verified_factor_exists: "이미 검증된 전화 MFA가 존재합니다.",
mfa_web_authn_enroll_not_enabled:
"WebAuthn MFA 등록이 비활성화되어 있습니다.",
mfa_web_authn_verify_not_enabled:
"WebAuthn MFA 검증이 비활성화되어 있습니다.",
no_authorization: "Authorization 헤더가 필요합니다.",
not_admin: "관리자 권한이 없습니다.",
oauth_provider_not_supported: "OAuth 제공자가 비활성화되어 있습니다.",
otp_disabled: "OTP 로그인이 비활성화되어 있습니다.",
otp_expired: "OTP가 만료되었습니다.",
over_email_send_rate_limit: "이메일 발송 제한을 초과했습니다.",
over_request_rate_limit: "요청 제한을 초과했습니다.",
over_sms_send_rate_limit: "SMS 발송 제한을 초과했습니다.",
phone_exists: "이미 가입된 전화번호입니다.",
phone_not_confirmed: "전화번호 인증이 완료되지 않았습니다.",
phone_provider_disabled: "전화번호 가입이 비활성화되어 있습니다.",
provider_disabled: "OAuth 제공자가 비활성화되어 있습니다.",
provider_email_needs_verification: "OAuth 이메일 인증이 필요합니다.",
reauthentication_needed: "비밀번호 변경을 위해 재인증이 필요합니다.",
reauthentication_not_valid: "재인증 코드가 유효하지 않습니다.",
refresh_token_already_used: "세션이 만료되었습니다. 다시 로그인해주세요.",
refresh_token_not_found: "세션을 찾을 수 없습니다.",
request_timeout: "요청 시간이 초과되었습니다.",
same_password: "새 비밀번호는 기존 비밀번호와 달라야 합니다.",
saml_assertion_no_email: "SAML 응답에 이메일이 없습니다.",
saml_assertion_no_user_id: "SAML 응답에 사용자 ID가 없습니다.",
saml_entity_id_mismatch: "SAML 엔티티 ID가 일치하지 않습니다.",
saml_idp_already_exists: "SAML IdP가 이미 등록되어 있습니다.",
saml_idp_not_found: "SAML IdP를 찾을 수 없습니다.",
saml_metadata_fetch_failed: "SAML 메타데이터를 불러오지 못했습니다.",
saml_provider_disabled: "SAML SSO가 비활성화되어 있습니다.",
saml_relay_state_expired: "SAML relay state가 만료되었습니다.",
saml_relay_state_not_found: "SAML relay state를 찾을 수 없습니다.",
session_expired: "세션이 만료되었습니다.",
session_not_found: "세션을 찾을 수 없습니다.",
signup_disabled: "회원가입이 비활성화되어 있습니다.",
single_identity_not_deletable: "유일한 아이덴티티는 삭제할 수 없습니다.",
sms_send_failed: "SMS 발송에 실패했습니다.",
sso_domain_already_exists: "SSO 도메인이 이미 등록되어 있습니다.",
sso_provider_not_found: "SSO 제공자를 찾을 수 없습니다.",
too_many_enrolled_mfa_factors: "등록 가능한 MFA 요인 수를 초과했습니다.",
unexpected_audience: "토큰 audience가 일치하지 않습니다.",
unexpected_failure: "인증 서버 오류가 발생했습니다.",
user_already_exists: "이미 존재하는 사용자입니다.",
user_banned: "계정이 일시적으로 차단되었습니다.",
user_not_found: "사용자를 찾을 수 없습니다.",
user_sso_managed: "SSO 사용자 정보는 수정할 수 없습니다.",
validation_failed: "요청 값 형식이 올바르지 않습니다.",
weak_password: "비밀번호가 정책을 만족하지 않습니다.",
} as const;
export const AUTH_ERROR_STATUS_MESSAGES = {
403: "요청한 기능을 사용할 수 없습니다.",
422: "요청을 처리할 수 없는 상태입니다.",
429: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
500: "인증 서버 오류가 발생했습니다.",
501: "요청한 기능이 활성화되어 있지 않습니다.",
} as const;
// ========================================
// 라우트 경로 상수
// ========================================
/**
* 인증 관련 라우트 경로
*/
export const AUTH_ROUTES = {
LOGIN: "/login",
SIGNUP: "/signup",
FORGOT_PASSWORD: "/forgot-password",
RESET_PASSWORD: "/reset-password",
AUTH_CONFIRM: "/auth/confirm",
AUTH_CALLBACK: "/auth/callback",
HOME: "/",
DASHBOARD: "/dashboard",
} as const;
/**
* 로그인 없이 접근 가능한 페이지 목록
* 미들웨어에서 라우트 보호에 사용
*/
export const PUBLIC_AUTH_PAGES = [
AUTH_ROUTES.LOGIN,
AUTH_ROUTES.SIGNUP,
AUTH_ROUTES.FORGOT_PASSWORD,
AUTH_ROUTES.RESET_PASSWORD,
AUTH_ROUTES.AUTH_CONFIRM,
AUTH_ROUTES.AUTH_CALLBACK,
] as const;
// 복구 플로우 전용 쿠키 (비밀번호 재설정 화면 외 접근 차단에 사용)
export const RECOVERY_COOKIE_NAME = "sb-recovery";
export const RECOVERY_COOKIE_MAX_AGE_SECONDS = 10 * 60;
// ========================================
// 검증 규칙 상수
// ========================================
/**
* 비밀번호 검증 규칙
* - 최소 8자 이상
* - 대문자 1개 이상
* - 소문자 1개 이상
* - 숫자 1개 이상
* - 특수문자 1개 이상
*/
export const PASSWORD_RULES = {
MIN_LENGTH: 8,
REQUIRE_UPPERCASE: true,
REQUIRE_LOWERCASE: true,
REQUIRE_NUMBER: true,
REQUIRE_SPECIAL_CHAR: true,
} as const;
// ========================================
// 세션 관련 상수
// ========================================
/**
* 세션 타임아웃 시간 (밀리초)
* 환경 변수에서 분 단위를 가져와 밀리초로 변환합니다.
* 기본값: 30분
*/
export const SESSION_TIMEOUT_MS =
(Number(process.env.NEXT_PUBLIC_SESSION_TIMEOUT_MINUTES) || 30) * 60 * 1000;
// 경고 표시 시간 (타임아웃 1분 전)
export const SESSION_WARNING_MS = 60 * 1000;
// ========================================
// 타입 정의
// ========================================
/**
* 인증 폼 데이터 타입
*/
export type AuthFormData = {
email: string;
password: string;
};
/**
* 인증 에러 타입
*/
export type AuthError = {
message: string;
type: "validation" | "auth" | "unknown";
};

30
features/auth/errors.ts Normal file
View File

@@ -0,0 +1,30 @@
import {
AUTH_ERROR_CODE_MESSAGES,
AUTH_ERROR_MESSAGES,
AUTH_ERROR_STATUS_MESSAGES,
} from "./constants";
export type AuthApiErrorLike = {
message?: string | null;
code?: string | null;
status?: number | null;
};
/**
* Supabase Auth 에러 데이터를 인간이 읽을 수 있는 한글 메시지로 변환합니다.
*/
export function getAuthErrorMessage(error: AuthApiErrorLike): string {
if (error.code && error.code in AUTH_ERROR_CODE_MESSAGES) {
return AUTH_ERROR_CODE_MESSAGES[
error.code as keyof typeof AUTH_ERROR_CODE_MESSAGES
];
}
if (error.status && error.status in AUTH_ERROR_STATUS_MESSAGES) {
return AUTH_ERROR_STATUS_MESSAGES[
error.status as keyof typeof AUTH_ERROR_STATUS_MESSAGES
];
}
return AUTH_ERROR_MESSAGES.DEFAULT;
}

View File

@@ -0,0 +1,69 @@
import { z } from "zod";
import { PASSWORD_RULES } from "@/features/auth/constants";
const passwordSchema = z
.string()
.min(PASSWORD_RULES.MIN_LENGTH, {
message: `비밀번호는 최소 ${PASSWORD_RULES.MIN_LENGTH}자 이상이어야 합니다.`,
})
.regex(/[A-Z]/, {
message: "대문자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[a-z]/, {
message: "소문자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[0-9]/, {
message: "숫자를 최소 1개 이상 포함해야 합니다.",
})
.regex(/[!@#$%^&*(),.?":{}|<>]/, {
message: "특수문자를 최소 1개 이상 포함해야 합니다.",
});
export const signupSchema = z
.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
export const resetPasswordSchema = z
.object({
password: passwordSchema,
confirmPassword: z
.string()
.min(1, { message: "비밀번호 확인을 입력해 주세요." }),
})
.refine((data) => data.password === data.confirmPassword, {
message: "비밀번호가 일치하지 않습니다.",
path: ["confirmPassword"],
});
export const loginSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
password: z.string().min(1, { message: "비밀번호를 입력해 주세요." }),
rememberMe: z.boolean().optional(),
});
export const forgotPasswordSchema = z.object({
email: z
.string()
.min(1, { message: "이메일을 입력해 주세요." })
.email({ message: "유효한 이메일 형식이 아닙니다." }),
});
export type SignupFormData = z.infer<typeof signupSchema>;
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
export type LoginFormData = z.infer<typeof loginSchema>;
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;

View File

@@ -0,0 +1,82 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardKisRevokeResponse,
DashboardKisValidateResponse,
DashboardKisWsApprovalResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* KIS API 키 검증 요청
* @param credentials 검증할 키 정보
*/
export async function validateKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisValidateResponse> {
const response = await fetch("/api/kis/validate", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisValidateResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 검증에 실패했습니다.");
}
return payload;
}
/**
* KIS 접근토큰 폐기 요청
* @param credentials 폐기할 키 정보
*/
export async function revokeKisCredentials(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisRevokeResponse> {
const response = await fetch("/api/kis/revoke", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisRevokeResponse;
if (!response.ok || !payload.ok) {
throw new Error(payload.message || "API 키 접근 폐기에 실패했습니다.");
}
return payload;
}
/**
* KIS 실시간 웹소켓 승인키 발급 요청
* @param credentials 인증 정보
*/
export async function fetchKisWebSocketApproval(
credentials: KisRuntimeCredentials,
): Promise<DashboardKisWsApprovalResponse> {
const response = await fetch("/api/kis/ws/approval", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify(credentials),
cache: "no-store",
});
const payload = (await response.json()) as DashboardKisWsApprovalResponse;
if (!response.ok || !payload.ok || !payload.approvalKey || !payload.wsUrl) {
throw new Error(
payload.message || "KIS 실시간 웹소켓 승인키 발급에 실패했습니다.",
);
}
return payload;
}

View File

@@ -0,0 +1,179 @@
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
DashboardStockChartResponse,
DashboardStockOrderBookResponse,
DashboardStockOverviewResponse,
DashboardStockSearchResponse,
} from "@/features/dashboard/types/dashboard.types";
/**
* 종목 검색 API 호출
* @param keyword 검색어
*/
export async function fetchStockSearch(
keyword: string,
signal?: AbortSignal,
): Promise<DashboardStockSearchResponse> {
const response = await fetch(
`/api/kis/domestic/search?q=${encodeURIComponent(keyword)}`,
{
cache: "no-store",
signal,
},
);
const payload = (await response.json()) as
| DashboardStockSearchResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 검색 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockSearchResponse;
}
/**
* 종목 상세 개요 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOverview(
symbol: string,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockOverviewResponse> {
const response = await fetch(
`/api/kis/domestic/overview?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store",
},
);
const payload = (await response.json()) as
| DashboardStockOverviewResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "종목 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOverviewResponse;
}
/**
* 종목 호가 조회 API 호출
* @param symbol 종목코드
* @param credentials KIS 인증 정보
*/
export async function fetchStockOrderBook(
symbol: string,
credentials: KisRuntimeCredentials,
signal?: AbortSignal,
): Promise<DashboardStockOrderBookResponse> {
const response = await fetch(
`/api/kis/domestic/orderbook?symbol=${encodeURIComponent(symbol)}`,
{
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store", // 호가는 실시간성이 중요하므로 항상 최신 데이터 조회
signal,
},
);
const payload = (await response.json()) as
| DashboardStockOrderBookResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "호가 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockOrderBookResponse;
}
/**
* 종목 차트(분봉/일봉/주봉) 조회 API 호출
*/
export async function fetchStockChart(
symbol: string,
timeframe: DashboardChartTimeframe,
credentials: KisRuntimeCredentials,
cursor?: string,
): Promise<DashboardStockChartResponse> {
const query = new URLSearchParams({
symbol,
timeframe,
});
if (cursor) query.set("cursor", cursor);
const response = await fetch(`/api/kis/domestic/chart?${query.toString()}`, {
method: "GET",
headers: {
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
cache: "no-store",
});
const payload = (await response.json()) as
| DashboardStockChartResponse
| { error?: string };
if (!response.ok) {
throw new Error(
"error" in payload ? payload.error : "차트 조회 중 오류가 발생했습니다.",
);
}
return payload as DashboardStockChartResponse;
}
/**
* 주식 현금 주문 API 호출
* @param request 주문 요청 데이터
* @param credentials KIS 인증 정보
*/
export async function fetchOrderCash(
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials,
): Promise<DashboardStockCashOrderResponse> {
const response = await fetch("/api/kis/domestic/order-cash", {
method: "POST",
headers: {
"content-type": "application/json",
"x-kis-app-key": credentials.appKey,
"x-kis-app-secret": credentials.appSecret,
"x-kis-trading-env": credentials.tradingEnv,
},
body: JSON.stringify(request),
cache: "no-store",
});
const payload = (await response.json()) as DashboardStockCashOrderResponse;
if (!response.ok) {
throw new Error(payload.message || "주문 전송 중 오류가 발생했습니다.");
}
return payload;
}

View File

@@ -0,0 +1,279 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { KisAuthForm } from "@/features/dashboard/components/auth/KisAuthForm";
import { StockSearchForm } from "@/features/dashboard/components/search/StockSearchForm";
import { StockSearchResults } from "@/features/dashboard/components/search/StockSearchResults";
import { useStockSearch } from "@/features/dashboard/hooks/useStockSearch";
import { useOrderBook } from "@/features/dashboard/hooks/useOrderBook";
import { useKisTradeWebSocket } from "@/features/dashboard/hooks/useKisTradeWebSocket";
import { useStockOverview } from "@/features/dashboard/hooks/useStockOverview";
import { DashboardLayout } from "@/features/dashboard/components/layout/DashboardLayout";
import { StockHeader } from "@/features/dashboard/components/header/StockHeader";
import { OrderBook } from "@/features/dashboard/components/orderbook/OrderBook";
import { OrderForm } from "@/features/dashboard/components/order/OrderForm";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import type {
DashboardStockItem,
DashboardStockOrderBookResponse,
DashboardStockSearchItem,
} from "@/features/dashboard/types/dashboard.types";
/**
* @description 대시보드 메인 컨테이너
* @see app/(main)/dashboard/page.tsx 로그인 완료 후 이 컴포넌트를 렌더링합니다.
* @see features/dashboard/hooks/useStockSearch.ts 검색 입력/요청 상태를 관리합니다.
* @see features/dashboard/hooks/useStockOverview.ts 선택 종목 상세 상태를 관리합니다.
*/
export function DashboardContainer() {
const skipNextAutoSearchRef = useRef(false);
const { verifiedCredentials, isKisVerified } = useKisRuntimeStore(
useShallow((state) => ({
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
})),
);
const {
keyword,
setKeyword,
searchResults,
setSearchResults,
setError: setSearchError,
isSearching,
search,
clearSearch,
} = useStockSearch();
const { selectedStock, loadOverview, updateRealtimeTradeTick } =
useStockOverview();
// 호가 실시간 데이터 (체결 WS에서 동일 소켓으로 수신)
const [realtimeOrderBook, setRealtimeOrderBook] =
useState<DashboardStockOrderBookResponse | null>(null);
const handleOrderBookMessage = useCallback(
(data: DashboardStockOrderBookResponse) => {
setRealtimeOrderBook(data);
},
[],
);
// 1. Trade WebSocket (체결 + 호가 통합)
const { latestTick, realtimeCandles, recentTradeTicks } =
useKisTradeWebSocket(
selectedStock?.symbol,
verifiedCredentials,
isKisVerified,
updateRealtimeTradeTick,
{
orderBookSymbol: selectedStock?.symbol,
onOrderBookMessage: handleOrderBookMessage,
},
);
// 2. OrderBook (REST 초기 조회 + WS 실시간 병합)
const { orderBook, isLoading: isOrderBookLoading } = useOrderBook(
selectedStock?.symbol,
selectedStock?.market,
verifiedCredentials,
isKisVerified,
{
enabled: !!selectedStock && !!verifiedCredentials && isKisVerified,
externalRealtimeOrderBook: realtimeOrderBook,
},
);
useEffect(() => {
if (skipNextAutoSearchRef.current) {
skipNextAutoSearchRef.current = false;
return;
}
if (!isKisVerified || !verifiedCredentials) {
clearSearch();
return;
}
const trimmed = keyword.trim();
if (!trimmed) {
clearSearch();
return;
}
const timer = window.setTimeout(() => {
search(trimmed, verifiedCredentials);
}, 220);
return () => window.clearTimeout(timer);
}, [keyword, isKisVerified, verifiedCredentials, search, clearSearch]);
// Price Calculation Logic
// Prioritize latestTick (Real Exec) > OrderBook Ask1 (Proxy) > REST Data
let currentPrice = selectedStock?.currentPrice;
let change = selectedStock?.change;
let changeRate = selectedStock?.changeRate;
if (latestTick) {
currentPrice = latestTick.price;
change = latestTick.change;
changeRate = latestTick.changeRate;
} else if (orderBook?.levels[0]?.askPrice) {
// Fallback: Use Best Ask Price as proxy for current price
const askPrice = orderBook.levels[0].askPrice;
if (askPrice > 0) {
currentPrice = askPrice;
// Recalculate change/rate based on prevClose
if (selectedStock && selectedStock.prevClose > 0) {
change = currentPrice - selectedStock.prevClose;
changeRate = (change / selectedStock.prevClose) * 100;
}
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
search(keyword, verifiedCredentials);
}
function handleSelectStock(item: DashboardStockSearchItem) {
if (!isKisVerified || !verifiedCredentials) {
setSearchError("API 키 검증을 먼저 완료해 주세요.");
return;
}
// 이미 선택된 종목을 다시 누른 경우 불필요한 개요 API 재호출을 막습니다.
if (selectedStock?.symbol === item.symbol) {
setSearchResults([]);
return;
}
// 카드 선택으로 keyword가 바뀔 때 자동 검색이 다시 돌지 않도록 1회 건너뜁니다.
skipNextAutoSearchRef.current = true;
setKeyword(item.name);
setSearchResults([]);
loadOverview(item.symbol, verifiedCredentials, item.market);
}
return (
<div className="relative h-full flex flex-col">
{/* ========== AUTH STATUS ========== */}
<div className="flex-none border-b bg-muted/40 transition-all duration-300 ease-in-out">
<div className="flex items-center justify-between px-4 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="font-semibold">KIS API :</span>
{isKisVerified ? (
<span className="text-green-600 font-medium flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
(
{verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-muted-foreground flex items-center">
<span className="mr-1.5 h-2 w-2 rounded-full bg-gray-300" />
</span>
)}
</div>
</div>
<div className="overflow-hidden transition-[max-height,opacity] duration-300 ease-in-out max-h-[500px] opacity-100">
<div className="p-4 border-t bg-background">
<KisAuthForm />
</div>
</div>
</div>
{/* ========== SEARCH ========== */}
<div className="flex-none p-4 border-b bg-background/95 backdrop-blur-sm z-30">
<div className="max-w-2xl mx-auto space-y-2 relative">
<StockSearchForm
keyword={keyword}
onKeywordChange={setKeyword}
onSubmit={handleSearchSubmit}
disabled={!isKisVerified}
isLoading={isSearching}
/>
{searchResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 z-50 bg-background border rounded-md shadow-lg max-h-80 overflow-y-auto overflow-x-hidden">
<StockSearchResults
items={searchResults}
onSelect={handleSelectStock}
selectedSymbol={
(selectedStock as DashboardStockItem | null)?.symbol
}
/>
</div>
)}
</div>
</div>
{/* ========== MAIN CONTENT ========== */}
<div
className={cn(
"transition-opacity duration-200 h-full flex-1 min-h-0 overflow-x-hidden",
!selectedStock && "opacity-20 pointer-events-none",
)}
>
<DashboardLayout
header={
selectedStock ? (
<StockHeader
stock={selectedStock}
price={currentPrice?.toLocaleString() ?? "0"}
change={change?.toLocaleString() ?? "0"}
changeRate={changeRate?.toFixed(2) ?? "0.00"}
high={latestTick ? latestTick.high.toLocaleString() : undefined} // High/Low/Vol only from Tick or Static
low={latestTick ? latestTick.low.toLocaleString() : undefined}
volume={
latestTick
? latestTick.accumulatedVolume.toLocaleString()
: undefined
}
/>
) : null
}
chart={
selectedStock ? (
<div className="p-0 h-full flex flex-col">
<StockLineChart
symbol={selectedStock.symbol}
candles={
realtimeCandles.length > 0
? realtimeCandles
: selectedStock.candles
}
credentials={verifiedCredentials}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground">
</div>
)
}
orderBook={
<OrderBook
symbol={selectedStock?.symbol}
referencePrice={selectedStock?.prevClose}
currentPrice={currentPrice}
latestTick={latestTick}
recentTicks={recentTradeTicks}
orderBook={orderBook}
isLoading={isOrderBookLoading}
/>
}
orderForm={<OrderForm stock={selectedStock ?? undefined} />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useTransition } from "react";
import { useShallow } from "zustand/react/shallow";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import {
revokeKisCredentials,
validateKisCredentials,
} from "@/features/dashboard/apis/kis-auth.api";
/**
* @description KIS 인증 입력 폼
* @see features/dashboard/store/use-kis-runtime-store.ts 인증 입력값/검증 상태를 저장합니다.
*/
export function KisAuthForm() {
const {
kisTradingEnvInput,
kisAppKeyInput,
kisAppSecretInput,
verifiedCredentials,
isKisVerified,
setKisTradingEnvInput,
setKisAppKeyInput,
setKisAppSecretInput,
setVerifiedKisSession,
invalidateKisVerification,
clearKisRuntimeSession,
} = useKisRuntimeStore(
useShallow((state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
setKisTradingEnvInput: state.setKisTradingEnvInput,
setKisAppKeyInput: state.setKisAppKeyInput,
setKisAppSecretInput: state.setKisAppSecretInput,
setVerifiedKisSession: state.setVerifiedKisSession,
invalidateKisVerification: state.invalidateKisVerification,
clearKisRuntimeSession: state.clearKisRuntimeSession,
})),
);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isValidating, startValidateTransition] = useTransition();
const [isRevoking, startRevokeTransition] = useTransition();
function handleValidate() {
startValidateTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const appKey = kisAppKeyInput.trim();
const appSecret = kisAppSecretInput.trim();
if (!appKey || !appSecret) {
throw new Error("App Key와 App Secret을 모두 입력해 주세요.");
}
// 주문 기능에서 계좌번호가 필요할 수 있어 구조는 유지하되, 인증 단계에서는 입력받지 않습니다.
const credentials = {
appKey,
appSecret,
tradingEnv: kisTradingEnvInput,
accountNo: verifiedCredentials?.accountNo ?? "",
};
const result = await validateKisCredentials(credentials);
setVerifiedKisSession(credentials, result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
invalidateKisVerification();
setErrorMessage(
err instanceof Error
? err.message
: "API 키 검증 중 오류가 발생했습니다.",
);
}
});
}
function handleRevoke() {
if (!verifiedCredentials) return;
startRevokeTransition(async () => {
try {
setErrorMessage(null);
setStatusMessage(null);
const result = await revokeKisCredentials(verifiedCredentials);
clearKisRuntimeSession(result.tradingEnv);
setStatusMessage(
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
);
} catch (err) {
setErrorMessage(
err instanceof Error
? err.message
: "연결 해제 중 오류가 발생했습니다.",
);
}
});
}
return (
<Card className="border-brand-200 bg-linear-to-r from-brand-50/60 to-background">
<CardHeader>
<CardTitle>KIS API </CardTitle>
<CardDescription>
, API .
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* ========== CREDENTIAL INPUTS ========== */}
<div className="grid gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-muted-foreground">
</label>
<div className="flex gap-2">
<Button
type="button"
variant={kisTradingEnvInput === "real" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "real"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("real")}
>
</Button>
<Button
type="button"
variant={kisTradingEnvInput === "mock" ? "default" : "outline"}
className={cn(
"flex-1",
kisTradingEnvInput === "mock"
? "bg-brand-600 hover:bg-brand-700"
: "",
)}
onClick={() => setKisTradingEnvInput("mock")}
>
</Button>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Key
</label>
<Input
type="password"
value={kisAppKeyInput}
onChange={(e) => setKisAppKeyInput(e.target.value)}
placeholder="App Key 입력"
autoComplete="off"
/>
</div>
<div>
<label className="mb-1 block text-xs text-muted-foreground">
KIS App Secret
</label>
<Input
type="password"
value={kisAppSecretInput}
onChange={(e) => setKisAppSecretInput(e.target.value)}
placeholder="App Secret 입력"
autoComplete="off"
/>
</div>
</div>
{/* ========== ACTIONS ========== */}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
onClick={handleValidate}
disabled={
isValidating || !kisAppKeyInput.trim() || !kisAppSecretInput.trim()
}
className="bg-brand-600 hover:bg-brand-700"
>
{isValidating ? "검증 중..." : "API 키 검증"}
</Button>
<Button
type="button"
variant="outline"
onClick={handleRevoke}
disabled={isRevoking || !isKisVerified || !verifiedCredentials}
className="border-brand-200 text-brand-700 hover:bg-brand-50 hover:text-brand-800"
>
{isRevoking ? "해제 중..." : "연결 끊기"}
</Button>
{isKisVerified ? (
<span className="flex items-center text-sm font-medium text-green-600">
<span className="mr-1.5 h-2 w-2 rounded-full bg-green-500 ring-2 ring-green-100" />
({verifiedCredentials?.tradingEnv === "real" ? "실전" : "모의"})
</span>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
{errorMessage && (
<div className="text-sm font-medium text-destructive">{errorMessage}</div>
)}
{statusMessage && <div className="text-sm text-blue-600">{statusMessage}</div>}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,484 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
CandlestickSeries,
ColorType,
HistogramSeries,
createChart,
type IChartApi,
type ISeriesApi,
type Time,
} from "lightweight-charts";
import { ChevronDown } from "lucide-react";
import { toast } from "sonner";
import { fetchStockChart } from "@/features/dashboard/apis/kis-stock.api";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import {
type ChartBar,
convertCandleToBar,
formatPrice,
formatSignedPercent,
isMinuteTimeframe,
mergeBars,
normalizeCandles,
upsertRealtimeBar,
} from "./chart-utils";
const UP_COLOR = "#ef4444";
const DOWN_COLOR = "#2563eb";
// 분봉 드롭다운 옵션
const MINUTE_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1m", label: "1분" },
{ value: "30m", label: "30분" },
{ value: "1h", label: "1시간" },
];
// 일봉 이상 개별 버튼
const PERIOD_TIMEFRAMES: Array<{
value: DashboardChartTimeframe;
label: string;
}> = [
{ value: "1d", label: "일" },
{ value: "1w", label: "주" },
];
// ChartBar 타입은 chart-utils.ts에서 import
interface StockLineChartProps {
symbol?: string;
candles: StockCandlePoint[];
credentials?: KisRuntimeCredentials | null;
}
/**
* @description TradingView 스타일 캔들 차트 + 거래량 + 무한 과거 로딩
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/realtime-updates
* @see https://tradingview.github.io/lightweight-charts/tutorials/demos/infinite-history
*/
export function StockLineChart({
symbol,
candles,
credentials,
}: StockLineChartProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const candleSeriesRef = useRef<ISeriesApi<"Candlestick", Time> | null>(null);
const volumeSeriesRef = useRef<ISeriesApi<"Histogram", Time> | null>(null);
const [timeframe, setTimeframe] = useState<DashboardChartTimeframe>("1d");
const [isMinuteDropdownOpen, setIsMinuteDropdownOpen] = useState(false);
const [bars, setBars] = useState<ChartBar[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [isChartReady, setIsChartReady] = useState(false);
const lastRealtimeKeyRef = useRef<string>("");
const loadingMoreRef = useRef(false);
const loadMoreHandlerRef = useRef<() => Promise<void>>(async () => {});
const initialLoadCompleteRef = useRef(false);
// candles prop을 ref로 관리하여 useEffect 디펜던시에서 제거 (무한 페칭 방지)
const latestCandlesRef = useRef(candles);
useEffect(() => {
latestCandlesRef.current = candles;
}, [candles]);
const latest = bars.at(-1);
const prevClose = bars.length > 1 ? (bars.at(-2)?.close ?? 0) : 0;
const change = latest ? latest.close - prevClose : 0;
const changeRate = prevClose > 0 ? (change / prevClose) * 100 : 0;
const renderableBars = useMemo(() => {
const dedup = new Map<number, ChartBar>();
for (const bar of bars) {
if (
!Number.isFinite(bar.time) ||
!Number.isFinite(bar.open) ||
!Number.isFinite(bar.high) ||
!Number.isFinite(bar.low) ||
!Number.isFinite(bar.close) ||
bar.close <= 0
) {
continue;
}
dedup.set(bar.time, bar);
}
return [...dedup.values()].sort((a, b) => a.time - b.time);
}, [bars]);
const setSeriesData = useCallback((nextBars: ChartBar[]) => {
const candleSeries = candleSeriesRef.current;
const volumeSeries = volumeSeriesRef.current;
if (!candleSeries || !volumeSeries) return;
// lightweight-charts는 시간 오름차순/유효 숫자 조건이 깨지면 렌더를 멈출 수 있어
// 전달 직전 데이터를 한 번 더 정리합니다.
const safeBars = nextBars;
try {
candleSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
open: bar.open,
high: bar.high,
low: bar.low,
close: bar.close,
})),
);
volumeSeries.setData(
safeBars.map((bar) => ({
time: bar.time,
value: Number.isFinite(bar.volume) ? bar.volume : 0,
color:
bar.close >= bar.open
? "rgba(239,68,68,0.45)"
: "rgba(37,99,235,0.45)",
})),
);
} catch (error) {
console.error("Failed to render chart series data:", error);
}
}, []);
const handleLoadMore = useCallback(async () => {
// 분봉은 당일 데이터만 제공되므로 과거 로딩 불가
if (isMinuteTimeframe(timeframe)) return;
if (!symbol || !credentials || !nextCursor || loadingMoreRef.current)
return;
loadingMoreRef.current = true;
setIsLoadingMore(true);
try {
const response = await fetchStockChart(
symbol,
timeframe,
credentials,
nextCursor,
);
const older = normalizeCandles(response.candles, timeframe);
setBars((prev) => mergeBars(older, prev));
setNextCursor(response.hasMore ? response.nextCursor : null);
} catch (error) {
const msg =
error instanceof Error
? error.message
: "과거 차트 조회에 실패했습니다.";
toast.error(msg);
} finally {
loadingMoreRef.current = false;
setIsLoadingMore(false);
}
}, [credentials, nextCursor, symbol, timeframe]);
useEffect(() => {
loadMoreHandlerRef.current = handleLoadMore;
}, [handleLoadMore]);
useEffect(() => {
const container = containerRef.current;
if (!container || chartRef.current) return;
const initialWidth = Math.max(container.clientWidth, 320);
const initialHeight = Math.max(container.clientHeight, 340);
const chart = createChart(container, {
width: initialWidth,
height: initialHeight,
layout: {
background: { type: ColorType.Solid, color: "#ffffff" },
textColor: "#475569",
attributionLogo: true,
},
localization: {
locale: "ko-KR",
},
rightPriceScale: {
borderColor: "#e2e8f0",
scaleMargins: {
top: 0.08,
bottom: 0.24,
},
},
grid: {
vertLines: { color: "#edf1f5" },
horzLines: { color: "#edf1f5" },
},
crosshair: {
vertLine: { color: "#94a3b8", width: 1, style: 2 },
horzLine: { color: "#94a3b8", width: 1, style: 2 },
},
timeScale: {
borderColor: "#e2e8f0",
timeVisible: true,
secondsVisible: false,
rightOffset: 2,
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
mouseWheel: true,
pinch: true,
axisPressedMouseMove: true,
},
});
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: UP_COLOR,
downColor: DOWN_COLOR,
wickUpColor: UP_COLOR,
wickDownColor: DOWN_COLOR,
borderUpColor: UP_COLOR,
borderDownColor: DOWN_COLOR,
priceLineVisible: true,
lastValueVisible: true,
});
const volumeSeries = chart.addSeries(HistogramSeries, {
priceScaleId: "volume",
priceLineVisible: false,
lastValueVisible: false,
base: 0,
});
chart.priceScale("volume").applyOptions({
scaleMargins: {
top: 0.78,
bottom: 0,
},
borderVisible: false,
});
// 스크롤 디바운스 타이머
let scrollTimeout: NodeJS.Timeout | null = null;
chart.timeScale().subscribeVisibleLogicalRangeChange((range) => {
if (!range) return;
// 분봉은 당일 데이터만 제공되므로 무한 스크롤 비활성화
if (range.from < 10 && initialLoadCompleteRef.current) {
if (scrollTimeout) clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
void loadMoreHandlerRef.current();
}, 300);
}
});
chartRef.current = chart;
candleSeriesRef.current = candleSeries;
volumeSeriesRef.current = volumeSeries;
setIsChartReady(true);
const resizeObserver = new ResizeObserver(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
resizeObserver.observe(container);
// 첫 렌더 직후 부모 레이아웃 계산이 끝난 시점에 한 번 더 사이즈를 맞춥니다.
const rafId = window.requestAnimationFrame(() => {
const nextWidth = Math.max(container.clientWidth, 320);
const nextHeight = Math.max(container.clientHeight, 340);
chart.resize(nextWidth, nextHeight);
});
return () => {
window.cancelAnimationFrame(rafId);
resizeObserver.disconnect();
chart.remove();
chartRef.current = null;
candleSeriesRef.current = null;
volumeSeriesRef.current = null;
setIsChartReady(false);
};
}, []);
useEffect(() => {
if (symbol && credentials) return;
setBars(normalizeCandles(candles, "1d"));
setNextCursor(null);
}, [candles, credentials, symbol]);
useEffect(() => {
if (!symbol || !credentials) return;
// 초기 로딩 보호 플래그 초기화 (타임프레임/종목 변경 시)
initialLoadCompleteRef.current = false;
let disposed = false;
const load = async () => {
setIsLoading(true);
try {
const response = await fetchStockChart(symbol, timeframe, credentials);
if (disposed) return;
const normalized = normalizeCandles(response.candles, timeframe);
setBars(normalized);
setNextCursor(response.hasMore ? response.nextCursor : null);
// 초기 로딩 완료 후 500ms 지연 후 무한 스크롤 활성화
// (fitContent 후 range가 0이 되어 즉시 트리거되는 것 방지)
setTimeout(() => {
if (!disposed) {
initialLoadCompleteRef.current = true;
}
}, 500);
} catch (error) {
if (disposed) return;
const message =
error instanceof Error ? error.message : "차트 조회에 실패했습니다.";
toast.error(message);
// 에러 발생 시 fallback으로 props로 전달된 candles 사용
setBars(normalizeCandles(latestCandlesRef.current, timeframe));
setNextCursor(null);
} finally {
if (!disposed) setIsLoading(false);
}
};
void load();
return () => {
disposed = true;
};
}, [credentials, symbol, timeframe]);
useEffect(() => {
if (!isChartReady) return;
setSeriesData(renderableBars);
// 초기 로딩 시에만 fitContent 수행
if (!initialLoadCompleteRef.current && renderableBars.length > 0) {
chartRef.current?.timeScale().fitContent();
}
}, [isChartReady, renderableBars, setSeriesData]);
const latestRealtime = useMemo(() => candles.at(-1), [candles]);
useEffect(() => {
if (!latestRealtime || bars.length === 0) return;
if (timeframe === "1w" && !latestRealtime.timestamp && !latestRealtime.time)
return;
const key = `${latestRealtime.time}-${latestRealtime.price}-${latestRealtime.volume ?? 0}`;
if (lastRealtimeKeyRef.current === key) return;
lastRealtimeKeyRef.current = key;
const nextBar = convertCandleToBar(latestRealtime, timeframe);
if (!nextBar) return;
setBars((prev) => upsertRealtimeBar(prev, nextBar));
}, [bars.length, candles, latestRealtime, timeframe]);
// 상태 메시지 결정 (오버레이로 표시)
const statusMessage = (() => {
if (isLoading && bars.length === 0)
return "차트 데이터를 불러오는 중입니다.";
if (bars.length === 0) return "차트 데이터가 없습니다.";
if (renderableBars.length === 0)
return "차트 데이터 형식이 올바르지 않아 렌더링할 수 없습니다.";
return null;
})();
return (
<div className="flex h-full min-h-[340px] flex-col bg-white">
{/* ========== CHART TOOLBAR ========== */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-200 px-2 py-2 sm:px-3">
<div className="flex flex-wrap items-center gap-1 text-xs sm:text-sm">
{/* 분봉 드롭다운 */}
<div className="relative">
<button
type="button"
onClick={() => setIsMinuteDropdownOpen((v) => !v)}
onBlur={() =>
setTimeout(() => setIsMinuteDropdownOpen(false), 200)
}
className={cn(
"flex items-center gap-1 rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
MINUTE_TIMEFRAMES.some((t) => t.value === timeframe) &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{MINUTE_TIMEFRAMES.find((t) => t.value === timeframe)?.label ??
"분봉"}
<ChevronDown className="h-3 w-3" />
</button>
{isMinuteDropdownOpen && (
<div className="absolute left-0 top-full z-10 mt-1 rounded border border-slate-200 bg-white shadow-lg">
{MINUTE_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => {
setTimeframe(item.value);
setIsMinuteDropdownOpen(false);
}}
className={cn(
"block w-full whitespace-nowrap px-3 py-1.5 text-left hover:bg-slate-100",
timeframe === item.value &&
"bg-brand-50 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
</div>
)}
</div>
{/* 일/주 버튼 */}
{PERIOD_TIMEFRAMES.map((item) => (
<button
key={item.value}
type="button"
onClick={() => setTimeframe(item.value)}
className={cn(
"rounded px-2 py-1 text-slate-600 transition-colors hover:bg-slate-100",
timeframe === item.value &&
"bg-brand-100 font-semibold text-brand-700",
)}
>
{item.label}
</button>
))}
{isLoadingMore && (
<span className="ml-2 text-[11px] text-muted-foreground">
...
</span>
)}
</div>
<div className="text-[11px] text-slate-600 sm:text-xs">
O {formatPrice(latest?.open ?? 0)} H {formatPrice(latest?.high ?? 0)}{" "}
L {formatPrice(latest?.low ?? 0)} C{" "}
<span className={cn(change >= 0 ? "text-red-600" : "text-blue-600")}>
{formatPrice(latest?.close ?? 0)} ({formatSignedPercent(changeRate)}
)
</span>
</div>
</div>
{/* ========== CHART BODY ========== */}
<div className="relative min-h-0 flex-1 overflow-hidden">
{/* 차트 캔버스 컨테이너 - 항상 렌더링 */}
<div ref={containerRef} className="h-full w-full" />
{/* 상태 메시지 오버레이 */}
{statusMessage && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 text-sm text-muted-foreground">
{statusMessage}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
/**
* @file chart-utils.ts
* @description StockLineChart에서 사용하는 유틸리티 함수 모음
*/
import type { UTCTimestamp } from "lightweight-charts";
import type {
DashboardChartTimeframe,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
// ─── 타입 ──────────────────────────────────────────────────
export type ChartBar = {
time: UTCTimestamp;
open: number;
high: number;
low: number;
close: number;
volume: number;
};
// ─── StockCandlePoint → ChartBar 변환 ─────────────────────
/**
* candles 배열을 ChartBar 배열로 정규화 (무효값 필터 + 병합 + 정렬)
*/
export function normalizeCandles(
candles: StockCandlePoint[],
timeframe: DashboardChartTimeframe,
): ChartBar[] {
const rows = candles
.map((c) => convertCandleToBar(c, timeframe))
.filter((b): b is ChartBar => Boolean(b));
return mergeBars([], rows);
}
/**
* 단일 candle → ChartBar 변환. 유효하지 않으면 null
*/
export function convertCandleToBar(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): ChartBar | null {
const close = candle.close ?? candle.price;
if (!Number.isFinite(close) || close <= 0) return null;
const open = candle.open ?? close;
const high = candle.high ?? Math.max(open, close);
const low = candle.low ?? Math.min(open, close);
const volume = candle.volume ?? 0;
const time = resolveBarTimestamp(candle, timeframe);
if (!time) return null;
return {
time,
open,
high: Math.max(high, open, close),
low: Math.min(low, open, close),
close,
volume,
};
}
// ─── 타임스탬프 해석/정렬 ─────────────────────────────────
function resolveBarTimestamp(
candle: StockCandlePoint,
timeframe: DashboardChartTimeframe,
): UTCTimestamp | null {
// timestamp 필드가 있으면 우선 사용
if (
typeof candle.timestamp === "number" &&
Number.isFinite(candle.timestamp)
) {
return alignTimestamp(candle.timestamp, timeframe);
}
const text = typeof candle.time === "string" ? candle.time.trim() : "";
if (!text) return null;
// "MM/DD" 형식 (일봉)
if (/^\d{2}\/\d{2}$/.test(text)) {
const [mm, dd] = text.split("/");
const year = new Date().getFullYear();
const ts = Math.floor(
new Date(`${year}-${mm}-${dd}T09:00:00+09:00`).getTime() / 1000,
);
return alignTimestamp(ts, timeframe);
}
// "HH:MM" 또는 "HH:MM:SS" 형식 (분봉)
if (/^\d{2}:\d{2}(:\d{2})?$/.test(text)) {
const [hh, mi, ss] = text.split(":");
const now = new Date();
const y = now.getFullYear();
const m = `${now.getMonth() + 1}`.padStart(2, "0");
const d = `${now.getDate()}`.padStart(2, "0");
const ts = Math.floor(
new Date(`${y}-${m}-${d}T${hh}:${mi}:${ss ?? "00"}+09:00`).getTime() /
1000,
);
return alignTimestamp(ts, timeframe);
}
return null;
}
/**
* 타임스탬프를 타임프레임 버킷 경계에 정렬
* - 1m: 그대로
* - 30m/1h: 분 단위를 버킷에 정렬
* - 1d: 00:00:00
* - 1w: 월요일 00:00:00
*/
function alignTimestamp(
timestamp: number,
timeframe: DashboardChartTimeframe,
): UTCTimestamp {
const d = new Date(timestamp * 1000);
if (timeframe === "30m" || timeframe === "1h") {
const bucket = timeframe === "30m" ? 30 : 60;
d.setUTCMinutes(Math.floor(d.getUTCMinutes() / bucket) * bucket, 0, 0);
} else if (timeframe === "1d") {
d.setUTCHours(0, 0, 0, 0);
} else if (timeframe === "1w") {
const day = d.getUTCDay();
d.setUTCDate(d.getUTCDate() + (day === 0 ? -6 : 1 - day));
d.setUTCHours(0, 0, 0, 0);
}
return Math.floor(d.getTime() / 1000) as UTCTimestamp;
}
// ─── 봉 병합 ──────────────────────────────────────────────
/**
* 두 ChartBar 배열을 시간 기준으로 병합. 같은 시간대는 OHLCV 통합
*/
export function mergeBars(left: ChartBar[], right: ChartBar[]): ChartBar[] {
const map = new Map<number, ChartBar>();
for (const bar of [...left, ...right]) {
const prev = map.get(bar.time);
if (!prev) {
map.set(bar.time, bar);
continue;
}
map.set(bar.time, {
time: bar.time,
open: prev.open,
high: Math.max(prev.high, bar.high),
low: Math.min(prev.low, bar.low),
close: bar.close,
volume: Math.max(prev.volume, bar.volume),
});
}
return [...map.values()].sort((a, b) => a.time - b.time);
}
/**
* 실시간 봉 업데이트: 같은 시간이면 기존 봉에 병합, 새 시간이면 추가
*/
export function upsertRealtimeBar(
prev: ChartBar[],
incoming: ChartBar,
): ChartBar[] {
if (prev.length === 0) return [incoming];
const last = prev[prev.length - 1];
if (incoming.time > last.time) return [...prev, incoming];
if (incoming.time < last.time) return prev;
return [
...prev.slice(0, -1),
{
time: last.time,
open: last.open,
high: Math.max(last.high, incoming.high),
low: Math.min(last.low, incoming.low),
close: incoming.close,
volume: Math.max(last.volume, incoming.volume),
},
];
}
// ─── 포맷터 ───────────────────────────────────────────────
export function formatPrice(value: number) {
return KRW_FORMATTER.format(Math.round(value));
}
export function formatSignedPercent(value: number) {
const sign = value > 0 ? "+" : "";
return `${sign}${value.toFixed(2)}%`;
}
/**
* 분봉 타임프레임인지 판별
*/
export function isMinuteTimeframe(tf: DashboardChartTimeframe) {
return tf === "1m" || tf === "30m" || tf === "1h";
}

View File

@@ -0,0 +1,144 @@
import { Activity, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { StockLineChart } from "@/features/dashboard/components/chart/StockLineChart";
import { StockPriceBadge } from "@/features/dashboard/components/details/StockPriceBadge";
import type {
DashboardStockItem,
DashboardPriceSource,
DashboardMarketPhase,
} from "@/features/dashboard/types/dashboard.types";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatVolume(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
function getPriceSourceLabel(
source: DashboardPriceSource,
marketPhase: DashboardMarketPhase,
) {
switch (source) {
case "inquire-overtime-price":
return "시간외 현재가(inquire-overtime-price)";
case "inquire-ccnl":
return marketPhase === "afterHours"
? "체결가 폴백(inquire-ccnl)"
: "체결가(inquire-ccnl)";
default:
return "현재가(inquire-price)";
}
}
function PriceStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-semibold text-foreground">{value}</p>
</div>
);
}
interface StockOverviewCardProps {
stock: DashboardStockItem;
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
isRealtimeConnected: boolean;
realtimeTrId: string | null;
lastRealtimeTickAt: number | null;
}
export function StockOverviewCard({
stock,
priceSource,
marketPhase,
isRealtimeConnected,
realtimeTrId,
lastRealtimeTickAt,
}: StockOverviewCardProps) {
const apiPriceSourceLabel = getPriceSourceLabel(priceSource, marketPhase);
const effectivePriceSourceLabel =
isRealtimeConnected && lastRealtimeTickAt
? `실시간 체결(WebSocket ${realtimeTrId || ""})`
: apiPriceSourceLabel;
return (
<Card className="overflow-hidden border-brand-200">
<CardHeader className="border-b border-border/50 bg-muted/30 pb-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">{stock.name}</CardTitle>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{stock.symbol}
</span>
<span className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
{stock.market}
</span>
</div>
<CardDescription className="mt-1 flex items-center gap-1.5">
<span>{effectivePriceSourceLabel}</span>
{isRealtimeConnected && (
<span className="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-xs font-medium text-green-700">
<Activity className="h-3 w-3" />
</span>
)}
</CardDescription>
</div>
<div className="flex flex-col items-end gap-1">
<StockPriceBadge
currentPrice={stock.currentPrice}
change={stock.change}
changeRate={stock.changeRate}
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
<div className="grid border-b border-border/50 lg:grid-cols-3">
<div className="col-span-2 border-r border-border/50">
{/* Chart Area */}
<div className="p-6">
<StockLineChart candles={stock.candles} />
</div>
</div>
<div className="col-span-1 bg-muted/10 p-6">
<div className="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground/80">
<ShieldCheck className="h-4 w-4 text-brand-600" />
</div>
<div className="grid grid-cols-2 gap-3">
<PriceStat
label="시가"
value={`${PRICE_FORMATTER.format(stock.open)}`}
/>
<PriceStat
label="고가"
value={`${PRICE_FORMATTER.format(stock.high)}`}
/>
<PriceStat
label="저가"
value={`${PRICE_FORMATTER.format(stock.low)}`}
/>
<PriceStat
label="전일종가"
value={`${PRICE_FORMATTER.format(stock.prevClose)}`}
/>
<div className="col-span-2">
<PriceStat label="거래량" value={formatVolume(stock.volume)} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
import { TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
const PRICE_FORMATTER = new Intl.NumberFormat("ko-KR");
function formatPrice(value: number) {
return `${PRICE_FORMATTER.format(value)}`;
}
interface StockPriceBadgeProps {
currentPrice: number;
change: number;
changeRate: number;
}
export function StockPriceBadge({
currentPrice,
change,
changeRate,
}: StockPriceBadgeProps) {
const isPositive = change >= 0;
const ChangeIcon = isPositive ? TrendingUp : TrendingDown;
const changeColor = isPositive ? "text-red-500" : "text-blue-500";
const changeSign = isPositive ? "+" : "";
return (
<div className="flex items-baseline gap-2">
<span className={cn("text-3xl font-bold", changeColor)}>
{formatPrice(currentPrice)}
</span>
<div
className={cn(
"flex items-center gap-1 text-sm font-medium",
changeColor,
)}
>
<ChangeIcon className="h-4 w-4" />
<span>
{changeSign}
{PRICE_FORMATTER.format(change)}
</span>
<span>
({changeSign}
{changeRate.toFixed(2)}%)
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
// import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
interface StockHeaderProps {
stock: DashboardStockItem;
price: string;
change: string;
changeRate: string;
high?: string;
low?: string;
volume?: string;
}
export function StockHeader({
stock,
price,
change,
changeRate,
high,
low,
volume,
}: StockHeaderProps) {
const isRise = changeRate.startsWith("+") || parseFloat(changeRate) > 0;
const isFall = changeRate.startsWith("-") || parseFloat(changeRate) < 0;
const colorClass = isRise
? "text-red-500"
: isFall
? "text-blue-500"
: "text-foreground";
return (
<div className="flex items-center justify-between px-4 py-3">
{/* Left: Stock Info */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<h1 className="text-xl font-bold">{stock.name}</h1>
<span className="text-sm text-muted-foreground">
{stock.symbol}/{stock.market}
</span>
</div>
<Separator orientation="vertical" className="h-6" />
<div className={cn("flex items-end gap-2", colorClass)}>
<span className="text-2xl font-bold tracking-tight">{price}</span>
<span className="text-sm font-medium mb-1">
{changeRate}% <span className="text-xs ml-1">{change}</span>
</span>
</div>
</div>
{/* Right: 24h Stats */}
<div className="hidden md:flex items-center gap-6 text-sm">
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></span>
<span className="font-medium text-red-500">{high || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs"></span>
<span className="font-medium text-blue-500">{low || "--"}</span>
</div>
<div className="flex flex-col items-end">
<span className="text-muted-foreground text-xs">(24H)</span>
<span className="font-medium">{volume || "--"}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from "react";
import { cn } from "@/lib/utils";
interface DashboardLayoutProps {
header: ReactNode;
chart: ReactNode;
orderBook: ReactNode;
orderForm: ReactNode;
className?: string;
}
export function DashboardLayout({
header,
chart,
orderBook,
orderForm,
className,
}: DashboardLayoutProps) {
return (
<div
className={cn(
"flex h-[calc(100vh-64px)] flex-col overflow-hidden",
className,
)}
>
{/* 1. Header Area */}
<div className="flex-none border-b border-border bg-background">
{header}
</div>
{/* 2. Main Content Area */}
<div className="flex flex-1 flex-col overflow-hidden xl:flex-row">
{/* Left Column: Chart & Info */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col border-border xl:border-r">
<div className="flex-1 min-h-0">{chart}</div>
{/* Future: Transaction History / Market Depth can go here */}
</div>
{/* Right Column: Order Book & Order Form */}
<div className="flex min-h-0 w-full flex-none flex-col bg-background xl:w-[460px] 2xl:w-[500px]">
{/* Top: Order Book (Hoga) */}
<div className="min-h-[360px] flex-1 overflow-hidden border-t border-border xl:min-h-0 xl:border-t-0 xl:border-b">
{orderBook}
</div>
{/* Bottom: Order Form */}
<div className="flex-none h-[320px] sm:h-[360px] xl:h-[380px]">
{orderForm}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,249 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
DashboardStockItem,
DashboardOrderSide,
} from "@/features/dashboard/types/dashboard.types";
import { useOrder } from "@/features/dashboard/hooks/useOrder";
import { useKisRuntimeStore } from "@/features/dashboard/store/use-kis-runtime-store";
import { Loader2 } from "lucide-react";
interface OrderFormProps {
stock?: DashboardStockItem;
}
export function OrderForm({ stock }: OrderFormProps) {
const verifiedCredentials = useKisRuntimeStore(
(state) => state.verifiedCredentials,
);
const { placeOrder, isLoading, error } = useOrder();
// Form State
// Initial price set from stock current price if available, relying on component remount (key) for updates
const [price, setPrice] = useState<string>(
stock?.currentPrice.toString() || "",
);
const [quantity, setQuantity] = useState<string>("");
const [activeTab, setActiveTab] = useState("buy");
const handleOrder = async (side: DashboardOrderSide) => {
if (!stock || !verifiedCredentials) return;
const priceNum = parseInt(price.replace(/,/g, ""), 10);
const qtyNum = parseInt(quantity.replace(/,/g, ""), 10);
if (isNaN(priceNum) || priceNum <= 0) {
alert("가격을 올바르게 입력해주세요.");
return;
}
if (isNaN(qtyNum) || qtyNum <= 0) {
alert("수량을 올바르게 입력해주세요.");
return;
}
if (!verifiedCredentials.accountNo) {
alert(
"계좌번호가 설정되지 않았습니다. 설정에서 계좌번호를 입력해주세요.",
);
return;
}
const response = await placeOrder(
{
symbol: stock.symbol,
side: side,
orderType: "limit", // 지정가 고정
price: priceNum,
quantity: qtyNum,
accountNo: verifiedCredentials.accountNo,
accountProductCode: "01", // Default to '01' (위탁)
},
verifiedCredentials,
);
if (response && response.orderNo) {
alert(`주문 전송 완료! 주문번호: ${response.orderNo}`);
setQuantity("");
}
};
const totalPrice =
parseInt(price.replace(/,/g, "") || "0", 10) *
parseInt(quantity.replace(/,/g, "") || "0", 10);
const setPercent = (pct: string) => {
// Placeholder logic for percent click
console.log("Percent clicked:", pct);
};
const isMarketDataAvailable = !!stock;
return (
<div className="h-full bg-background p-4 border-l border-border">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full h-full flex flex-col"
>
<TabsList className="grid w-full grid-cols-2 mb-4">
<TabsTrigger
value="buy"
className="data-[state=active]:bg-red-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
<TabsTrigger
value="sell"
className="data-[state=active]:bg-blue-600 data-[state=active]:text-white transition-colors"
>
</TabsTrigger>
</TabsList>
<TabsContent
value="buy"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="buy"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-red-600 hover:bg-red-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("buy")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매수하기"}
</Button>
</TabsContent>
<TabsContent
value="sell"
className="flex-1 flex flex-col space-y-4 data-[state=inactive]:hidden"
>
<OrderInputs
type="sell"
price={price}
setPrice={setPrice}
quantity={quantity}
setQuantity={setQuantity}
totalPrice={totalPrice}
disabled={!isMarketDataAvailable}
hasError={!!error}
errorMessage={error}
/>
<PercentButtons onSelect={setPercent} />
<Button
className="w-full bg-blue-600 hover:bg-blue-700 mt-auto text-lg h-12"
disabled={isLoading || !isMarketDataAvailable}
onClick={() => handleOrder("sell")}
>
{isLoading ? <Loader2 className="animate-spin mr-2" /> : "매도하기"}
</Button>
</TabsContent>
</Tabs>
</div>
);
}
function OrderInputs({
type,
price,
setPrice,
quantity,
setQuantity,
totalPrice,
disabled,
hasError,
errorMessage,
}: {
type: "buy" | "sell";
price: string;
setPrice: (v: string) => void;
quantity: string;
setQuantity: (v: string) => void;
totalPrice: number;
disabled: boolean;
hasError: boolean;
errorMessage: string | null;
}) {
return (
<div className="space-y-4">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>- {type === "buy" ? "KRW" : "주"}</span>
</div>
{hasError && (
<div className="p-2 bg-destructive/10 text-destructive text-xs rounded break-keep">
{errorMessage}
</div>
)}
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium">
{type === "buy" ? "매수가격" : "매도가격"}
</span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={price}
onChange={(e) => setPrice(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono"
placeholder="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
disabled={disabled}
/>
</div>
<div className="grid grid-cols-4 gap-2 items-center">
<span className="text-sm font-medium"></span>
<Input
className="col-span-3 text-right font-mono bg-muted/50"
value={totalPrice.toLocaleString()}
readOnly
disabled={disabled}
/>
</div>
</div>
);
}
function PercentButtons({ onSelect }: { onSelect: (pct: string) => void }) {
return (
<div className="grid grid-cols-4 gap-2 mt-2">
{["10%", "25%", "50%", "100%"].map((pct) => (
<Button
key={pct}
variant="outline"
size="sm"
className="text-xs"
onClick={() => onSelect(pct)}
>
{pct}
</Button>
))}
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { AnimatePresence, motion } from "framer-motion";
interface AnimatedQuantityProps {
value: number;
format?: (val: number) => string;
className?: string;
/** 값 변동 시 배경 깜빡임 */
useColor?: boolean;
}
/**
* 실시간 수량 표시 — 값이 변할 때 ±diff를 인라인으로 보여줍니다.
*/
export function AnimatedQuantity({
value,
format = (v) => v.toLocaleString(),
className,
useColor = false,
}: AnimatedQuantityProps) {
const prevRef = useRef(value);
const [diff, setDiff] = useState<number | null>(null);
const [flash, setFlash] = useState<"up" | "down" | null>(null);
useEffect(() => {
if (prevRef.current === value) return;
const delta = value - prevRef.current;
prevRef.current = value;
if (delta === 0) return;
setDiff(delta);
setFlash(delta > 0 ? "up" : "down");
const timer = setTimeout(() => {
setDiff(null);
setFlash(null);
}, 1200);
return () => clearTimeout(timer);
}, [value]);
return (
<span
className={cn(
"relative inline-flex items-center gap-1 tabular-nums",
className,
)}
>
{/* 배경 깜빡임 */}
<AnimatePresence>
{useColor && flash && (
<motion.span
initial={{ opacity: 0.5 }}
animate={{ opacity: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1 }}
className={cn(
"absolute inset-0 z-0 rounded-sm",
flash === "up" ? "bg-red-200/50" : "bg-blue-200/50",
)}
/>
)}
</AnimatePresence>
{/* 수량 값 */}
<span className="relative z-10">{format(value)}</span>
{/* ±diff (인라인 표시) */}
<AnimatePresence>
{diff != null && diff !== 0 && (
<motion.span
initial={{ opacity: 1, scale: 1 }}
animate={{ opacity: 0, scale: 0.85 }}
exit={{ opacity: 0 }}
transition={{ duration: 1.2, ease: "easeOut" }}
className={cn(
"relative z-20 whitespace-nowrap text-[9px] font-bold leading-none",
diff > 0 ? "text-red-500" : "text-blue-500",
)}
>
{diff > 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()}
</motion.span>
)}
</AnimatePresence>
</span>
);
}

View File

@@ -0,0 +1,567 @@
import { useMemo } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
} from "@/features/dashboard/types/dashboard.types";
import { cn } from "@/lib/utils";
import { AnimatedQuantity } from "./AnimatedQuantity";
// ─── 타입 ───────────────────────────────────────────────
interface OrderBookProps {
symbol?: string;
referencePrice?: number;
currentPrice?: number;
latestTick: DashboardRealtimeTradeTick | null;
recentTicks: DashboardRealtimeTradeTick[];
orderBook: DashboardStockOrderBookResponse | null;
isLoading?: boolean;
}
interface BookRow {
price: number;
size: number;
changePercent: number | null;
isHighlighted: boolean;
}
// ─── 유틸리티 함수 ──────────────────────────────────────
/** 천단위 구분 포맷 */
function fmt(v: number) {
return Number.isFinite(v) ? v.toLocaleString("ko-KR") : "0";
}
/** 부호 포함 퍼센트 */
function fmtPct(v: number) {
return `${v > 0 ? "+" : ""}${v.toFixed(2)}%`;
}
/** 등락률 계산 */
function pctChange(price: number, base: number) {
return base > 0 ? ((price - base) / base) * 100 : 0;
}
/** 체결 시각 포맷 */
function fmtTime(hms: string) {
if (!hms || hms.length !== 6) return "--:--:--";
return `${hms.slice(0, 2)}:${hms.slice(2, 4)}:${hms.slice(4, 6)}`;
}
// ─── 메인 컴포넌트 ──────────────────────────────────────
/**
* 호가창 — 실시간 매도·매수 10호가와 체결 목록을 표시합니다.
*/
export function OrderBook({
symbol,
referencePrice,
currentPrice,
latestTick,
recentTicks,
orderBook,
isLoading,
}: OrderBookProps) {
const levels = useMemo(() => orderBook?.levels ?? [], [orderBook]);
// 체결가: tick에서 우선, 없으면 0
const latestPrice =
latestTick?.price && latestTick.price > 0 ? latestTick.price : 0;
// 등락률 기준가
const basePrice =
(referencePrice ?? 0) > 0
? referencePrice!
: (currentPrice ?? 0) > 0
? currentPrice!
: latestPrice > 0
? latestPrice
: 0;
// 매도호가 (역순: 10호가 → 1호가)
const askRows: BookRow[] = useMemo(
() =>
[...levels].reverse().map((l) => ({
price: l.askPrice,
size: Math.max(l.askSize, 0),
changePercent:
l.askPrice > 0 && basePrice > 0
? pctChange(l.askPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.askPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
// 매수호가 (1호가 → 10호가)
const bidRows: BookRow[] = useMemo(
() =>
levels.map((l) => ({
price: l.bidPrice,
size: Math.max(l.bidSize, 0),
changePercent:
l.bidPrice > 0 && basePrice > 0
? pctChange(l.bidPrice, basePrice)
: null,
isHighlighted: latestPrice > 0 && l.bidPrice === latestPrice,
})),
[levels, basePrice, latestPrice],
);
const askMax = Math.max(1, ...askRows.map((r) => r.size));
const bidMax = Math.max(1, ...bidRows.map((r) => r.size));
// 스프레드·수급 불균형
const bestAsk = levels.find((l) => l.askPrice > 0)?.askPrice ?? 0;
const bestBid = levels.find((l) => l.bidPrice > 0)?.bidPrice ?? 0;
const spread = bestAsk > 0 && bestBid > 0 ? bestAsk - bestBid : 0;
const totalAsk = orderBook?.totalAskSize ?? 0;
const totalBid = orderBook?.totalBidSize ?? 0;
const imbalance =
totalAsk + totalBid > 0
? ((totalBid - totalAsk) / (totalAsk + totalBid)) * 100
: 0;
// 체결가 행 중앙 스크롤
// ─── 빈/로딩 상태 ───
if (!symbol) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
if (isLoading && !orderBook) return <OrderBookSkeleton />;
if (!orderBook) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col bg-background">
<Tabs defaultValue="normal" className="h-full min-h-0">
{/* 탭 헤더 */}
<div className="border-b px-2 pt-2">
<TabsList variant="line" className="w-full justify-start">
<TabsTrigger value="normal" className="px-3">
</TabsTrigger>
<TabsTrigger value="cumulative" className="px-3">
</TabsTrigger>
<TabsTrigger value="order" className="px-3">
</TabsTrigger>
</TabsList>
</div>
{/* ── 일반호가 탭 ── */}
<TabsContent value="normal" className="min-h-0 flex-1">
<div className="grid h-full min-h-0 grid-rows-[1fr_190px] overflow-hidden border-t">
<div className="grid min-h-0 grid-cols-[minmax(0,1fr)_150px] overflow-hidden">
{/* 호가 테이블 */}
<div className="min-h-0 border-r">
<BookHeader />
<ScrollArea className="h-[calc(100%-32px)]">
{/* 매도호가 */}
<BookSideRows rows={askRows} side="ask" maxSize={askMax} />
{/* 중앙 바: 현재 체결가 */}
<div className="grid h-9 grid-cols-3 items-center border-y-2 border-amber-400 bg-amber-50/80 dark:bg-amber-900/30">
<div className="px-2 text-right text-[10px] font-medium text-muted-foreground">
{totalAsk > 0 ? fmt(totalAsk) : ""}
</div>
<div className="flex items-center justify-center gap-1">
<span className="text-xs font-bold tabular-nums">
{latestPrice > 0
? fmt(latestPrice)
: bestAsk > 0
? fmt(bestAsk)
: "-"}
</span>
{latestPrice > 0 && basePrice > 0 && (
<span
className={cn(
"text-[10px] font-medium",
latestPrice >= basePrice
? "text-red-500"
: "text-blue-500",
)}
>
{fmtPct(pctChange(latestPrice, basePrice))}
</span>
)}
</div>
<div className="px-2 text-left text-[10px] font-medium text-muted-foreground">
{totalBid > 0 ? fmt(totalBid) : ""}
</div>
</div>
{/* 매수호가 */}
<BookSideRows rows={bidRows} side="bid" maxSize={bidMax} />
</ScrollArea>
</div>
{/* 우측 요약 패널 */}
<SummaryPanel
orderBook={orderBook}
latestTick={latestTick}
spread={spread}
imbalance={imbalance}
totalAsk={totalAsk}
totalBid={totalBid}
/>
</div>
{/* 체결 목록 */}
<TradeTape ticks={recentTicks} />
</div>
</TabsContent>
{/* ── 누적호가 탭 ── */}
<TabsContent value="cumulative" className="min-h-0 flex-1">
<ScrollArea className="h-full border-t">
<div className="p-3">
<div className="mb-2 grid grid-cols-3 text-[11px] font-medium text-muted-foreground">
<span></span>
<span className="text-center"></span>
<span className="text-right"></span>
</div>
<CumulativeRows asks={askRows} bids={bidRows} />
</div>
</ScrollArea>
</TabsContent>
{/* ── 호가주문 탭 ── */}
<TabsContent value="order" className="min-h-0 flex-1">
<div className="flex h-full items-center justify-center border-t px-4 text-sm text-muted-foreground">
.
</div>
</TabsContent>
</Tabs>
</div>
);
}
// ─── 하위 컴포넌트 ──────────────────────────────────────
/** 호가 표 헤더 */
function BookHeader() {
return (
<div className="grid h-8 grid-cols-3 border-b bg-muted/20 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center justify-end px-2"></div>
<div className="flex items-center justify-center border-x"></div>
<div className="flex items-center justify-start px-2"></div>
</div>
);
}
/** 매도 또는 매수 호가 행 목록 */
function BookSideRows({
rows,
side,
maxSize,
}: {
rows: BookRow[];
side: "ask" | "bid";
maxSize: number;
}) {
const isAsk = side === "ask";
return (
<div className={isAsk ? "bg-red-50/15" : "bg-blue-50/15"}>
{rows.map((row, i) => {
const ratio =
maxSize > 0 ? Math.min((row.size / maxSize) * 100, 100) : 0;
return (
<div
key={`${side}-${row.price}-${i}`}
className={cn(
"grid h-8 grid-cols-3 border-b border-border/40 text-xs",
row.isHighlighted &&
"ring-1 ring-inset ring-amber-400 bg-amber-100/50 dark:bg-amber-900/30",
)}
>
{/* 매도잔량 (좌측) */}
<div className="relative flex items-center justify-end overflow-hidden px-2">
{isAsk && (
<>
<DepthBar ratio={ratio} side="ask" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10"
/>
</>
)}
</div>
{/* 호가 (중앙) */}
<div
className={cn(
"flex items-center justify-center border-x font-medium tabular-nums gap-1",
row.isHighlighted &&
"border-x-amber-400 bg-amber-50/70 dark:bg-amber-900/25",
)}
>
<span className={isAsk ? "text-red-600" : "text-blue-600"}>
{row.price > 0 ? fmt(row.price) : "-"}
</span>
<span
className={cn(
"text-[10px]",
row.changePercent !== null
? row.changePercent >= 0
? "text-red-500"
: "text-blue-500"
: "text-muted-foreground",
)}
>
{row.changePercent === null ? "-" : fmtPct(row.changePercent)}
</span>
</div>
{/* 매수잔량 (우측) */}
<div className="relative flex items-center justify-start overflow-hidden px-1">
{!isAsk && (
<>
<DepthBar ratio={ratio} side="bid" />
<AnimatedQuantity
value={row.size}
format={fmt}
useColor
className="relative z-10"
/>
</>
)}
</div>
</div>
);
})}
</div>
);
}
/** 우측 요약 패널 */
function SummaryPanel({
orderBook,
latestTick,
spread,
imbalance,
totalAsk,
totalBid,
}: {
orderBook: DashboardStockOrderBookResponse;
latestTick: DashboardRealtimeTradeTick | null;
spread: number;
imbalance: number;
totalAsk: number;
totalBid: number;
}) {
return (
<div className="border-l bg-muted/15 p-2 text-[11px]">
<Row
label="실시간"
value={orderBook ? "연결됨" : "끊김"}
tone={orderBook ? "bid" : undefined}
/>
<Row
label="거래량"
value={fmt(latestTick?.tradeVolume ?? orderBook.anticipatedVolume ?? 0)}
/>
<Row
label="누적거래량"
value={fmt(
latestTick?.accumulatedVolume ?? orderBook.accumulatedVolume ?? 0,
)}
/>
<Row
label="체결강도"
value={
latestTick
? `${latestTick.tradeStrength.toFixed(2)}%`
: orderBook.anticipatedChangeRate !== undefined
? `${orderBook.anticipatedChangeRate.toFixed(2)}%`
: "-"
}
/>
<Row label="예상체결가" value={fmt(orderBook.anticipatedPrice ?? 0)} />
<Row
label="매도1호가"
value={latestTick ? fmt(latestTick.askPrice1) : "-"}
tone="ask"
/>
<Row
label="매수1호가"
value={latestTick ? fmt(latestTick.bidPrice1) : "-"}
tone="bid"
/>
<Row
label="매수체결"
value={latestTick ? fmt(latestTick.buyExecutionCount) : "-"}
/>
<Row
label="매도체결"
value={latestTick ? fmt(latestTick.sellExecutionCount) : "-"}
/>
<Row
label="순매수체결"
value={latestTick ? fmt(latestTick.netBuyExecutionCount) : "-"}
/>
<Row label="총 매도잔량" value={fmt(totalAsk)} tone="ask" />
<Row label="총 매수잔량" value={fmt(totalBid)} tone="bid" />
<Row label="스프레드" value={fmt(spread)} />
<Row
label="수급 불균형"
value={`${imbalance >= 0 ? "+" : ""}${imbalance.toFixed(2)}%`}
tone={imbalance >= 0 ? "bid" : "ask"}
/>
</div>
);
}
/** 요약 패널 단일 행 */
function Row({
label,
value,
tone,
}: {
label: string;
value: string;
tone?: "ask" | "bid";
}) {
return (
<div className="mb-1.5 flex items-center justify-between rounded border bg-background px-2 py-1">
<span className="text-muted-foreground">{label}</span>
<span
className={cn(
"font-medium tabular-nums",
tone === "ask" && "text-red-600",
tone === "bid" && "text-blue-600",
)}
>
{value}
</span>
</div>
);
}
/** 잔량 깊이 바 */
function DepthBar({ ratio, side }: { ratio: number; side: "ask" | "bid" }) {
if (ratio <= 0) return null;
return (
<div
className={cn(
"absolute inset-y-1 z-0 rounded-sm",
side === "ask" ? "right-1 bg-red-200/50" : "left-1 bg-blue-200/50",
)}
style={{ width: `${ratio}%` }}
/>
);
}
/** 체결 목록 (Trade Tape) */
function TradeTape({ ticks }: { ticks: DashboardRealtimeTradeTick[] }) {
return (
<div className="border-t bg-background">
<div className="grid h-7 grid-cols-4 border-b bg-muted/20 px-2 text-[11px] font-medium text-muted-foreground">
<div className="flex items-center"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
<div className="flex items-center justify-end"></div>
</div>
<ScrollArea className="h-[162px]">
<div>
{ticks.length === 0 && (
<div className="flex h-[160px] items-center justify-center text-xs text-muted-foreground">
.
</div>
)}
{ticks.map((t, i) => (
<div
key={`${t.tickTime}-${t.price}-${i}`}
className="grid h-8 grid-cols-4 border-b border-border/40 px-2 text-xs"
>
<div className="flex items-center tabular-nums">
{fmtTime(t.tickTime)}
</div>
<div className="flex items-center justify-end tabular-nums text-red-600">
{fmt(t.price)}
</div>
<div className="flex items-center justify-end tabular-nums text-blue-600">
{fmt(t.tradeVolume)}
</div>
<div className="flex items-center justify-end tabular-nums">
{t.tradeStrength.toFixed(2)}%
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}
/** 누적호가 행 */
function CumulativeRows({ asks, bids }: { asks: BookRow[]; bids: BookRow[] }) {
const rows = useMemo(() => {
const len = Math.max(asks.length, bids.length);
const result: { askAcc: number; bidAcc: number; price: number }[] = [];
for (let i = 0; i < len; i++) {
const prevAsk = result[i - 1]?.askAcc ?? 0;
const prevBid = result[i - 1]?.bidAcc ?? 0;
result.push({
askAcc: prevAsk + (asks[i]?.size ?? 0),
bidAcc: prevBid + (bids[i]?.size ?? 0),
price: asks[i]?.price || bids[i]?.price || 0,
});
}
return result;
}, [asks, bids]);
return (
<div className="space-y-1">
{rows.map((r, i) => (
<div
key={i}
className="grid h-7 grid-cols-3 items-center rounded border bg-background px-2 text-xs"
>
<span className="tabular-nums text-red-600">{fmt(r.askAcc)}</span>
<span className="text-center font-medium tabular-nums">
{fmt(r.price)}
</span>
<span className="text-right tabular-nums text-blue-600">
{fmt(r.bidAcc)}
</span>
</div>
))}
</div>
);
}
/** 로딩 스켈레톤 */
function OrderBookSkeleton() {
return (
<div className="flex h-full flex-col p-3">
<div className="mb-3 grid grid-cols-3 gap-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<div className="space-y-2">
{Array.from({ length: 16 }).map((_, i) => (
<Skeleton key={i} className="h-7 w-full" />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import type { FormEvent } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
interface StockSearchFormProps {
keyword: string;
onKeywordChange: (value: string) => void;
onSubmit: (e: FormEvent) => void;
disabled?: boolean;
isLoading?: boolean;
}
export function StockSearchForm({
keyword,
onKeywordChange,
onSubmit,
disabled,
isLoading,
}: StockSearchFormProps) {
return (
<form onSubmit={onSubmit} className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="종목명 또는 코드(6자리) 입력..."
className="pl-9"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
/>
</div>
<Button type="submit" disabled={disabled || isLoading}>
{isLoading ? "검색 중..." : "검색"}
</Button>
</form>
);
}

View File

@@ -0,0 +1,47 @@
import { Button } from "@/components/ui/button";
// import { Activity, TrendingDown, TrendingUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
interface StockSearchResultsProps {
items: DashboardStockSearchItem[];
onSelect: (item: DashboardStockSearchItem) => void;
selectedSymbol?: string;
}
export function StockSearchResults({
items,
onSelect,
selectedSymbol,
}: StockSearchResultsProps) {
if (items.length === 0) return null;
return (
<div className="flex flex-col p-2">
{items.map((item) => {
const isSelected = item.symbol === selectedSymbol;
return (
<Button
key={item.symbol}
variant="outline"
className={cn(
"h-auto w-full flex-col items-start gap-1 p-3 text-left",
isSelected && "border-brand-500 bg-brand-50 hover:bg-brand-100",
)}
onClick={() => onSelect(item)}
>
<div className="flex w-full items-center justify-between gap-2">
<span className="font-semibold truncate">{item.name}</span>
<span className="text-xs text-muted-foreground shrink-0">
{item.symbol}
</span>
</div>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{item.market}
</div>
</Button>
);
})}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
import rawStocks from "@/features/dashboard/data/korean-stocks.json";
import type { KoreanStockIndexItem } from "@/features/dashboard/types/dashboard.types";
/**
* 국내주식 검색 인덱스(KOSPI + KOSDAQ)
* - 파일 원본: korean-stocks.json
* - 사용처: /api/kis/domestic/search 라우트의 메모리 검색
* @see app/api/kis/domestic/search/route.ts 종목명/종목코드 검색에 사용합니다.
*/
export const KOREAN_STOCK_INDEX = rawStocks as KoreanStockIndexItem[];

View File

@@ -0,0 +1,126 @@
/**
* @file features/dashboard/data/mock-stocks.ts
* @description 대시보드 1단계 UI 검증용 목업 종목 데이터
* @remarks
* - 한국투자증권 API 연동 전까지 화면 동작 검증에 사용합니다.
* - 2단계 이후 실제 화면은 app/api/kis/* 응답을 사용합니다.
* - 현재는 레거시/비교용 샘플 데이터로만 남겨둔 상태입니다.
*/
import type { DashboardStockItem } from "@/features/dashboard/types/dashboard.types";
/**
* 대시보드 목업 종목 목록
* @see app/(main)/dashboard/page.tsx DashboardPage가 DashboardMain을 통해 이 데이터를 조회합니다.
* @see features/dashboard/components/dashboard-main.tsx 검색/차트/지표 카드의 기본 데이터 소스입니다.
*/
export const MOCK_STOCKS: DashboardStockItem[] = [
{
symbol: "005930",
name: "삼성전자",
market: "KOSPI",
currentPrice: 78500,
change: 1200,
changeRate: 1.55,
open: 77300,
high: 78900,
low: 77000,
prevClose: 77300,
volume: 15234012,
candles: [
{ time: "09:00", price: 74400 },
{ time: "09:10", price: 74650 },
{ time: "09:20", price: 75100 },
{ time: "09:30", price: 74950 },
{ time: "09:40", price: 75300 },
{ time: "09:50", price: 75600 },
{ time: "10:00", price: 75400 },
{ time: "10:10", price: 75850 },
{ time: "10:20", price: 76100 },
{ time: "10:30", price: 75950 },
{ time: "10:40", price: 76350 },
{ time: "10:50", price: 76700 },
{ time: "11:00", price: 76900 },
{ time: "11:10", price: 77250 },
{ time: "11:20", price: 77100 },
{ time: "11:30", price: 77400 },
{ time: "11:40", price: 77700 },
{ time: "11:50", price: 78150 },
{ time: "12:00", price: 77900 },
{ time: "12:10", price: 78300 },
{ time: "12:20", price: 78500 },
],
},
{
symbol: "000660",
name: "SK하이닉스",
market: "KOSPI",
currentPrice: 214500,
change: -1500,
changeRate: -0.69,
open: 216000,
high: 218000,
low: 213000,
prevClose: 216000,
volume: 3210450,
candles: [
{ time: "09:00", price: 221000 },
{ time: "09:10", price: 220400 },
{ time: "09:20", price: 219900 },
{ time: "09:30", price: 220200 },
{ time: "09:40", price: 219300 },
{ time: "09:50", price: 218500 },
{ time: "10:00", price: 217900 },
{ time: "10:10", price: 218300 },
{ time: "10:20", price: 217600 },
{ time: "10:30", price: 216900 },
{ time: "10:40", price: 216500 },
{ time: "10:50", price: 216800 },
{ time: "11:00", price: 215900 },
{ time: "11:10", price: 215300 },
{ time: "11:20", price: 214800 },
{ time: "11:30", price: 215100 },
{ time: "11:40", price: 214200 },
{ time: "11:50", price: 214700 },
{ time: "12:00", price: 214300 },
{ time: "12:10", price: 214600 },
{ time: "12:20", price: 214500 },
],
},
{
symbol: "035420",
name: "NAVER",
market: "KOSPI",
currentPrice: 197800,
change: 2200,
changeRate: 1.12,
open: 195500,
high: 198600,
low: 194900,
prevClose: 195600,
volume: 1904123,
candles: [
{ time: "09:00", price: 191800 },
{ time: "09:10", price: 192400 },
{ time: "09:20", price: 193000 },
{ time: "09:30", price: 192700 },
{ time: "09:40", price: 193600 },
{ time: "09:50", price: 194200 },
{ time: "10:00", price: 194000 },
{ time: "10:10", price: 194900 },
{ time: "10:20", price: 195100 },
{ time: "10:30", price: 194700 },
{ time: "10:40", price: 195800 },
{ time: "10:50", price: 196400 },
{ time: "11:00", price: 196100 },
{ time: "11:10", price: 196900 },
{ time: "11:20", price: 197200 },
{ time: "11:30", price: 197000 },
{ time: "11:40", price: 197600 },
{ time: "11:50", price: 198000 },
{ time: "12:00", price: 197400 },
{ time: "12:10", price: 198300 },
{ time: "12:20", price: 197800 },
],
},
];

View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from "react";
import {
type KisRuntimeCredentials,
useKisRuntimeStore,
} from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
import {
appendRealtimeTick,
buildKisRealtimeMessage,
formatRealtimeTickTime,
parseKisRealtimeOrderbook,
parseKisRealtimeTickBatch,
toTickOrderValue,
} from "@/features/dashboard/utils/kis-realtime.utils";
// ─── TR ID 상수 ─────────────────────────────────────────
const TRADE_TR_ID = "H0STCNT0"; // 체결 (실전/모의 공통)
const TRADE_TR_ID_OVERTIME = "H0STOUP0"; // 시간외 단일가
const ORDERBOOK_TR_ID = "H0STASP0"; // 호가 (정규장)
const ORDERBOOK_TR_ID_OVERTIME = "H0STOAA0"; // 호가 (시간외)
const MAX_TRADE_TICKS = 10;
// ─── 시간대별 TR ID 선택 ────────────────────────────────
function isOvertimeHours() {
const now = new Date();
const t = now.getHours() * 100 + now.getMinutes();
return t >= 1600 && t < 1800;
}
function resolveTradeTrId(env: KisRuntimeCredentials["tradingEnv"]) {
if (env === "mock") return TRADE_TR_ID;
return isOvertimeHours() ? TRADE_TR_ID_OVERTIME : TRADE_TR_ID;
}
function resolveOrderBookTrId() {
return isOvertimeHours() ? ORDERBOOK_TR_ID_OVERTIME : ORDERBOOK_TR_ID;
}
// ─── 메인 훅 ────────────────────────────────────────────
/**
* 통합 실시간 웹소켓 훅 — 체결(H0STCNT0) + 호가(H0STASP0)를 단일 WS로 수신합니다.
*
* @param symbol 종목코드
* @param credentials KIS 인증 정보
* @param isVerified 인증 완료 여부
* @param onTick 체결 콜백 (StockHeader 갱신용)
* @param options.orderBookSymbol 호가 구독 종목코드
* @param options.onOrderBookMessage 호가 수신 콜백
*/
export function useKisTradeWebSocket(
symbol: string | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
onTick?: (tick: DashboardRealtimeTradeTick) => void,
options?: {
orderBookSymbol?: string;
onOrderBookMessage?: (data: DashboardStockOrderBookResponse) => void;
},
) {
const [latestTick, setLatestTick] =
useState<DashboardRealtimeTradeTick | null>(null);
const [realtimeCandles, setRealtimeCandles] = useState<StockCandlePoint[]>(
[],
);
const [recentTradeTicks, setRecentTradeTicks] = useState<
DashboardRealtimeTradeTick[]
>([]);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastTickAt, setLastTickAt] = useState<number | null>(null);
const socketRef = useRef<WebSocket | null>(null);
const approvalKeyRef = useRef<string | null>(null);
const lastTickOrderRef = useRef<number>(-1);
const seenTickRef = useRef<Set<string>>(new Set());
const trId = credentials ? resolveTradeTrId(credentials.tradingEnv) : null;
const obSymbol = options?.orderBookSymbol;
const onOrderBookMsg = options?.onOrderBookMessage;
const obTrId = obSymbol ? resolveOrderBookTrId() : null;
// 8초간 데이터 없을 시 안내 메시지
useEffect(() => {
if (!isConnected || lastTickAt) return;
const timer = window.setTimeout(() => {
setError(
"실시간 연결은 되었지만 체결 데이터가 없습니다. 장중(09:00~15:30)인지 확인해 주세요.",
);
}, 8000);
return () => window.clearTimeout(timer);
}, [isConnected, lastTickAt]);
// ─── 웹소켓 연결 ─────────────────────────────────────
useEffect(() => {
setLatestTick(null);
setRealtimeCandles([]);
setRecentTradeTicks([]);
setError(null);
seenTickRef.current.clear();
if (!symbol || !isVerified || !credentials) {
socketRef.current?.close();
socketRef.current = null;
approvalKeyRef.current = null;
setIsConnected(false);
return;
}
let disposed = false;
let socket: WebSocket | null = null;
const currentTrId = resolveTradeTrId(credentials.tradingEnv);
const connect = async () => {
try {
setError(null);
setIsConnected(false);
const approvalKey = await useKisRuntimeStore
.getState()
.getOrFetchApprovalKey();
if (!approvalKey) throw new Error("웹소켓 승인키 발급에 실패했습니다.");
if (disposed) return;
approvalKeyRef.current = approvalKey;
const wsBase =
process.env.NEXT_PUBLIC_KIS_WS_URL ||
"ws://ops.koreainvestment.com:21000";
socket = new WebSocket(`${wsBase}/tryitout/${currentTrId}`);
socketRef.current = socket;
// ── onopen: 체결 + 호가 구독 ──
socket.onopen = () => {
if (disposed || !approvalKeyRef.current) return;
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
symbol,
currentTrId,
"1",
),
),
);
if (obSymbol && obTrId) {
socket?.send(
JSON.stringify(
buildKisRealtimeMessage(
approvalKeyRef.current,
obSymbol,
obTrId,
"1",
),
),
);
}
setIsConnected(true);
};
// ── onmessage: TR ID 기반 분기 ──
socket.onmessage = (event) => {
if (disposed || typeof event.data !== "string") return;
// 호가 메시지 확인
if (obSymbol && onOrderBookMsg) {
const ob = parseKisRealtimeOrderbook(event.data, obSymbol);
if (ob) {
if (credentials) ob.tradingEnv = credentials.tradingEnv;
onOrderBookMsg(ob);
return;
}
}
// 체결 메시지 파싱
const ticks = parseKisRealtimeTickBatch(event.data, symbol);
if (ticks.length === 0) return;
// 중복 제거 (TradeTape용)
const meaningful = ticks.filter((t) => t.tradeVolume > 0);
const deduped = meaningful.filter((t) => {
const key = `${t.tickTime}-${t.price}-${t.tradeVolume}`;
if (seenTickRef.current.has(key)) return false;
seenTickRef.current.add(key);
return true;
});
// 최신 틱 → Header
const latest = ticks[ticks.length - 1];
setLatestTick(latest);
// 캔들 → Chart
const order = toTickOrderValue(latest.tickTime);
if (order > 0 && lastTickOrderRef.current <= order) {
lastTickOrderRef.current = order;
setRealtimeCandles((prev) =>
appendRealtimeTick(prev, {
time: formatRealtimeTickTime(latest.tickTime),
price: latest.price,
}),
);
}
// 체결 테이프
if (deduped.length > 0) {
setRecentTradeTicks((prev) =>
[...deduped.reverse(), ...prev].slice(0, MAX_TRADE_TICKS),
);
}
setError(null);
setLastTickAt(Date.now());
onTick?.(latest);
};
socket.onerror = () => {
if (!disposed) setIsConnected(false);
};
socket.onclose = () => {
if (!disposed) setIsConnected(false);
};
} catch (err) {
if (disposed) return;
setError(
err instanceof Error
? err.message
: "실시간 웹소켓 초기화 중 오류가 발생했습니다.",
);
setIsConnected(false);
}
};
void connect();
const seenRef = seenTickRef.current;
// ── cleanup: 구독 해제 ──
return () => {
disposed = true;
setIsConnected(false);
const key = approvalKeyRef.current;
if (socket?.readyState === WebSocket.OPEN && key) {
socket.send(
JSON.stringify(
buildKisRealtimeMessage(key, symbol, currentTrId, "2"),
),
);
if (obSymbol && obTrId) {
socket.send(
JSON.stringify(buildKisRealtimeMessage(key, obSymbol, obTrId, "2")),
);
}
}
socket?.close();
if (socketRef.current === socket) socketRef.current = null;
approvalKeyRef.current = null;
seenRef.clear();
};
}, [
isVerified,
symbol,
credentials,
onTick,
obSymbol,
obTrId,
onOrderBookMsg,
]);
return {
latestTick,
realtimeCandles,
recentTradeTicks,
isConnected,
error,
lastTickAt,
realtimeTrId: trId ?? TRADE_TR_ID,
};
}

View File

@@ -0,0 +1,61 @@
import { useState, useCallback } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardStockCashOrderRequest,
DashboardStockCashOrderResponse,
} from "@/features/dashboard/types/dashboard.types";
import { fetchOrderCash } from "@/features/dashboard/apis/kis-stock.api";
export function useOrder() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<DashboardStockCashOrderResponse | null>(
null,
);
const placeOrder = useCallback(
async (
request: DashboardStockCashOrderRequest,
credentials: KisRuntimeCredentials | null,
) => {
if (!credentials) {
setError("KIS API 자격 증명이 없습니다.");
return null;
}
setIsLoading(true);
setError(null);
setResult(null);
try {
const data = await fetchOrderCash(request, credentials);
setResult(data);
return data;
} catch (err) {
const message =
err instanceof Error
? err.message
: "주문 처리 중 오류가 발생했습니다.";
setError(message);
return null;
} finally {
setIsLoading(false);
}
},
[],
);
const reset = useCallback(() => {
setError(null);
setResult(null);
setIsLoading(false);
}, []);
return {
placeOrder,
isLoading,
error,
result,
reset,
};
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockOrderBookResponse } from "@/features/dashboard/types/dashboard.types";
import { fetchStockOrderBook } from "@/features/dashboard/apis/kis-stock.api";
import { toast } from "sonner";
/**
* @description 초기 REST 호가를 한 번 조회하고, 이후에는 웹소켓 호가를 우선 사용합니다.
* 웹소켓 호가 데이터는 DashboardContainer에서 useKisTradeWebSocket을 통해
* 단일 WebSocket으로 수신되어 externalRealtimeOrderBook으로 주입됩니다.
* @see features/dashboard/components/DashboardContainer.tsx 호가 데이터 흐름
* @see features/dashboard/components/orderbook/OrderBook.tsx 호가창 렌더링 데이터 공급
*/
export function useOrderBook(
symbol: string | undefined,
market: "KOSPI" | "KOSDAQ" | undefined,
credentials: KisRuntimeCredentials | null,
isVerified: boolean,
options: {
enabled?: boolean;
/** 체결 WS에서 받은 실시간 호가 데이터 (단일 WS 통합) */
externalRealtimeOrderBook?: DashboardStockOrderBookResponse | null;
} = {},
) {
const { enabled = true, externalRealtimeOrderBook = null } = options;
const isRequestEnabled = enabled && !!symbol && !!credentials;
const requestSeqRef = useRef(0);
const lastErrorToastRef = useRef<string>("");
const [initialData, setInitialData] =
useState<DashboardStockOrderBookResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!isRequestEnabled || !symbol || !credentials) {
return;
}
const requestSeq = ++requestSeqRef.current;
let isDisposed = false;
const loadInitialOrderBook = async () => {
setInitialData(null);
setIsLoading(true);
setError(null);
try {
const data = await fetchStockOrderBook(symbol, credentials);
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setInitialData(data);
} catch (err) {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
console.error("Failed to fetch initial orderbook:", err);
const message =
err instanceof Error
? err.message
: "호가 정보를 불러오지 못했습니다. 잠시 후 다시 시도해주세요.";
setError(message);
if (lastErrorToastRef.current !== message) {
lastErrorToastRef.current = message;
toast.error(message);
}
} finally {
if (isDisposed || requestSeq !== requestSeqRef.current) return;
setIsLoading(false);
}
};
void loadInitialOrderBook();
return () => {
isDisposed = true;
};
}, [isRequestEnabled, symbol, credentials]);
// 외부 실시간 호가 → 초기 데이터 → null 순 우선
const orderBook = isRequestEnabled
? (externalRealtimeOrderBook ?? initialData)
: null;
const mergedError = isRequestEnabled ? error : null;
const mergedLoading = isRequestEnabled ? isLoading && !orderBook : false;
return {
orderBook,
isLoading: mergedLoading,
error: mergedError,
isWsConnected: !!externalRealtimeOrderBook,
};
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useState, useTransition } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type {
DashboardMarketPhase,
DashboardPriceSource,
DashboardRealtimeTradeTick,
DashboardStockSearchItem,
DashboardStockItem,
} from "@/features/dashboard/types/dashboard.types";
import { fetchStockOverview } from "@/features/dashboard/apis/kis-stock.api";
interface OverviewMeta {
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
fetchedAt: string;
}
export function useStockOverview() {
const [selectedStock, setSelectedStock] = useState<DashboardStockItem | null>(
null,
);
const [meta, setMeta] = useState<OverviewMeta | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, startTransition] = useTransition();
const loadOverview = useCallback(
(
symbol: string,
credentials: KisRuntimeCredentials | null,
marketHint?: DashboardStockSearchItem["market"],
) => {
if (!credentials) return;
startTransition(async () => {
try {
setError(null);
const data = await fetchStockOverview(symbol, credentials);
setSelectedStock({
...data.stock,
market: marketHint ?? data.stock.market,
});
setMeta({
priceSource: data.priceSource,
marketPhase: data.marketPhase,
fetchedAt: data.fetchedAt,
});
} catch (err) {
const message =
err instanceof Error
? err.message
: "종목 조회 중 오류가 발생했습니다.";
setError(message);
setMeta(null);
}
});
},
[],
);
// 실시간 체결 데이터 수신 시 헤더/차트 기준 가격을 갱신합니다.
const updateRealtimeTradeTick = useCallback(
(tick: DashboardRealtimeTradeTick) => {
setSelectedStock((prev) => {
if (!prev) return prev;
const { price, accumulatedVolume, change, changeRate, tickTime } = tick;
const pointTime =
tickTime && tickTime.length === 6
? `${tickTime.slice(0, 2)}:${tickTime.slice(2, 4)}`
: "실시간";
const nextChange = change;
const nextChangeRate = Number.isFinite(changeRate)
? changeRate
: prev.prevClose > 0
? (nextChange / prev.prevClose) * 100
: prev.changeRate;
const nextHigh = prev.high > 0 ? Math.max(prev.high, price) : price;
const nextLow = prev.low > 0 ? Math.min(prev.low, price) : price;
const nextCandles =
prev.candles.length > 0 &&
prev.candles[prev.candles.length - 1]?.time === pointTime
? [
...prev.candles.slice(0, -1),
{
...prev.candles[prev.candles.length - 1],
time: pointTime,
price,
},
]
: [...prev.candles, { time: pointTime, price }].slice(-80);
return {
...prev,
currentPrice: price,
change: nextChange,
changeRate: nextChangeRate,
high: nextHigh,
low: nextLow,
volume: accumulatedVolume > 0 ? accumulatedVolume : prev.volume,
candles: nextCandles,
};
});
},
[],
);
return {
selectedStock,
setSelectedStock,
meta,
setMeta,
error,
setError,
isLoading,
loadOverview,
updateRealtimeTradeTick,
};
}

View File

@@ -0,0 +1,91 @@
import { useCallback, useRef, useState } from "react";
import type { KisRuntimeCredentials } from "@/features/dashboard/store/use-kis-runtime-store";
import type { DashboardStockSearchItem } from "@/features/dashboard/types/dashboard.types";
import { fetchStockSearch } from "@/features/dashboard/apis/kis-stock.api";
export function useStockSearch() {
const [keyword, setKeyword] = useState("삼성전자");
const [searchResults, setSearchResults] = useState<
DashboardStockSearchItem[]
>([]);
const [error, setError] = useState<string | null>(null);
const [isSearching, setIsSearching] = useState(false);
const requestIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
const loadSearch = useCallback(async (query: string) => {
const requestId = ++requestIdRef.current;
const controller = new AbortController();
abortRef.current?.abort();
abortRef.current = controller;
setIsSearching(true);
setError(null);
try {
const data = await fetchStockSearch(query, controller.signal);
if (requestId === requestIdRef.current) {
setSearchResults(data.items);
}
return data.items;
} catch (err) {
if (controller.signal.aborted) {
return [];
}
if (requestId === requestIdRef.current) {
setError(
err instanceof Error
? err.message
: "종목 검색 중 오류가 발생했습니다.",
);
}
return [];
} finally {
if (requestId === requestIdRef.current) {
setIsSearching(false);
}
}
}, []);
const search = useCallback(
(query: string, credentials: KisRuntimeCredentials | null) => {
if (!credentials) {
setError("API 키 검증이 필요합니다.");
setSearchResults([]);
setIsSearching(false);
return;
}
const trimmed = query.trim();
if (!trimmed) {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
return;
}
void loadSearch(trimmed);
},
[loadSearch],
);
const clearSearch = useCallback(() => {
abortRef.current?.abort();
setSearchResults([]);
setError(null);
setIsSearching(false);
}, []);
return {
keyword,
setKeyword,
searchResults,
setSearchResults,
error,
setError,
isSearching,
search,
clearSearch,
};
}

View File

@@ -0,0 +1,229 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import type { KisTradingEnv } from "@/features/dashboard/types/dashboard.types";
import { fetchKisWebSocketApproval } from "@/features/dashboard/apis/kis-auth.api";
/**
* @file features/dashboard/store/use-kis-runtime-store.ts
* @description KIS 키 입력/검증 상태를 zustand로 관리하고 새로고침 시 복원합니다.
*/
export interface KisRuntimeCredentials {
appKey: string;
appSecret: string;
tradingEnv: KisTradingEnv;
accountNo: string;
}
interface KisRuntimeStoreState {
// [State] 입력 폼 상태
kisTradingEnvInput: KisTradingEnv;
kisAppKeyInput: string;
kisAppSecretInput: string;
kisAccountNoInput: string;
// [State] 검증/연동 상태
verifiedCredentials: KisRuntimeCredentials | null;
isKisVerified: boolean;
tradingEnv: KisTradingEnv;
// [State] 웹소켓 승인키
wsApprovalKey: string | null;
}
interface KisRuntimeStoreActions {
/**
* 거래 모드 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param tradingEnv 거래 모드
* @see features/dashboard/components/dashboard-main.tsx 거래 모드 버튼 onClick 이벤트
*/
setKisTradingEnvInput: (tradingEnv: KisTradingEnv) => void;
/**
* 앱 키 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appKey 앱 키
* @see features/dashboard/components/dashboard-main.tsx App Key onChange 이벤트
*/
setKisAppKeyInput: (appKey: string) => void;
/**
* 앱 시크릿 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param appSecret 앱 시크릿
* @see features/dashboard/components/dashboard-main.tsx App Secret onChange 이벤트
*/
setKisAppSecretInput: (appSecret: string) => void;
/**
* 계좌번호 입력값을 변경하고 기존 검증 상태를 무효화합니다.
* @param accountNo 계좌번호 (8자리-2자리)
*/
setKisAccountNoInput: (accountNo: string) => void;
/**
* 검증 성공 상태를 저장합니다.
* @param credentials 검증 완료된 키
* @param tradingEnv 현재 연동 모드
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis
*/
setVerifiedKisSession: (
credentials: KisRuntimeCredentials,
tradingEnv: KisTradingEnv,
) => void;
/**
* 검증 실패 또는 입력 변경 시 검증 상태만 초기화합니다.
* @see features/dashboard/components/dashboard-main.tsx handleValidateKis catch
*/
invalidateKisVerification: () => void;
/**
* 접근 폐기 시 입력값/검증값을 모두 제거합니다.
* @param tradingEnv 표시용 모드
* @see features/dashboard/components/dashboard-main.tsx handleRevokeKis
*/
clearKisRuntimeSession: (tradingEnv: KisTradingEnv) => void;
/**
* 웹소켓 승인키를 가져오거나 없으면 발급받습니다.
* @returns approvalKey
*/
getOrFetchApprovalKey: () => Promise<string | null>;
}
const INITIAL_STATE: KisRuntimeStoreState = {
kisTradingEnvInput: "real",
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv: "real",
wsApprovalKey: null,
};
// 동시 요청 방지를 위한 모듈 스코프 변수
let approvalPromise: Promise<string | null> | null = null;
export const useKisRuntimeStore = create<
KisRuntimeStoreState & KisRuntimeStoreActions
>()(
persist(
(set, get) => ({
...INITIAL_STATE,
setKisTradingEnvInput: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppKeyInput: (appKey) =>
set({
kisAppKeyInput: appKey,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAppSecretInput: (appSecret) =>
set({
kisAppSecretInput: appSecret,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setKisAccountNoInput: (accountNo) =>
set({
kisAccountNoInput: accountNo,
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
setVerifiedKisSession: (credentials, tradingEnv) =>
set({
verifiedCredentials: credentials,
isKisVerified: true,
tradingEnv,
// 인증이 바뀌면 승인키도 초기화
wsApprovalKey: null,
}),
invalidateKisVerification: () =>
set({
verifiedCredentials: null,
isKisVerified: false,
wsApprovalKey: null,
}),
clearKisRuntimeSession: (tradingEnv) =>
set({
kisTradingEnvInput: tradingEnv,
kisAppKeyInput: "",
kisAppSecretInput: "",
kisAccountNoInput: "",
verifiedCredentials: null,
isKisVerified: false,
tradingEnv,
wsApprovalKey: null,
}),
getOrFetchApprovalKey: async () => {
const { wsApprovalKey, verifiedCredentials } = get();
// 1. 이미 키가 있으면 반환
if (wsApprovalKey) {
return wsApprovalKey;
}
// 2. 인증 정보가 없으면 실패
if (!verifiedCredentials) {
return null;
}
// 3. 이미 진행 중인 요청이 있다면 해당 Promise 반환 (Deduping)
if (approvalPromise) {
return approvalPromise;
}
// 4. API 호출
approvalPromise = (async () => {
try {
const data = await fetchKisWebSocketApproval(verifiedCredentials);
if (data.approvalKey) {
set({ wsApprovalKey: data.approvalKey });
return data.approvalKey;
}
return null;
} catch (error) {
console.error(error);
return null;
} finally {
approvalPromise = null;
}
})();
return approvalPromise;
},
}),
{
name: "autotrade-kis-runtime-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
kisTradingEnvInput: state.kisTradingEnvInput,
kisAppKeyInput: state.kisAppKeyInput,
kisAppSecretInput: state.kisAppSecretInput,
kisAccountNoInput: state.kisAccountNoInput,
verifiedCredentials: state.verifiedCredentials,
isKisVerified: state.isKisVerified,
tradingEnv: state.tradingEnv,
// wsApprovalKey도 로컬 스토리지에 저장하여 새로고침 후에도 유지 (선택사항이나 유지하는 게 유리)
// 단, 승인키 유효기간 문제가 있을 수 있으나 API 실패 시 재발급 로직을 넣거나,
// 현재 로직상 인증 정보가 바뀌면 초기화되므로 저장해도 무방.
// 하지만 유효기간 만료 처리가 없으므로 일단 저장하지 않는 게 안전할 수도 있음.
// 사용자가 "새로고침"을 하는 빈도보다 "일반적인 사용"이 많으므로 저장하지 않음 (partialize에서 제외)
// -> 코드를 보니 여기 포함시키지 않으면 저장이 안 됨.
// 유효기간 처리가 없으니 승인키는 메모리에만 유지하도록 함 (새로고침 시 재발급)
}),
},
),
);

View File

@@ -0,0 +1,212 @@
/**
* @file features/dashboard/types/dashboard.types.ts
* @description 대시보드(검색/시세/차트)에서 공통으로 쓰는 타입 모음
*/
export type KisTradingEnv = "real" | "mock";
export type DashboardPriceSource =
| "inquire-price"
| "inquire-ccnl"
| "inquire-overtime-price";
export type DashboardMarketPhase = "regular" | "afterHours";
/**
* KOSPI/KOSDAQ 종목 인덱스 항목
*/
export interface KoreanStockIndexItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
standardCode: string;
}
/**
* 차트 1개 점(시점 + 가격)
*/
export interface StockCandlePoint {
time: string;
price: number;
open?: number;
high?: number;
low?: number;
close?: number;
volume?: number;
timestamp?: number;
}
export type DashboardChartTimeframe = "1m" | "30m" | "1h" | "1d" | "1w";
/**
* 호가창 1레벨(가격 + 잔량)
*/
export interface DashboardOrderBookLevel {
askPrice: number;
bidPrice: number;
askSize: number;
bidSize: number;
}
/**
* 대시보드 종목 상세 모델
*/
export interface DashboardStockItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
currentPrice: number;
change: number;
changeRate: number;
open: number;
high: number;
low: number;
prevClose: number;
volume: number;
candles: StockCandlePoint[];
}
/**
* 검색 결과 1개 항목
*/
export interface DashboardStockSearchItem {
symbol: string;
name: string;
market: "KOSPI" | "KOSDAQ";
}
/**
* 종목 검색 API 응답
*/
export interface DashboardStockSearchResponse {
query: string;
items: DashboardStockSearchItem[];
total: number;
}
/**
* 종목 개요 API 응답
*/
export interface DashboardStockOverviewResponse {
stock: DashboardStockItem;
source: "kis";
priceSource: DashboardPriceSource;
marketPhase: DashboardMarketPhase;
tradingEnv: KisTradingEnv;
fetchedAt: string;
}
export interface DashboardStockChartResponse {
symbol: string;
timeframe: DashboardChartTimeframe;
candles: StockCandlePoint[];
nextCursor: string | null;
hasMore: boolean;
fetchedAt: string;
}
/**
* 종목 호가 API 응답
*/
export interface DashboardStockOrderBookResponse {
symbol: string;
source: "kis" | "REALTIME";
levels: DashboardOrderBookLevel[];
totalAskSize: number;
totalBidSize: number;
businessHour?: string;
hourClassCode?: string;
accumulatedVolume?: number;
anticipatedPrice?: number;
anticipatedVolume?: number;
anticipatedTotalVolume?: number;
anticipatedChange?: number;
anticipatedChangeSign?: string;
anticipatedChangeRate?: number;
totalAskSizeDelta?: number;
totalBidSizeDelta?: number;
tradingEnv: KisTradingEnv | string;
fetchedAt: string;
}
/**
* 실시간 체결(틱) 1건 모델
*/
export interface DashboardRealtimeTradeTick {
symbol: string;
tickTime: string;
price: number;
change: number;
changeRate: number;
tradeVolume: number;
accumulatedVolume: number;
tradeStrength: number;
askPrice1: number;
bidPrice1: number;
sellExecutionCount: number;
buyExecutionCount: number;
netBuyExecutionCount: number;
open: number;
high: number;
low: number;
}
export type DashboardOrderSide = "buy" | "sell";
export type DashboardOrderType = "limit" | "market";
/**
* 국내주식 현금 주문 요청 모델
*/
export interface DashboardStockCashOrderRequest {
symbol: string;
side: DashboardOrderSide;
orderType: DashboardOrderType;
quantity: number;
price: number;
accountNo: string;
accountProductCode: string;
}
/**
* 국내주식 현금 주문 응답 모델
*/
export interface DashboardStockCashOrderResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
orderNo?: string;
orderTime?: string;
orderOrgNo?: string;
}
/**
* KIS 키 검증 API 응답
*/
export interface DashboardKisValidateResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
sample?: {
symbol: string;
name: string;
currentPrice: number;
};
}
/**
* KIS 키 접근 폐기 API 응답
*/
export interface DashboardKisRevokeResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
}
/**
* KIS 웹소켓 승인키 발급 API 응답
*/
export interface DashboardKisWsApprovalResponse {
ok: boolean;
tradingEnv: KisTradingEnv;
message: string;
approvalKey?: string;
wsUrl?: string;
}

View File

@@ -0,0 +1,269 @@
import type {
DashboardRealtimeTradeTick,
DashboardStockOrderBookResponse,
StockCandlePoint,
} from "@/features/dashboard/types/dashboard.types";
const REALTIME_SIGN_NEGATIVE = new Set(["4", "5"]);
const TICK_FIELD_INDEX = {
symbol: 0,
tickTime: 1,
price: 2,
sign: 3,
change: 4,
changeRate: 5,
open: 7,
high: 8,
low: 9,
askPrice1: 10,
bidPrice1: 11,
tradeVolume: 12,
accumulatedVolume: 13,
sellExecutionCount: 15,
buyExecutionCount: 16,
netBuyExecutionCount: 17,
tradeStrength: 18,
} as const;
/**
* KIS 실시간 구독/해제 웹소켓 메시지를 생성합니다.
*/
export function buildKisRealtimeMessage(
approvalKey: string,
symbol: string,
trId: string,
trType: "1" | "2",
) {
return {
header: {
approval_key: approvalKey,
custtype: "P",
tr_type: trType,
"content-type": "utf-8",
},
body: {
input: {
tr_id: trId,
tr_key: symbol,
},
},
};
}
/**
* 실시간 체결 스트림(raw)을 배열 단위로 파싱합니다.
* - 배치 전송(복수 틱)일 때도 모든 틱을 추출
* - 심볼 불일치/가격 0 이하 데이터는 제외
*/
export function parseKisRealtimeTickBatch(raw: string, expectedSymbol: string) {
if (!/^([01])\|/.test(raw)) return [] as DashboardRealtimeTradeTick[];
const parts = raw.split("|");
if (parts.length < 4) return [] as DashboardRealtimeTradeTick[];
// TR ID Check: Allow H0STCNT0 (Real/Mock) or H0STOUP0 (Overtime)
const receivedTrId = parts[1];
if (receivedTrId !== "H0STCNT0" && receivedTrId !== "H0STOUP0") {
// console.warn("[KisRealtime] Unknown TR ID for Trade Tick:", receivedTrId);
return [] as DashboardRealtimeTradeTick[];
}
// if (parts[1] !== expectedTrId) return [] as DashboardRealtimeTradeTick[];
const tickCount = Number(parts[2] ?? "1");
const values = parts[3].split("^");
if (values.length === 0) return [] as DashboardRealtimeTradeTick[];
const parsedCount =
Number.isInteger(tickCount) && tickCount > 0 ? tickCount : 1;
const fieldsPerTick = Math.floor(values.length / parsedCount);
if (fieldsPerTick <= TICK_FIELD_INDEX.tradeStrength) {
return [] as DashboardRealtimeTradeTick[];
}
const ticks: DashboardRealtimeTradeTick[] = [];
for (let index = 0; index < parsedCount; index++) {
const base = index * fieldsPerTick;
const symbol = readString(values, base + TICK_FIELD_INDEX.symbol);
if (symbol !== expectedSymbol) {
if (symbol.trim() !== expectedSymbol.trim()) {
console.warn(
`[KisRealtime] Symbol mismatch: received '${symbol}', expected '${expectedSymbol}'`,
);
continue;
}
}
const price = readNumber(values, base + TICK_FIELD_INDEX.price);
if (!Number.isFinite(price) || price <= 0) continue;
const sign = readString(values, base + TICK_FIELD_INDEX.sign);
const rawChange = readNumber(values, base + TICK_FIELD_INDEX.change);
const rawChangeRate = readNumber(
values,
base + TICK_FIELD_INDEX.changeRate,
);
const change = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChange)
: rawChange;
const changeRate = REALTIME_SIGN_NEGATIVE.has(sign)
? -Math.abs(rawChangeRate)
: rawChangeRate;
ticks.push({
symbol,
tickTime: readString(values, base + TICK_FIELD_INDEX.tickTime),
price,
change,
changeRate,
tradeVolume: readNumber(values, base + TICK_FIELD_INDEX.tradeVolume),
accumulatedVolume: readNumber(
values,
base + TICK_FIELD_INDEX.accumulatedVolume,
),
tradeStrength: readNumber(values, base + TICK_FIELD_INDEX.tradeStrength),
askPrice1: readNumber(values, base + TICK_FIELD_INDEX.askPrice1),
bidPrice1: readNumber(values, base + TICK_FIELD_INDEX.bidPrice1),
sellExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.sellExecutionCount,
),
buyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.buyExecutionCount,
),
netBuyExecutionCount: readNumber(
values,
base + TICK_FIELD_INDEX.netBuyExecutionCount,
),
open: readNumber(values, base + TICK_FIELD_INDEX.open),
high: readNumber(values, base + TICK_FIELD_INDEX.high),
low: readNumber(values, base + TICK_FIELD_INDEX.low),
});
}
return ticks;
}
export function formatRealtimeTickTime(hhmmss?: string) {
if (!hhmmss || hhmmss.length !== 6) return "실시간";
return `${hhmmss.slice(0, 2)}:${hhmmss.slice(2, 4)}:${hhmmss.slice(4, 6)}`;
}
export function appendRealtimeTick(
prev: StockCandlePoint[],
next: StockCandlePoint,
) {
if (prev.length === 0) return [next];
const last = prev[prev.length - 1];
if (last.time === next.time) {
return [...prev.slice(0, -1), next];
}
return [...prev, next].slice(-80);
}
export function toTickOrderValue(hhmmss?: string) {
if (!hhmmss || !/^\d{6}$/.test(hhmmss)) return -1;
return Number(hhmmss);
}
/**
* KIS 실시간 호가(H0STASP0/H0UNASP0/H0STOAA0)를 OrderBook 구조로 파싱합니다.
*/
export function parseKisRealtimeOrderbook(
raw: string,
expectedSymbol: string,
): DashboardStockOrderBookResponse | null {
if (!/^([01])\|/.test(raw)) return null;
const parts = raw.split("|");
if (parts.length < 4) return null;
const trId = parts[1];
if (trId !== "H0STASP0" && trId !== "H0UNASP0" && trId !== "H0STOAA0") {
return null;
}
const values = parts[3].split("^");
const levelCount = trId === "H0STOAA0" ? 9 : 10;
const symbol = values[0]?.trim() ?? "";
const normalizedSymbol = normalizeDomesticSymbol(symbol);
const normalizedExpected = normalizeDomesticSymbol(expectedSymbol);
if (normalizedSymbol !== normalizedExpected) return null;
const askPriceStart = 3;
const bidPriceStart = askPriceStart + levelCount;
const askSizeStart = bidPriceStart + levelCount;
const bidSizeStart = askSizeStart + levelCount;
const totalAskIndex = bidSizeStart + levelCount;
const totalBidIndex = totalAskIndex + 1;
const anticipatedPriceIndex = totalBidIndex + 3;
const anticipatedVolumeIndex = anticipatedPriceIndex + 1;
const anticipatedTotalVolumeIndex = anticipatedPriceIndex + 2;
const anticipatedChangeIndex = anticipatedPriceIndex + 3;
const anticipatedChangeSignIndex = anticipatedPriceIndex + 4;
const anticipatedChangeRateIndex = anticipatedPriceIndex + 5;
const accumulatedVolumeIndex = anticipatedPriceIndex + 6;
const totalAskDeltaIndex = anticipatedPriceIndex + 7;
const totalBidDeltaIndex = anticipatedPriceIndex + 8;
const minFieldLength = totalBidDeltaIndex + 1;
if (values.length < minFieldLength) return null;
const realtimeLevels = Array.from({ length: levelCount }, (_, i) => ({
askPrice: readNumber(values, askPriceStart + i),
bidPrice: readNumber(values, bidPriceStart + i),
askSize: readNumber(values, askSizeStart + i),
bidSize: readNumber(values, bidSizeStart + i),
}));
return {
symbol: normalizedExpected,
totalAskSize: readNumber(values, totalAskIndex),
totalBidSize: readNumber(values, totalBidIndex),
businessHour: readString(values, 1),
hourClassCode: readString(values, 2),
anticipatedPrice: readNumber(values, anticipatedPriceIndex),
anticipatedVolume: readNumber(values, anticipatedVolumeIndex),
anticipatedTotalVolume: readNumber(values, anticipatedTotalVolumeIndex),
anticipatedChange: readNumber(values, anticipatedChangeIndex),
anticipatedChangeSign: readString(values, anticipatedChangeSignIndex),
anticipatedChangeRate: readNumber(values, anticipatedChangeRateIndex),
accumulatedVolume: readNumber(values, accumulatedVolumeIndex),
totalAskSizeDelta: readNumber(values, totalAskDeltaIndex),
totalBidSizeDelta: readNumber(values, totalBidDeltaIndex),
levels: realtimeLevels,
source: "REALTIME",
tradingEnv: "real",
fetchedAt: new Date().toISOString(),
};
}
/**
* @description 국내 종목코드 비교를 위해 접두 문자를 제거하고 6자리 코드로 정규화합니다.
* @see features/dashboard/utils/kis-realtime.utils.ts parseKisRealtimeOrderbook 종목 매칭 비교
*/
function normalizeDomesticSymbol(value: string) {
const trimmed = value.trim();
const digits = trimmed.replace(/\D/g, "");
if (digits.length >= 6) {
return digits.slice(-6);
}
return trimmed;
}
function readString(values: string[], index: number) {
return (values[index] ?? "").trim();
}
function readNumber(values: string[], index: number) {
const raw = readString(values, index).replaceAll(",", "");
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
}

View File

@@ -0,0 +1,29 @@
"use client";
import Spline from "@splinetool/react-spline";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface SplineSceneProps {
sceneUrl: string;
className?: string;
}
export function SplineScene({ sceneUrl, className }: SplineSceneProps) {
const [isLoading, setIsLoading] = useState(true);
return (
<div className={cn("relative h-full w-full", className)}>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-zinc-100 dark:bg-zinc-900">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-zinc-200 border-t-brand-500 dark:border-zinc-800" />
</div>
)}
<Spline
scene={sceneUrl}
onLoad={() => setIsLoading(false)}
className="h-full w-full"
/>
</div>
);
}

View File

@@ -0,0 +1,153 @@
/**
* @file features/layout/components/header.tsx
* @description 애플리케이션 상단 헤더 컴포넌트
*/
import Link from "next/link";
import { User } from "@supabase/supabase-js";
import { AUTH_ROUTES } from "@/features/auth/constants";
import { UserMenu } from "@/features/layout/components/user-menu";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { SessionTimer } from "@/features/auth/components/session-timer";
import { cn } from "@/lib/utils";
interface HeaderProps {
/** 현재 로그인 사용자 정보(null 가능) */
user: User | null;
/** 대시보드 링크 버튼 노출 여부 */
showDashboardLink?: boolean;
/** 홈 랜딩에서 배경과 자연스럽게 섞이는 헤더 모드 */
blendWithBackground?: boolean;
}
/**
* 글로벌 헤더 컴포넌트
* @param user Supabase User 객체
* @param showDashboardLink 대시보드 버튼 노출 여부
* @param blendWithBackground 홈 랜딩 전용 반투명 모드
* @returns Header JSX
* @see app/(home)/page.tsx 홈 랜딩에서 blendWithBackground=true로 호출
*/
export function Header({
user,
showDashboardLink = false,
blendWithBackground = false,
}: HeaderProps) {
return (
<header
className={cn(
"fixed inset-x-0 top-0 z-50 w-full",
blendWithBackground
? "text-white"
: "border-b border-border/40 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/60",
)}
>
{blendWithBackground && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-24 bg-linear-to-b from-black/70 via-black/35 to-transparent"
/>
)}
<div
className={cn(
"relative z-10 flex h-16 w-full items-center justify-between px-4 md:px-6",
blendWithBackground
? "bg-black/30 backdrop-blur-xl supports-backdrop-filter:bg-black/20"
: "",
)}
>
{/* ========== LEFT: LOGO SECTION ========== */}
<Link
href={AUTH_ROUTES.HOME}
className={cn("group flex items-center gap-2", blendWithBackground ? "text-white" : "")}
>
<div
className={cn(
"flex h-9 w-9 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-110",
blendWithBackground ? "bg-brand-500/45 ring-1 ring-white/55" : "bg-primary/10",
)}
>
<div className="h-5 w-5 rounded-lg bg-linear-to-br from-brand-500 to-brand-700" />
</div>
<span
className={cn(
"text-xl font-bold tracking-tight transition-colors",
blendWithBackground
? "!text-white [text-shadow:0_2px_18px_rgba(0,0,0,0.75)] group-hover:text-brand-100"
: "text-foreground group-hover:text-primary",
)}
>
AutoTrade
</span>
</Link>
{/* ========== RIGHT: ACTION SECTION ========== */}
<div className={cn("flex items-center gap-2 sm:gap-3", blendWithBackground ? "text-white" : "")}
>
<ThemeToggle
className={cn(
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 focus-visible:ring-white/80"
: "",
)}
iconClassName={blendWithBackground ? "!text-white" : undefined}
/>
{user ? (
<>
<SessionTimer blendWithBackground={blendWithBackground} />
{showDashboardLink && (
<Button
asChild
variant="ghost"
size="sm"
className={cn(
"hidden font-medium sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md [text-shadow:0_1px_8px_rgba(0,0,0,0.45)] hover:bg-black/65 hover:!text-white"
: "",
)}
>
<Link href={AUTH_ROUTES.DASHBOARD}></Link>
</Button>
)}
<UserMenu user={user} blendWithBackground={blendWithBackground} />
</>
) : (
<div className="flex items-center gap-2">
<Button
asChild
variant="ghost"
size="sm"
className={cn(
"hidden sm:inline-flex",
blendWithBackground
? "rounded-full border border-white/40 bg-black/50 !text-white backdrop-blur-md hover:bg-black/65 hover:!text-white"
: "",
)}
>
<Link href={AUTH_ROUTES.LOGIN}></Link>
</Button>
<Button
asChild
size="sm"
className={cn(
"rounded-full px-6",
blendWithBackground
? "bg-brand-500/90 text-white shadow-lg shadow-brand-700/40 hover:bg-brand-400"
: "",
)}
>
<Link href={AUTH_ROUTES.SIGNUP}></Link>
</Button>
</div>
)}
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,80 @@
"use client";
import { cn } from "@/lib/utils";
import { BarChart2, Home, Settings, User, Wallet } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { MenuItem } from "../types";
const MENU_ITEMS: MenuItem[] = [
{
title: "대시보드",
href: "/",
icon: Home,
variant: "default",
matchExact: true,
},
{
title: "자동매매",
href: "/trade",
icon: BarChart2,
variant: "ghost",
},
{
title: "자산현황",
href: "/assets",
icon: Wallet,
variant: "ghost",
},
{
title: "프로필",
href: "/profile",
icon: User,
variant: "ghost",
},
{
title: "설정",
href: "/settings",
icon: Settings,
variant: "ghost",
},
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden h-[calc(100vh-4rem)] shrink-0 overflow-y-auto border-r border-zinc-200 bg-white py-6 pl-2 pr-6 dark:border-zinc-800 dark:bg-black md:sticky md:top-16 md:block md:w-64 lg:w-72">
<div className="flex flex-col space-y-1">
{MENU_ITEMS.map((item) => {
const isActive = item.matchExact
? pathname === item.href
: pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"group flex items-center rounded-md px-3 py-2 text-sm font-medium hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 transition-colors",
isActive
? "bg-zinc-100 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50"
: "text-zinc-500 dark:text-zinc-400",
)}
>
<item.icon
className={cn(
"mr-3 h-5 w-5 shrink-0 transition-colors",
isActive
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-400 group-hover:text-zinc-900 dark:text-zinc-500 dark:group-hover:text-zinc-50",
)}
/>
{item.title}
</Link>
);
})}
</div>
</aside>
);
}

View File

@@ -0,0 +1,105 @@
/**
* @file features/layout/components/user-menu.tsx
* @description 사용자 프로필 드롭다운 메뉴 컴포넌트
*/
"use client";
import { User } from "@supabase/supabase-js";
import { LogOut, Settings, User as UserIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { signout } from "@/features/auth/actions";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
interface UserMenuProps {
/** Supabase User 객체 */
user: User | null;
/** 홈 랜딩의 shader 배경 위에서 대비를 높이는 모드 */
blendWithBackground?: boolean;
}
/**
* 사용자 메뉴/프로필 컴포넌트
* @param user 로그인한 사용자 정보
* @param blendWithBackground shader 배경 위 가독성 모드
* @returns Avatar 버튼 + 드롭다운 메뉴
* @see features/layout/components/header.tsx 헤더 우측 액션 영역에서 호출
*/
export function UserMenu({ user, blendWithBackground = false }: UserMenuProps) {
const router = useRouter();
if (!user) return null;
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex items-center gap-2 rounded-full outline-none transition-colors",
blendWithBackground
? "ring-1 ring-white/30 hover:bg-black/30 focus-visible:ring-2 focus-visible:ring-white/70"
: "",
)}
aria-label="사용자 메뉴 열기"
>
<Avatar className="h-8 w-8 transition-opacity hover:opacity-90">
<AvatarImage src={user.user_metadata?.avatar_url} />
<AvatarFallback
className={cn(
"text-xs font-bold text-white",
blendWithBackground
? "bg-brand-500/90 [text-shadow:0_1px_8px_rgba(0,0,0,0.45)]"
: "bg-linear-to-br from-brand-500 to-brand-700",
)}
>
{user.email?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">
{user.user_metadata?.full_name || user.user_metadata?.name || "사용자"}
</p>
<p className="text-xs leading-none text-muted-foreground">{user.email}</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push("/profile")}>
<UserIcon className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push("/settings")}>
<Settings className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<form action={signout}>
<DropdownMenuItem asChild>
<button className="w-full text-red-600 dark:text-red-400">
<LogOut className="mr-2 h-4 w-4" />
<span></span>
</button>
</DropdownMenuItem>
</form>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,9 @@
import { LucideIcon } from "lucide-react";
export interface MenuItem {
title: string;
href: string;
icon: LucideIcon;
variant: "default" | "ghost";
matchExact?: boolean;
}

View File

@@ -0,0 +1,42 @@
import { useQuery } from "@tanstack/react-query";
import { createClient } from "@/utils/supabase/client";
/**
* [사용자 정보 조회 쿼리]
*
* 현재 로그인한 사용자의 정보를 조회합니다.
* - 자동 캐싱 및 재검증
* - 로딩/에러 상태 자동 관리
*
* @example
* ```tsx
* import { useUserQuery } from '@/hooks/queries/use-user-query';
*
* function Profile() {
* const { data: user, isLoading, error } = useUserQuery();
*
* if (isLoading) return <div>Loading...</div>;
* if (error) return <div>Error: {error.message}</div>;
* if (!user) return <div>Not logged in</div>;
*
* return <div>Welcome, {user.email}</div>;
* }
* ```
*/
export function useUserQuery() {
return useQuery({
queryKey: ["user"],
queryFn: async () => {
const supabase = createClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error) throw error;
return user;
},
staleTime: 5 * 60 * 1000, // 5분 - 사용자 정보는 자주 변경되지 않음
retry: 1,
});
}

Some files were not shown because too many files have changed in this diff Show More