Compare commits
38 Commits
main
...
1ac907cd27
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ac907cd27 | |||
| 12feeb2775 | |||
| 434a814246 | |||
| 8f1d75b4d5 | |||
| 3cea3e66d0 | |||
| f650d51f68 | |||
| 95291e6922 | |||
| def87bd47a | |||
| 89bad1d141 | |||
| e5a518b211 | |||
| ca01f33d71 | |||
| 851a2acd69 | |||
| 35916430b7 | |||
| ac7effc939 | |||
| d2c66a639d | |||
| d31e3f9bc9 | |||
| f1e340d9f1 | |||
| ded49b5e2a | |||
| 2d34d70948 | |||
| 9c967af9c1 | |||
| aae7000807 | |||
| 22ced3a6ae | |||
| edcfa2a837 | |||
| 4b41267ea5 | |||
| 0436ddf41c | |||
| 63a09034a9 | |||
| 462d3c1923 | |||
| 7500b963c0 | |||
| a7bcbeda72 | |||
| 09277205e7 | |||
| ac292bcf2a | |||
| c0ecec6586 | |||
| 06a90b4fd6 | |||
| 40757e393a | |||
| 151626b181 | |||
| 43119caf80 | |||
| 12182823b0 | |||
| 3058b93c66 |
34
.agent/rules/auto-trade.md
Normal file
34
.agent/rules/auto-trade.md
Normal 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 활용
|
||||||
|
- 변경 사항은 반드시 로컬에서 검증 후 완료 보고
|
||||||
|
- 에러 발생 시 근본 원인 파악 및 해결
|
||||||
175
.agent/rules/builder-rule.md
Normal file
175
.agent/rules/builder-rule.md
Normal 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` 타입 사용 금지
|
||||||
313
.agent/rules/code-analysis-rule.md
Normal file
313
.agent/rules/code-analysis-rule.md
Normal 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
333
.agent/rules/doc-rule.md
Normal 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는 파일명 + 함수명 + 역할**: 전체 경로 불필요
|
||||||
|
|
||||||
|
# 지금부터 작업
|
||||||
|
|
||||||
|
내가 주는 코드를 위 규칙에 맞게 "주석만" 보강하라.
|
||||||
341
.agent/rules/master-integration.md
Normal file
341
.agent/rules/master-integration.md
Normal 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개 룰
|
||||||
94
.agent/rules/refactoring-rule.md
Normal file
94
.agent/rules/refactoring-rule.md
Normal 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)보다는 명확한 상대/절대 경로를 사용한다.
|
||||||
96
.agent/skills/find-skills/SKILL.md
Normal file
96
.agent/skills/find-skills/SKILL.md
Normal 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`
|
||||||
65
.agent/skills/nextjs-app-router-patterns/SKILL.md
Normal file
65
.agent/skills/nextjs-app-router-patterns/SKILL.md
Normal 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.
|
||||||
95
.agent/skills/vercel-react-best-practices/SKILL.md
Normal file
95
.agent/skills/vercel-react-best-practices/SKILL.md
Normal 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
|
||||||
29
.env.example
Normal file
29
.env.example
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# (선택) 서버에서 기본 계좌번호를 사용할 경우
|
||||||
|
# 형식: KIS_ACCOUNT_NO=12345678 또는 12345678-01
|
||||||
|
# KIS_ACCOUNT_PRODUCT_CODE=01
|
||||||
|
KIS_ACCOUNT_NO=
|
||||||
|
KIS_ACCOUNT_PRODUCT_CODE=
|
||||||
6
.gemini/settings.json
Normal file
6
.gemini/settings.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"tools": {
|
||||||
|
"approvalMode": "auto_edit",
|
||||||
|
"allowed": ["run_shell_command"]
|
||||||
|
}
|
||||||
|
}
|
||||||
132
.gitignore
vendored
Normal file
132
.gitignore
vendored
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# ========================================
|
||||||
|
# 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
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Custom
|
||||||
|
# ========================================
|
||||||
|
.playwright-mcp/
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# Documentation (문서)
|
||||||
|
# ========================================
|
||||||
|
docs/
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"chatgpt.openOnStartup": false
|
||||||
|
}
|
||||||
56
AGENTS.md
Normal file
56
AGENTS.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# 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): `npm run dev`
|
||||||
|
- 린트: `npm run lint`
|
||||||
|
- 빌드: `npm run build`
|
||||||
|
- 실행: `npm run start`
|
||||||
|
|
||||||
|
## 코드 및 문서 규칙
|
||||||
|
|
||||||
|
- JSX 섹션 주석 형식: `{/* ========== SECTION NAME ========== */}`
|
||||||
|
- 함수 및 컴포넌트 JSDoc에 `@see` 필수
|
||||||
|
- `@see`에는 호출 파일, 함수/이벤트 이름, 목적을 함께 작성
|
||||||
|
- 상태 정의, 이벤트 핸들러, 복잡한 JSX 로직에는 인라인 주석을 충분히 작성
|
||||||
|
- UI 흐름 설명 필수: `어느 UI -> A 함수 호출 -> B 함수 호출 -> 리턴값 반영` 형태로 작성
|
||||||
|
|
||||||
|
## 브랜드 색상 규칙
|
||||||
|
|
||||||
|
- 메인 컬러는 헤더 로고/프로필 기준의 보라 계열 `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` 토큰 수정
|
||||||
|
|
||||||
|
## 개발 도구 활용
|
||||||
|
|
||||||
|
- **Skills**: 프로젝트에 적합한 스킬을 적극 활용하여 베스트 프랙티스 적용
|
||||||
|
- **MCP 서버**:
|
||||||
|
- `sequential-thinking`: 복잡한 문제 해결 시 단계별 사고 과정 정리
|
||||||
|
- `tavily-remote`: 최신 기술 트렌드 및 문서 검색
|
||||||
|
- `playwright` / `playwriter`: 브라우저 자동화 테스트
|
||||||
|
- `next-devtools`: Next.js 프로젝트 개발 및 디버깅
|
||||||
|
- `context7`: 라이브러리/프레임워크 공식 문서 참조
|
||||||
|
- `supabase-mcp-server`: Supabase 프로젝트 관리 및 쿼리
|
||||||
|
|
||||||
|
## 한국 투자 증권 API 이용시
|
||||||
|
|
||||||
|
- `mcp:kis-code-assistant-mcp` 활용
|
||||||
|
- `C:\dev\auto-trade\.tmp\open-trading-api` 활용
|
||||||
|
- API 이용시 공식 문서에 최신 업데이트가 안되어 있을수 있으므로 필요시 최신 API 명세 엑셀파일 요청을 한다. 그럼 사용자가 업로드 해줄것이다.
|
||||||
160
README.md
160
README.md
@@ -1 +1,161 @@
|
|||||||
# auto-trade
|
# auto-trade
|
||||||
|
|
||||||
|
한국투자증권(KIS) Open API와 Supabase 인증을 연결한 국내주식 트레이딩 대시보드입니다.
|
||||||
|
사용자는 로그인 후 API 키를 검증하고, 종목 검색/상세/차트/호가/체결/주문 화면을 한 곳에서 사용할 수 있습니다.
|
||||||
|
|
||||||
|
## 1) 핵심 기능
|
||||||
|
|
||||||
|
- 인증: Supabase 이메일/소셜 로그인, 비밀번호 재설정, 세션 타임아웃 자동 로그아웃
|
||||||
|
- KIS 연결: 실전/모의 모드 선택, API 키 검증, 웹소켓 승인키 발급
|
||||||
|
- 트레이드 화면: 종목 검색, 종목 개요, 캔들 차트, 실시간 호가/체결, 현금 주문
|
||||||
|
- 종목 검색 인덱스: `korean-stocks.json` 기반 고속 검색 + 자동 갱신 스크립트
|
||||||
|
|
||||||
|
## 2) 기술 스택
|
||||||
|
|
||||||
|
- 프레임워크: Next.js 16 (App Router), React 19, TypeScript
|
||||||
|
- 상태관리: Zustand
|
||||||
|
- 서버 상태: TanStack Query (React Query)
|
||||||
|
- 인증/백엔드: Supabase (`@supabase/ssr`, `@supabase/supabase-js`)
|
||||||
|
- UI: Tailwind CSS v4, Radix UI, Sonner
|
||||||
|
- 차트: `lightweight-charts`
|
||||||
|
|
||||||
|
## 3) 화면/라우트
|
||||||
|
|
||||||
|
- `/`: 서비스 랜딩 페이지
|
||||||
|
- `/login`, `/signup`, `/forgot-password`, `/reset-password`: 인증 페이지
|
||||||
|
- `/dashboard`: 로그인 전용(현재 플레이스홀더)
|
||||||
|
- `/settings`: KIS API 키 연결/해제
|
||||||
|
- `/trade`: 실제 트레이딩 대시보드
|
||||||
|
|
||||||
|
## 4) UI 흐름 (중요)
|
||||||
|
|
||||||
|
- 인증 흐름: 로그인 UI -> `features/auth/actions.ts` -> Supabase Auth -> 세션 쿠키 반영 -> 보호 라우트 접근 허용
|
||||||
|
- KIS 연결 흐름: 설정 UI -> `/api/kis/validate` -> 토큰 발급 성공 확인 -> `use-kis-runtime-store`에 검증 상태 저장
|
||||||
|
- 실시간 흐름: 트레이드 UI -> `/api/kis/ws/approval` -> `useKisTradeWebSocket` 구독 -> 체결/호가 파싱 -> 차트/호가창 반영
|
||||||
|
- 검색 흐름: 검색 UI -> `/api/kis/domestic/search` -> `korean-stocks.json` 메모리 검색 -> 결과 목록 반영
|
||||||
|
|
||||||
|
## 5) 빠른 시작
|
||||||
|
|
||||||
|
### 5-1. 요구 사항
|
||||||
|
|
||||||
|
- Node.js 20 이상
|
||||||
|
- npm 10 이상 권장
|
||||||
|
|
||||||
|
### 5-2. 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5-3. 환경변수 설정
|
||||||
|
|
||||||
|
`.env.example`을 복사해서 `.env.local`을 만듭니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
필수 값은 아래를 먼저 채우면 됩니다.
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_URL`
|
||||||
|
- `NEXT_PUBLIC_SUPABASE_ANON_KEY`
|
||||||
|
|
||||||
|
KIS는 선택입니다(직접 입력 방식이면 서버 기본 키 없이도 동작 가능).
|
||||||
|
|
||||||
|
- `KIS_TRADING_ENV=real|mock`
|
||||||
|
- `KIS_APP_KEY_REAL`, `KIS_APP_SECRET_REAL` (선택)
|
||||||
|
- `KIS_APP_KEY_MOCK`, `KIS_APP_SECRET_MOCK` (선택)
|
||||||
|
- `NEXT_PUBLIC_BASE_URL` (선택, 배포 도메인 사용 시 권장)
|
||||||
|
|
||||||
|
### 5-4. 로컬 실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- 개발 서버: `http://localhost:3001`
|
||||||
|
- Turbopack 적용: `package.json`의 `dev` 스크립트에 `--turbopack` 포함
|
||||||
|
|
||||||
|
### 5-5. 점검 명령
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6) 종목 인덱스 동기화
|
||||||
|
|
||||||
|
`features/trade/data/korean-stocks.json`은 수동 편집용 파일이 아닙니다.
|
||||||
|
KIS 마스터 파일(KOSPI/KOSDAQ)에서 자동 생성합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sync:stocks
|
||||||
|
```
|
||||||
|
|
||||||
|
검증만 하고 싶으면:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run sync:stocks:check
|
||||||
|
```
|
||||||
|
|
||||||
|
상세 문서: `docs/trade-stock-sync.md`
|
||||||
|
|
||||||
|
## 7) API 엔드포인트 요약
|
||||||
|
|
||||||
|
- 인증/연결
|
||||||
|
- `POST /api/kis/validate`: API 키 검증
|
||||||
|
- `POST /api/kis/revoke`: 토큰 폐기
|
||||||
|
- `POST /api/kis/ws/approval`: 웹소켓 승인키 발급
|
||||||
|
|
||||||
|
- 국내주식
|
||||||
|
- `GET /api/kis/domestic/search?q=...`: 종목 검색(로컬 인덱스)
|
||||||
|
- `GET /api/kis/domestic/overview?symbol=...`: 종목 개요
|
||||||
|
- `GET /api/kis/domestic/orderbook?symbol=...`: 호가
|
||||||
|
- `GET /api/kis/domestic/chart?symbol=...&timeframe=...`: 차트
|
||||||
|
- `POST /api/kis/domestic/order-cash`: 현금 주문
|
||||||
|
|
||||||
|
## 8) 프로젝트 구조
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
(home)/ 랜딩
|
||||||
|
(auth)/ 로그인/회원가입/비밀번호 재설정
|
||||||
|
(main)/ 로그인 후 화면(dashboard/trade/settings)
|
||||||
|
api/kis/ KIS 연동 API 라우트
|
||||||
|
features/
|
||||||
|
auth/ 인증 UI/액션/상수
|
||||||
|
settings/ KIS 키 설정 UI + 런타임 스토어
|
||||||
|
trade/ 검색/차트/호가/주문/웹소켓
|
||||||
|
lib/kis/ KIS REST/WS 공통 로직
|
||||||
|
scripts/
|
||||||
|
sync-korean-stocks.mjs
|
||||||
|
utils/supabase/ 서버/클라이언트/미들웨어 클라이언트
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9) 트러블슈팅
|
||||||
|
|
||||||
|
- KIS 검증 실패
|
||||||
|
- 입력한 앱키/시크릿, 실전/모의 모드가 맞는지 확인
|
||||||
|
- KIS Open API 앱 권한과 IP 허용 설정 확인
|
||||||
|
|
||||||
|
- 실시간 체결/호가가 안 들어옴
|
||||||
|
- `/settings`에서 검증 상태가 유지되는지 확인
|
||||||
|
- 장 구간(장중/동시호가/시간외)에 따라 데이터가 달라질 수 있음
|
||||||
|
- 브라우저 콘솔 디버그가 필요하면 `localStorage.KIS_WS_DEBUG = "1"` 설정
|
||||||
|
|
||||||
|
- 검색 결과가 기대와 다름
|
||||||
|
- 검색은 KIS 실시간 검색 API가 아니라 로컬 인덱스(`korean-stocks.json`) 기반
|
||||||
|
- 최신 종목 반영이 필요하면 `npm run sync:stocks` 실행
|
||||||
|
|
||||||
|
## 10) 운영 주의사항
|
||||||
|
|
||||||
|
- 현재 KIS 입력값과 검증 상태 일부는 브라우저 로컬 스토리지(localStorage, 브라우저 저장소)에 저장됩니다.
|
||||||
|
- 실전 운영 시에는 민감정보 보관 정책(암호화, 만료, 서버 보관 방식)을 반드시 따로 설계하세요.
|
||||||
|
- 주문은 계좌번호가 필요합니다. 계좌번호 입력/검증 UX는 운영 환경에 맞게 보완이 필요합니다.
|
||||||
|
|||||||
87
app/(auth)/forgot-password/page.tsx
Normal file
87
app/(auth)/forgot-password/page.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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";
|
||||||
|
import { Mail } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 찾기 페이지]
|
||||||
|
*
|
||||||
|
* 사용자가 비밀번호를 잊어버렸을 때 재설정 링크를 요청하는 페이지입니다.
|
||||||
|
* - 이메일 입력 폼 제공
|
||||||
|
* - 서버 액션(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-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/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-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
|
<Mail className="h-7 w-7 text-white" />
|
||||||
|
</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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
formAction={requestPasswordReset}
|
||||||
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
|
>
|
||||||
|
재설정 링크 보내기
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<Link
|
||||||
|
href={AUTH_ROUTES.LOGIN}
|
||||||
|
className="text-sm font-medium text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
로그인 페이지로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
app/(auth)/layout.tsx
Normal file
34
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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-brand-50 via-white to-brand-100/60 dark:from-brand-950 dark:via-gray-950 dark:to-brand-900/40">
|
||||||
|
{/* ========== 헤더 (홈 이동용) ========== */}
|
||||||
|
<Header user={user} />
|
||||||
|
|
||||||
|
{/* ========== 배경 그라디언트 레이어 ========== */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,var(--tw-gradient-stops))] from-brand-200/40 via-brand-100/20 to-transparent dark:from-brand-800/25 dark:via-brand-900/15" />
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_bottom_left,var(--tw-gradient-stops))] from-brand-300/30 via-brand-200/15 to-transparent dark:from-brand-700/20 dark:via-brand-800/10" />
|
||||||
|
|
||||||
|
{/* ========== 애니메이션 블러 효과 ========== */}
|
||||||
|
<div className="absolute left-1/4 top-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-300/25 blur-3xl dark:bg-brand-700/15" />
|
||||||
|
<div className="absolute bottom-1/4 right-1/4 h-72 w-72 animate-pulse rounded-full bg-brand-400/20 blur-3xl delay-700 dark:bg-brand-600/15" />
|
||||||
|
<div className="absolute right-1/3 top-1/3 h-48 w-48 animate-pulse rounded-full bg-brand-200/30 blur-2xl delay-1000 dark:bg-brand-800/20" />
|
||||||
|
|
||||||
|
{/* ========== 메인 콘텐츠 영역 (중앙 정렬) ========== */}
|
||||||
|
<main className="z-10 flex w-full flex-1 flex-col items-center justify-center px-4 py-12">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
app/(auth)/login/page.tsx
Normal file
51
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import FormMessage from "@/components/form-message";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import LoginForm from "@/features/auth/components/login-form";
|
||||||
|
import { LogIn } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [로그인 페이지 컴포넌트]
|
||||||
|
*
|
||||||
|
* 브랜드 컬러 기반 글래스모피즘 카드 디자인
|
||||||
|
* - 보라색 그라디언트 아이콘 배지
|
||||||
|
* - shadcn/ui 컴포넌트로 일관된 디자인 시스템 유지
|
||||||
|
*
|
||||||
|
* @param searchParams - URL 쿼리 파라미터 (에러 메시지 전달용)
|
||||||
|
*/
|
||||||
|
export default async function LoginPage({
|
||||||
|
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-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/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-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
|
<LogIn className="h-7 w-7 text-white" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-3xl font-bold tracking-tight">
|
||||||
|
환영합니다!
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-base">
|
||||||
|
서비스 이용을 위해 로그인해 주세요.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
<LoginForm />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
app/(auth)/reset-password/page.tsx
Normal file
62
app/(auth)/reset-password/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
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";
|
||||||
|
import { KeyRound } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [비밀번호 재설정 페이지]
|
||||||
|
*
|
||||||
|
* 이메일 링크를 타고 들어온 사용자가 새 비밀번호를 설정하는 페이지입니다.
|
||||||
|
* - 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-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/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-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
|
<KeyRound className="h-7 w-7 text-white" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
app/(auth)/signup/page.tsx
Normal file
54
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
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";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
|
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-brand-200/30 bg-white/80 shadow-2xl shadow-brand-500/5 backdrop-blur-xl dark:border-brand-800/30 dark:bg-brand-950/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-brand-500 to-brand-700 shadow-lg shadow-brand-500/25">
|
||||||
|
<UserPlus className="h-7 w-7 text-white" />
|
||||||
|
</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-muted-foreground">
|
||||||
|
이미 계정이 있으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href={AUTH_ROUTES.LOGIN}
|
||||||
|
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
로그인 하러 가기
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
352
app/(home)/page.tsx
Normal file
352
app/(home)/page.tsx
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(home)/page.tsx
|
||||||
|
* @description 서비스 메인 랜딩 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowRight,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
TrendingUp,
|
||||||
|
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";
|
||||||
|
|
||||||
|
interface ValuePoint {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureItem {
|
||||||
|
icon: LucideIcon;
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartStep {
|
||||||
|
step: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALUE_POINTS: ValuePoint[] = [
|
||||||
|
{
|
||||||
|
value: "3분",
|
||||||
|
label: "초기 세팅 시간",
|
||||||
|
detail: "복잡한 설정 대신 질문 기반으로 빠르게 시작합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "24시간",
|
||||||
|
label: "실시간 시장 관제",
|
||||||
|
detail: "자리 비운 시간에도 규칙 기반으로 자동 대응합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "0원",
|
||||||
|
label: "가입 시작 비용",
|
||||||
|
detail: "부담 없이 계정 생성 후 내 투자 스타일부터 점검합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const FEATURE_ITEMS: FeatureItem[] = [
|
||||||
|
{
|
||||||
|
icon: ShieldCheck,
|
||||||
|
eyebrow: "Risk Guard",
|
||||||
|
title: "손실 방어를 먼저 설계합니다",
|
||||||
|
description:
|
||||||
|
"진입보다 중요한 것은 방어입니다. 손절 기준과 노출 한도를 먼저 세워 감정 개입을 줄입니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
eyebrow: "Data Momentum",
|
||||||
|
title: "데이터로 타점을 좁힙니다",
|
||||||
|
description:
|
||||||
|
"실시간 데이터 흐름을 읽어 조건이 맞을 때만 실행합니다. 초보도 납득 가능한 근거를 확인할 수 있습니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Zap,
|
||||||
|
eyebrow: "Auto Execution",
|
||||||
|
title: "기회가 오면 즉시 자동 실행합니다",
|
||||||
|
description:
|
||||||
|
"규칙이 충족되면 지연 없이 주문이 실행됩니다. 잠자는 시간과 업무 시간에도 전략은 멈추지 않습니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const START_STEPS: StartStep[] = [
|
||||||
|
{
|
||||||
|
step: "STEP 01",
|
||||||
|
title: "계정 연결",
|
||||||
|
description: "안내에 따라 거래 계정을 안전하게 연결합니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "STEP 02",
|
||||||
|
title: "성향 선택",
|
||||||
|
description: "공격형·균형형·안정형 중 내 스타일을 고릅니다.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
step: "STEP 03",
|
||||||
|
title: "자동 실행 시작",
|
||||||
|
description: "선택한 전략으로 실시간 관제를 바로 시작합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 홈 메인 랜딩 페이지
|
||||||
|
* @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();
|
||||||
|
|
||||||
|
// [CTA 분기] 로그인 여부에 따라 같은 위치에서 다른 행동으로 자연스럽게 전환합니다.
|
||||||
|
const primaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.SIGNUP;
|
||||||
|
const primaryCtaLabel = user ? "대시보드로 전략 실행하기" : "무료로 시작하고 첫 전략 받기";
|
||||||
|
const secondaryCtaHref = user ? AUTH_ROUTES.DASHBOARD : AUTH_ROUTES.LOGIN;
|
||||||
|
const secondaryCtaLabel = user ? "실시간 상태 확인하기" : "기존 계정으로 로그인";
|
||||||
|
|
||||||
|
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 overflow-hidden pt-16">
|
||||||
|
{/* ========== SHADER BACKGROUND SECTION ========== */}
|
||||||
|
<ShaderBackground opacity={1} className="-z-20" />
|
||||||
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 bg-black/45" />
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -left-40 top-40 -z-10 h-96 w-96 rounded-full bg-brand-500/20 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute -right-24 top-72 -z-10 h-[26rem] w-[26rem] rounded-full bg-brand-300/20 blur-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ========== HERO SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-7xl px-4 pb-14 pt-14 md:pt-24">
|
||||||
|
<div className="grid gap-8 lg:grid-cols-12 lg:gap-10">
|
||||||
|
<div className="lg:col-span-7">
|
||||||
|
<span className="inline-flex animate-in fade-in-0 items-center gap-2 rounded-full border border-brand-400/40 bg-brand-500/15 px-4 py-1.5 text-xs font-semibold tracking-wide text-brand-100 backdrop-blur-md duration-700">
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
초보 투자자를 위한 전략 자동매매 파트너
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<h1 className="mt-5 animate-in slide-in-from-bottom-4 text-4xl font-black tracking-tight text-white [text-shadow:0_6px_40px_rgba(0,0,0,0.55)] duration-700 md:text-6xl">
|
||||||
|
감이 아닌 전략으로,
|
||||||
|
<br />
|
||||||
|
<span className="bg-linear-to-r from-brand-100 via-brand-300 to-brand-500 bg-clip-text text-transparent">
|
||||||
|
내 계좌의 성장 루틴을 만드세요
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-5 max-w-2xl animate-in slide-in-from-bottom-4 text-sm leading-relaxed text-white/80 duration-700 md:text-lg">
|
||||||
|
주린이는 보호와 성장의 균형을 먼저 설계합니다.
|
||||||
|
<br />
|
||||||
|
손실 방어 규칙, 데이터 신호, 자동 실행을 하나의 흐름으로 묶어 초보의 첫 수익 루틴을 만듭니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 flex animate-in flex-col gap-3 duration-700 sm:flex-row">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="group h-12 rounded-full bg-linear-to-r from-brand-500 via-brand-400 to-brand-600 px-8 text-base font-semibold text-white shadow-2xl shadow-brand-800/45 [background-size:200%_200%] animate-gradient-x hover:brightness-110"
|
||||||
|
>
|
||||||
|
<Link href={primaryCtaHref}>
|
||||||
|
{primaryCtaLabel}
|
||||||
|
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-12 rounded-full border-white/35 bg-black/30 px-8 text-base text-white backdrop-blur-md hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<Link href={secondaryCtaHref}>{secondaryCtaLabel}</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3 text-xs text-white/70">
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
가입 3분
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
카드 등록 없음
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-white/20 bg-white/5 px-3 py-1 backdrop-blur-sm">
|
||||||
|
언제든 전략 변경 가능
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative lg:col-span-5">
|
||||||
|
<div className="absolute -inset-px rounded-3xl bg-linear-to-br from-brand-300/50 via-brand-600/0 to-brand-600/60 blur-lg" />
|
||||||
|
<div className="relative overflow-hidden rounded-3xl border border-white/15 bg-black/35 p-6 shadow-[0_30px_90px_-45px_rgba(0,0,0,0.85)] backdrop-blur-2xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-brand-200">LIVE STRATEGY STATUS</p>
|
||||||
|
<p className="mt-1 text-lg font-semibold text-white">실시간 자동매매 관제</p>
|
||||||
|
</div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-brand-500/25 px-3 py-1 text-xs font-semibold text-brand-100">
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
안정 운영중
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* [신뢰 포인트] UI 안에서 가치 제안을 한눈에 보여 주기 위해 핵심 상태를 카드형으로 배치합니다. */}
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">리스크 룰 적용</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">손실 제한 + 분할 대응 활성화</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">데이터 신호 감시</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">시장 흐름 조건 충족 시 자동 진입</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
|
||||||
|
<p className="text-xs text-white/65">실행 상태</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-white">체결 지연 최소화, 규칙 기반 지속 운영</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-2 text-xs text-brand-100/90">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full bg-brand-300" />
|
||||||
|
당신의 전략은 설정한 원칙 안에서만 실행됩니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-4 md:grid-cols-3">
|
||||||
|
{VALUE_POINTS.map((point) => (
|
||||||
|
<div
|
||||||
|
key={point.label}
|
||||||
|
className="rounded-2xl border border-white/10 bg-white/5 p-6 backdrop-blur-md transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<p className="text-2xl font-black text-brand-100">{point.value}</p>
|
||||||
|
<p className="mt-2 text-sm font-semibold text-white">{point.label}</p>
|
||||||
|
<p className="mt-2 text-xs leading-relaxed text-white/70">{point.detail}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== FEATURE SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-7xl px-4 pb-16 md:pb-24">
|
||||||
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
|
<p className="text-xs font-semibold tracking-widest text-brand-200">WHY JURINI</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
|
초보에게 필요한 건 복잡함이 아니라
|
||||||
|
<br />
|
||||||
|
<span className="text-brand-200">기준이 분명한 자동매매</span>입니다
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-6 md:grid-cols-3">
|
||||||
|
{FEATURE_ITEMS.map((feature) => {
|
||||||
|
const FeatureIcon = feature.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={feature.title}
|
||||||
|
className="group border-white/10 bg-black/25 text-white shadow-none backdrop-blur-md transition-all hover:-translate-y-1 hover:border-brand-400/40 hover:bg-black/35"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="mb-2 inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-500/20 text-brand-200 transition-transform group-hover:scale-110">
|
||||||
|
<FeatureIcon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-brand-200">{feature.eyebrow}</p>
|
||||||
|
<CardTitle className="text-xl leading-snug text-white">{feature.title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="text-sm leading-relaxed text-white/75">
|
||||||
|
{feature.description}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== START STEP SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-7xl px-4 pb-16">
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-xl md:p-10">
|
||||||
|
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-widest text-brand-200">GET STARTED</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
|
가입부터 자동 실행까지
|
||||||
|
<br />
|
||||||
|
<span className="text-brand-200">딱 3단계면 충분합니다</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-sm leading-relaxed text-white/70">
|
||||||
|
어려운 설정 화면 대신, 따라 하기 쉬운 단계로 바로 시작할 수 있게 구성했습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
||||||
|
{START_STEPS.map((item) => (
|
||||||
|
<div key={item.step} className="rounded-2xl border border-white/10 bg-black/30 p-5">
|
||||||
|
<p className="text-xs font-semibold tracking-wide text-brand-200">{item.step}</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-white">{item.title}</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-white/70">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== CTA SECTION ========== */}
|
||||||
|
<section className="container mx-auto max-w-7xl px-4 pb-20">
|
||||||
|
<div className="relative overflow-hidden rounded-3xl border border-brand-300/30 bg-linear-to-r from-brand-600/30 via-brand-500/20 to-brand-700/30 p-8 backdrop-blur-xl md:p-12">
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-brand-200/25 blur-3xl"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute -bottom-24 -left-16 h-72 w-72 rounded-full bg-brand-700/30 blur-3xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center justify-between gap-8 text-center md:flex-row md:text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-brand-100">수익의 시작은 빠를수록 좋습니다</p>
|
||||||
|
<h2 className="mt-2 text-3xl font-black tracking-tight text-white md:text-4xl">
|
||||||
|
지금 가입하고,
|
||||||
|
<br />
|
||||||
|
내 전략을 오늘부터 자동 실행하세요
|
||||||
|
</h2>
|
||||||
|
<p className="mt-3 text-sm text-white/75 md:text-base">
|
||||||
|
주린이가 첫 설정부터 실행까지 함께 안내합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
size="lg"
|
||||||
|
className="group h-14 rounded-full bg-white px-9 text-lg font-bold text-brand-700 shadow-xl shadow-black/35 hover:bg-white/90"
|
||||||
|
>
|
||||||
|
<Link href={primaryCtaHref}>
|
||||||
|
지금 시작하기
|
||||||
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
app/(main)/dashboard/page.tsx
Normal file
25
app/(main)/dashboard/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(main)/dashboard/page.tsx
|
||||||
|
* @description 로그인 사용자 전용 대시보드 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { DashboardContainer } from "@/features/dashboard/components/DashboardContainer";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 페이지
|
||||||
|
* @returns DashboardContainer UI
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 상태 헤더/지수/보유종목 UI를 제공합니다.
|
||||||
|
*/
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return <DashboardContainer />;
|
||||||
|
}
|
||||||
25
app/(main)/layout.tsx
Normal file
25
app/(main)/layout.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Header } from "@/features/layout/components/header";
|
||||||
|
import { MobileBottomNav, 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-background">
|
||||||
|
<Header user={user} />
|
||||||
|
<div className="flex flex-1 pt-16">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="min-w-0 flex-1 pb-20 md:pb-0">{children}</main>
|
||||||
|
</div>
|
||||||
|
<MobileBottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
app/(main)/settings/page.tsx
Normal file
26
app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(main)/settings/page.tsx
|
||||||
|
* @description 로그인 사용자 전용 설정 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { SettingsContainer } from "@/features/settings/components/SettingsContainer";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 페이지
|
||||||
|
* @returns SettingsContainer UI
|
||||||
|
* @see features/settings/components/SettingsContainer.tsx KIS 인증 설정 UI를 제공합니다.
|
||||||
|
*/
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return <SettingsContainer />;
|
||||||
|
}
|
||||||
|
|
||||||
26
app/(main)/trade/page.tsx
Normal file
26
app/(main)/trade/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @file app/(main)/trade/page.tsx
|
||||||
|
* @description 로그인 사용자 전용 트레이딩 페이지(Server Component)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { TradeContainer } from "@/features/trade/components/TradeContainer";
|
||||||
|
import { createClient } from "@/utils/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 트레이딩 페이지
|
||||||
|
* @returns TradeContainer UI
|
||||||
|
* @see features/trade/components/TradeContainer.tsx 종목 검색/차트/호가/주문 기능을 제공합니다.
|
||||||
|
*/
|
||||||
|
export default async function TradePage() {
|
||||||
|
// 상태 정의: 서버에서 세션을 먼저 확인해 비로그인 접근을 차단합니다.
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
return <TradeContainer />;
|
||||||
|
}
|
||||||
|
|
||||||
45
app/api/kis/domestic/_shared.ts
Normal file
45
app/api/kis/domestic/_shared.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
|
import {
|
||||||
|
normalizeTradingEnv,
|
||||||
|
type KisCredentialInput,
|
||||||
|
} from "@/lib/kis/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 요청 헤더에서 KIS 키를 읽어옵니다.
|
||||||
|
* @param headers 요청 헤더
|
||||||
|
* @returns KIS 인증 입력값
|
||||||
|
* @see app/api/kis/domestic/balance/route.ts 대시보드 잔고 API 인증키 파싱
|
||||||
|
* @see app/api/kis/domestic/indices/route.ts 대시보드 지수 API 인증키 파싱
|
||||||
|
*/
|
||||||
|
export 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 요청 헤더(또는 서버 환경변수)에서 계좌번호(8-2)를 읽어옵니다.
|
||||||
|
* @param headers 요청 헤더
|
||||||
|
* @returns 계좌번호 파트(8 + 2) 또는 null
|
||||||
|
* @see app/api/kis/domestic/balance/route.ts 잔고 조회 시 필수 계좌정보 파싱
|
||||||
|
*/
|
||||||
|
export function readKisAccountParts(headers: Headers) {
|
||||||
|
const headerAccountNo = headers.get("x-kis-account-no");
|
||||||
|
const headerAccountProductCode = headers.get("x-kis-account-product-code");
|
||||||
|
|
||||||
|
const envAccountNo = process.env.KIS_ACCOUNT_NO;
|
||||||
|
const envAccountProductCode = process.env.KIS_ACCOUNT_PRODUCT_CODE;
|
||||||
|
|
||||||
|
return (
|
||||||
|
parseKisAccountParts(headerAccountNo, headerAccountProductCode) ??
|
||||||
|
parseKisAccountParts(envAccountNo, envAccountProductCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/api/kis/domestic/activity/route.ts
Normal file
69
app/api/kis/domestic/activity/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { DashboardActivityResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import { getDomesticDashboardActivity } from "@/lib/kis/dashboard";
|
||||||
|
import {
|
||||||
|
readKisAccountParts,
|
||||||
|
readKisCredentialsFromHeaders,
|
||||||
|
} from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/domestic/activity/route.ts
|
||||||
|
* @description 국내주식 주문내역/매매일지 조회 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 하단(주문내역/매매일지) 조회 API
|
||||||
|
* @returns 주문내역 목록 + 매매일지 목록/요약
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> useDashboardData -> /api/kis/domestic/activity -> ActivitySection 렌더링
|
||||||
|
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||||
|
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 섹션 렌더링
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "KIS API 키 설정이 필요합니다.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = readKisAccountParts(request.headers);
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getDomesticDashboardActivity(account, credentials);
|
||||||
|
const response: DashboardActivityResponse = {
|
||||||
|
source: "kis",
|
||||||
|
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||||
|
orders: result.orders,
|
||||||
|
tradeJournal: result.tradeJournal,
|
||||||
|
journalSummary: result.journalSummary,
|
||||||
|
warnings: result.warnings,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
65
app/api/kis/domestic/balance/route.ts
Normal file
65
app/api/kis/domestic/balance/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { DashboardBalanceResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import { getDomesticDashboardBalance } from "@/lib/kis/dashboard";
|
||||||
|
import {
|
||||||
|
readKisAccountParts,
|
||||||
|
readKisCredentialsFromHeaders,
|
||||||
|
} from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/domestic/balance/route.ts
|
||||||
|
* @description 국내주식 계좌 잔고/보유종목 조회 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 잔고 조회 API
|
||||||
|
* @returns 총자산/손익/보유종목 목록
|
||||||
|
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/새로고침에서 호출합니다.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "KIS API 키 설정이 필요합니다.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = readKisAccountParts(request.headers);
|
||||||
|
if (!account) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
"계좌번호가 필요합니다. 설정에서 계좌번호(예: 12345678-01)를 입력해 주세요.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await getDomesticDashboardBalance(account, credentials);
|
||||||
|
const response: DashboardBalanceResponse = {
|
||||||
|
source: "kis",
|
||||||
|
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||||
|
summary: result.summary,
|
||||||
|
holdings: result.holdings,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/api/kis/domestic/chart/route.ts
Normal file
98
app/api/kis/domestic/chart/route.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type {
|
||||||
|
DashboardChartTimeframe,
|
||||||
|
DashboardStockChartResponse,
|
||||||
|
} from "@/features/trade/types/trade.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
app/api/kis/domestic/indices/route.ts
Normal file
50
app/api/kis/domestic/indices/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { DashboardIndicesResponse } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { hasKisConfig, normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import { getDomesticDashboardIndices } from "@/lib/kis/dashboard";
|
||||||
|
import { readKisCredentialsFromHeaders } from "@/app/api/kis/domestic/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/domestic/indices/route.ts
|
||||||
|
* @description 국내 주요 지수(KOSPI/KOSDAQ) 조회 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 지수 조회 API
|
||||||
|
* @returns 코스피/코스닥 지수 목록
|
||||||
|
* @see features/dashboard/hooks/use-dashboard-data.ts 대시보드 초기 로드/주기 갱신에서 호출합니다.
|
||||||
|
*/
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const credentials = readKisCredentialsFromHeaders(request.headers);
|
||||||
|
|
||||||
|
if (!hasKisConfig(credentials)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: "KIS API 키 설정이 필요합니다.",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const items = await getDomesticDashboardIndices(credentials);
|
||||||
|
const response: DashboardIndicesResponse = {
|
||||||
|
source: "kis",
|
||||||
|
tradingEnv: normalizeTradingEnv(credentials.tradingEnv),
|
||||||
|
items,
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
app/api/kis/domestic/order-cash/route.ts
Normal file
104
app/api/kis/domestic/order-cash/route.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { executeOrderCash } from "@/lib/kis/trade";
|
||||||
|
import {
|
||||||
|
DashboardStockCashOrderRequest,
|
||||||
|
DashboardStockCashOrderResponse,
|
||||||
|
} from "@/features/trade/types/trade.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
165
app/api/kis/domestic/orderbook/route.ts
Normal file
165
app/api/kis/domestic/orderbook/route.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
getDomesticOrderBook,
|
||||||
|
KisDomesticOrderBookOutput,
|
||||||
|
} from "@/lib/kis/domestic";
|
||||||
|
import { DashboardStockOrderBookResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import {
|
||||||
|
KisCredentialInput,
|
||||||
|
hasKisConfig,
|
||||||
|
normalizeTradingEnv,
|
||||||
|
} from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||||
|
parseDomesticKisSession,
|
||||||
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||||
|
const raw = await getDomesticOrderBook(symbol, credentials, {
|
||||||
|
sessionOverride,
|
||||||
|
});
|
||||||
|
|
||||||
|
const levels = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const idx = i + 1;
|
||||||
|
return {
|
||||||
|
askPrice: readOrderBookNumber(raw, `askp${idx}`, `ovtm_untp_askp${idx}`),
|
||||||
|
bidPrice: readOrderBookNumber(raw, `bidp${idx}`, `ovtm_untp_bidp${idx}`),
|
||||||
|
askSize: readOrderBookNumber(
|
||||||
|
raw,
|
||||||
|
`askp_rsqn${idx}`,
|
||||||
|
`ovtm_untp_askp_rsqn${idx}`,
|
||||||
|
),
|
||||||
|
bidSize: readOrderBookNumber(raw, ...resolveBidSizeKeys(idx)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: DashboardStockOrderBookResponse = {
|
||||||
|
symbol,
|
||||||
|
source: "kis",
|
||||||
|
levels,
|
||||||
|
totalAskSize: readOrderBookNumber(
|
||||||
|
raw,
|
||||||
|
"total_askp_rsqn",
|
||||||
|
"ovtm_untp_total_askp_rsqn",
|
||||||
|
"ovtm_total_askp_rsqn",
|
||||||
|
),
|
||||||
|
totalBidSize: readOrderBookNumber(
|
||||||
|
raw,
|
||||||
|
"total_bidp_rsqn",
|
||||||
|
"ovtm_untp_total_bidp_rsqn",
|
||||||
|
"ovtm_total_bidp_rsqn",
|
||||||
|
),
|
||||||
|
businessHour: readOrderBookString(raw, "bsop_hour", "ovtm_untp_last_hour"),
|
||||||
|
hourClassCode: readOrderBookString(raw, "hour_cls_code"),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||||
|
if (process.env.NODE_ENV === "production") return null;
|
||||||
|
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||||
|
return parseDomesticKisSession(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가 응답 필드를 대소문자 모두 허용해 숫자로 읽습니다.
|
||||||
|
* @see app/api/kis/domestic/orderbook/route.ts GET에서 output/output1 키 차이 방어 로직으로 사용합니다.
|
||||||
|
*/
|
||||||
|
function readOrderBookNumber(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const value = resolveOrderBookValue(record, keys) ?? "0";
|
||||||
|
const normalized =
|
||||||
|
typeof value === "string"
|
||||||
|
? value.replaceAll(",", "").trim()
|
||||||
|
: String(value ?? "0");
|
||||||
|
const parsed = Number(normalized);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 호가 응답 필드를 문자열로 읽습니다.
|
||||||
|
* @see app/api/kis/domestic/orderbook/route.ts GET 응답 생성 시 businessHour/hourClassCode 추출
|
||||||
|
*/
|
||||||
|
function readOrderBookString(raw: KisDomesticOrderBookOutput, ...keys: string[]) {
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
const value = resolveOrderBookValue(record, keys);
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
const text = String(value).trim();
|
||||||
|
return text.length > 0 ? text : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOrderBookValue(record: Record<string, unknown>, keys: string[]) {
|
||||||
|
for (const key of keys) {
|
||||||
|
const direct = record[key];
|
||||||
|
if (direct !== undefined && direct !== null) return direct;
|
||||||
|
|
||||||
|
const upper = record[key.toUpperCase()];
|
||||||
|
if (upper !== undefined && upper !== null) return upper;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveBidSizeKeys(index: number) {
|
||||||
|
if (index === 2) {
|
||||||
|
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`, "ovtm_untp_bidp_rsqn"];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [`bidp_rsqn${index}`, `ovtm_untp_bidp_rsqn${index}`];
|
||||||
|
}
|
||||||
94
app/api/kis/domestic/overview/route.ts
Normal file
94
app/api/kis/domestic/overview/route.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||||
|
import type { DashboardStockOverviewResponse } from "@/features/trade/types/trade.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";
|
||||||
|
import {
|
||||||
|
DOMESTIC_KIS_SESSION_OVERRIDE_HEADER,
|
||||||
|
parseDomesticKisSession,
|
||||||
|
} from "@/lib/kis/domestic-market-session";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 sessionOverride = readSessionOverrideFromHeaders(request.headers);
|
||||||
|
const overview = await getDomesticOverview(
|
||||||
|
symbol,
|
||||||
|
fallbackMeta,
|
||||||
|
credentials,
|
||||||
|
{ sessionOverride },
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSessionOverrideFromHeaders(headers: Headers) {
|
||||||
|
if (process.env.NODE_ENV === "production") return null;
|
||||||
|
const raw = headers.get(DOMESTIC_KIS_SESSION_OVERRIDE_HEADER);
|
||||||
|
return parseDomesticKisSession(raw);
|
||||||
|
}
|
||||||
108
app/api/kis/domestic/search/route.ts
Normal file
108
app/api/kis/domestic/search/route.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { KOREAN_STOCK_INDEX } from "@/features/trade/data/korean-stocks";
|
||||||
|
import type {
|
||||||
|
DashboardStockSearchItem,
|
||||||
|
DashboardStockSearchResponse,
|
||||||
|
KoreanStockIndexItem,
|
||||||
|
} from "@/features/trade/types/trade.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/trade/data/korean-stocks.ts, features/trade/components/dashboard-main.tsx
|
||||||
|
* @author jihoon87.lee
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 국내주식 검색 API
|
||||||
|
* @param request query string의 q(검색어) 사용
|
||||||
|
* @returns 종목 검색 결과 목록
|
||||||
|
* @see features/trade/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;
|
||||||
|
}
|
||||||
58
app/api/kis/revoke/route.ts
Normal file
58
app/api/kis/revoke/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DashboardKisRevokeResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
|
import { revokeKisAccessToken } from "@/lib/kis/token";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/revoke/route.ts
|
||||||
|
* @description 사용자 입력 KIS API 키로 액세스 토큰을 폐기합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 액세스 토큰 폐기
|
||||||
|
* @see features/settings/components/KisAuthForm.tsx
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
|
if (invalidMessage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: invalidMessage,
|
||||||
|
} satisfies DashboardKisRevokeResponse,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const message = await revokeKisAccessToken(credentials);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tradingEnv,
|
||||||
|
message,
|
||||||
|
} satisfies DashboardKisRevokeResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "API 토큰 폐기 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message,
|
||||||
|
} satisfies DashboardKisRevokeResponse,
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
235
app/api/kis/validate-profile/route.ts
Normal file
235
app/api/kis/validate-profile/route.ts
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import type { DashboardKisProfileValidateResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import { parseKisAccountParts } from "@/lib/kis/account";
|
||||||
|
import { kisGet } from "@/lib/kis/client";
|
||||||
|
import { normalizeTradingEnv, type KisCredentialInput } from "@/lib/kis/config";
|
||||||
|
import { validateKisCredentialInput } from "@/lib/kis/request";
|
||||||
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
|
|
||||||
|
interface KisProfileValidateRequestBody {
|
||||||
|
appKey?: string;
|
||||||
|
appSecret?: string;
|
||||||
|
tradingEnv?: string;
|
||||||
|
accountNo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BalanceValidationPreset {
|
||||||
|
inqrDvsn: "01" | "02";
|
||||||
|
prcsDvsn: "00" | "01";
|
||||||
|
}
|
||||||
|
|
||||||
|
const BALANCE_VALIDATION_PRESETS: BalanceValidationPreset[] = [
|
||||||
|
{
|
||||||
|
// 명세 기본 요청값
|
||||||
|
inqrDvsn: "01",
|
||||||
|
prcsDvsn: "01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 일부 계좌/환경 호환값
|
||||||
|
inqrDvsn: "02",
|
||||||
|
prcsDvsn: "00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/validate-profile/route.ts
|
||||||
|
* @description 한국투자증권 계좌번호를 검증합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 앱키/앱시크릿키 + 계좌번호 유효성을 검증합니다.
|
||||||
|
* @remarks UI 흐름: /settings -> KisProfileForm 확인 버튼 -> /api/kis/validate-profile -> store 반영 -> 대시보드 상태 확장
|
||||||
|
* @see features/settings/components/KisProfileForm.tsx 계좌 확인 버튼에서 호출합니다.
|
||||||
|
* @see features/settings/apis/kis-auth.api.ts validateKisProfile 클라이언트 API 함수
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
let body: KisProfileValidateRequestBody = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
body = (await request.json()) as KisProfileValidateRequestBody;
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv: "mock",
|
||||||
|
message: "요청 본문(JSON)을 읽을 수 없습니다.",
|
||||||
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials: KisCredentialInput = {
|
||||||
|
appKey: body.appKey?.trim(),
|
||||||
|
appSecret: body.appSecret?.trim(),
|
||||||
|
tradingEnv: normalizeTradingEnv(body.tradingEnv),
|
||||||
|
};
|
||||||
|
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
|
const invalidCredentialMessage = validateKisCredentialInput(credentials);
|
||||||
|
if (invalidCredentialMessage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: invalidCredentialMessage,
|
||||||
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountNoInput = (body.accountNo ?? "").trim();
|
||||||
|
|
||||||
|
if (!accountNoInput) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: "계좌번호를 입력해 주세요.",
|
||||||
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountParts = parseKisAccountParts(accountNoInput);
|
||||||
|
if (!accountParts) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: "계좌번호 형식이 올바르지 않습니다. 8-2 형식(예: 12345678-01)으로 입력해 주세요.",
|
||||||
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) 토큰 발급으로 앱키/시크릿 사전 검증
|
||||||
|
try {
|
||||||
|
await getKisAccessToken(credentials);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`앱키 검증 실패: ${toErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 계좌 유효성 검증 (실제 계좌 조회 API)
|
||||||
|
try {
|
||||||
|
await validateAccountByBalanceApi(
|
||||||
|
accountParts.accountNo,
|
||||||
|
accountParts.accountProductCode,
|
||||||
|
credentials,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`계좌 검증 실패: ${toErrorMessage(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedAccountNo = `${accountParts.accountNo}-${accountParts.accountProductCode}`;
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tradingEnv,
|
||||||
|
message: "계좌번호 검증이 완료되었습니다.",
|
||||||
|
account: {
|
||||||
|
normalizedAccountNo,
|
||||||
|
},
|
||||||
|
} satisfies DashboardKisProfileValidateResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "계좌 검증 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message,
|
||||||
|
} satisfies Pick<DashboardKisProfileValidateResponse, "ok" | "tradingEnv" | "message">,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 계좌번호로 잔고 조회 API를 호출해 유효성을 확인합니다.
|
||||||
|
* @param accountNo 계좌번호 앞 8자리
|
||||||
|
* @param accountProductCode 계좌번호 뒤 2자리
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @see app/api/kis/validate-profile/route.ts POST
|
||||||
|
*/
|
||||||
|
async function validateAccountByBalanceApi(
|
||||||
|
accountNo: string,
|
||||||
|
accountProductCode: string,
|
||||||
|
credentials: KisCredentialInput,
|
||||||
|
) {
|
||||||
|
const trId = normalizeTradingEnv(credentials.tradingEnv) === "real" ? "TTTC8434R" : "VTTC8434R";
|
||||||
|
const attemptErrors: string[] = [];
|
||||||
|
|
||||||
|
for (const preset of BALANCE_VALIDATION_PRESETS) {
|
||||||
|
try {
|
||||||
|
const response = await kisGet<unknown>(
|
||||||
|
"/uapi/domestic-stock/v1/trading/inquire-balance",
|
||||||
|
trId,
|
||||||
|
{
|
||||||
|
CANO: accountNo,
|
||||||
|
ACNT_PRDT_CD: accountProductCode,
|
||||||
|
AFHR_FLPR_YN: "N",
|
||||||
|
OFL_YN: "",
|
||||||
|
INQR_DVSN: preset.inqrDvsn,
|
||||||
|
UNPR_DVSN: "01",
|
||||||
|
FUND_STTL_ICLD_YN: "N",
|
||||||
|
FNCG_AMT_AUTO_RDPT_YN: "N",
|
||||||
|
PRCS_DVSN: preset.prcsDvsn,
|
||||||
|
CTX_AREA_FK100: "",
|
||||||
|
CTX_AREA_NK100: "",
|
||||||
|
},
|
||||||
|
credentials,
|
||||||
|
);
|
||||||
|
|
||||||
|
validateInquireBalanceResponse(response);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
attemptErrors.push(
|
||||||
|
`INQR_DVSN=${preset.inqrDvsn}, PRCS_DVSN=${preset.prcsDvsn}: ${toErrorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`계좌 확인 요청이 모두 실패했습니다. ${attemptErrors.join(" | ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 주식잔고조회 응답 구조를 최소 검증합니다.
|
||||||
|
* @param response KIS 원본 응답
|
||||||
|
* @see app/api/kis/validate-profile/route.ts validateAccountByBalanceApi
|
||||||
|
*/
|
||||||
|
function validateInquireBalanceResponse(
|
||||||
|
response: {
|
||||||
|
output1?: unknown;
|
||||||
|
output2?: unknown;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const output1Ok =
|
||||||
|
Array.isArray(response.output1) ||
|
||||||
|
(response.output1 !== null && typeof response.output1 === "object");
|
||||||
|
const output2Ok =
|
||||||
|
Array.isArray(response.output2) ||
|
||||||
|
(response.output2 !== null && typeof response.output2 === "object");
|
||||||
|
|
||||||
|
if (!output1Ok && !output2Ok) {
|
||||||
|
throw new Error("응답에 output1/output2가 없습니다. 요청 파라미터를 확인해 주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Error 객체를 사용자 표시용 문자열로 변환합니다.
|
||||||
|
* @param error unknown 에러
|
||||||
|
* @returns 메시지 문자열
|
||||||
|
* @see app/api/kis/validate-profile/route.ts POST
|
||||||
|
*/
|
||||||
|
function toErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "알 수 없는 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
58
app/api/kis/validate/route.ts
Normal file
58
app/api/kis/validate/route.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { DashboardKisValidateResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
|
import { getKisAccessToken } from "@/lib/kis/token";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/validate/route.ts
|
||||||
|
* @description 사용자 입력 KIS API 키를 검증합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 액세스 토큰 발급 성공 여부로 API 키를 검증합니다.
|
||||||
|
* @see features/settings/components/KisAuthForm.tsx
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
|
if (invalidMessage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: invalidMessage,
|
||||||
|
} satisfies DashboardKisValidateResponse,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getKisAccessToken(credentials);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tradingEnv,
|
||||||
|
message: "API 키 검증이 완료되었습니다. (토큰 발급 성공)",
|
||||||
|
} satisfies DashboardKisValidateResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "API 키 검증 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message,
|
||||||
|
} satisfies DashboardKisValidateResponse,
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/api/kis/ws/approval/route.ts
Normal file
61
app/api/kis/ws/approval/route.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { DashboardKisWsApprovalResponse } from "@/features/trade/types/trade.types";
|
||||||
|
import { getKisApprovalKey, resolveKisWebSocketUrl } from "@/lib/kis/approval";
|
||||||
|
import { normalizeTradingEnv } from "@/lib/kis/config";
|
||||||
|
import {
|
||||||
|
parseKisCredentialRequest,
|
||||||
|
validateKisCredentialInput,
|
||||||
|
} from "@/lib/kis/request";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file app/api/kis/ws/approval/route.ts
|
||||||
|
* @description KIS 웹소켓 승인키와 WS URL을 발급합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 실시간 웹소켓 연결 정보를 발급합니다.
|
||||||
|
* @see features/trade/hooks/useKisTradeWebSocket.ts
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const credentials = await parseKisCredentialRequest(request);
|
||||||
|
const tradingEnv = normalizeTradingEnv(credentials.tradingEnv);
|
||||||
|
|
||||||
|
const invalidMessage = validateKisCredentialInput(credentials);
|
||||||
|
if (invalidMessage) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message: invalidMessage,
|
||||||
|
} satisfies DashboardKisWsApprovalResponse,
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const approvalKey = await getKisApprovalKey(credentials);
|
||||||
|
const wsUrl = resolveKisWebSocketUrl(credentials);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tradingEnv,
|
||||||
|
approvalKey,
|
||||||
|
wsUrl,
|
||||||
|
message: "웹소켓 승인키 발급이 완료되었습니다.",
|
||||||
|
} satisfies DashboardKisWsApprovalResponse);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "웹소켓 승인키 발급 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
tradingEnv,
|
||||||
|
message,
|
||||||
|
} satisfies DashboardKisWsApprovalResponse,
|
||||||
|
{ status: 401 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/auth/callback/route.ts
Normal file
121
app/auth/callback/route.ts
Normal 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
84
app/auth/confirm/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
194
app/globals.css
Normal file
194
app/globals.css
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "tailwindcss-animate";
|
||||||
|
@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: var(--brand-50);
|
||||||
|
--color-brand-100: var(--brand-100);
|
||||||
|
--color-brand-200: var(--brand-200);
|
||||||
|
--color-brand-300: var(--brand-300);
|
||||||
|
--color-brand-400: var(--brand-400);
|
||||||
|
--color-brand-500: var(--brand-500);
|
||||||
|
--color-brand-600: var(--brand-600);
|
||||||
|
--color-brand-700: var(--brand-700);
|
||||||
|
--color-brand-800: var(--brand-800);
|
||||||
|
--color-brand-900: var(--brand-900);
|
||||||
|
--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 {
|
||||||
|
/* BRAND PALETTE CONTROL
|
||||||
|
* 이 블록만 수정하면 랜딩/대시보드의 보라 톤이 함께 바뀝니다.
|
||||||
|
*/
|
||||||
|
/* 초기 브랜드 보라값(원본 기준) */
|
||||||
|
--brand-50: oklch(0.97 0.02 294);
|
||||||
|
--brand-100: oklch(0.93 0.05 294);
|
||||||
|
--brand-200: oklch(0.87 0.1 294);
|
||||||
|
--brand-300: oklch(0.79 0.15 294);
|
||||||
|
--brand-400: oklch(0.7 0.2 294);
|
||||||
|
--brand-500: oklch(0.62 0.24 294);
|
||||||
|
--brand-600: oklch(0.56 0.26 294);
|
||||||
|
--brand-700: oklch(0.49 0.24 295);
|
||||||
|
--brand-800: oklch(0.4 0.2 296);
|
||||||
|
--brand-900: oklch(0.33 0.14 297);
|
||||||
|
|
||||||
|
/* 차트(canvas): 봉 하락색은 요청대로 파란색 유지 */
|
||||||
|
--brand-chart-background-light: #ffffff;
|
||||||
|
--brand-chart-background-dark: #17131e;
|
||||||
|
--brand-chart-text-light: #6b21a8;
|
||||||
|
--brand-chart-text-dark: #e9d5ff;
|
||||||
|
--brand-chart-border-light: #e9d5ff;
|
||||||
|
--brand-chart-border-dark: rgba(216, 180, 254, 0.3);
|
||||||
|
--brand-chart-grid-light: #f3e8ff;
|
||||||
|
--brand-chart-grid-dark: rgba(216, 180, 254, 0.14);
|
||||||
|
--brand-chart-crosshair-light: #c084fc;
|
||||||
|
--brand-chart-crosshair-dark: rgba(233, 213, 255, 0.75);
|
||||||
|
|
||||||
|
--brand-chart-background: #ffffff;
|
||||||
|
--brand-chart-down: #2563eb;
|
||||||
|
--brand-chart-volume-down: rgba(37, 99, 235, 0.45);
|
||||||
|
--brand-chart-text: #6b21a8;
|
||||||
|
--brand-chart-border: var(--brand-chart-border-light);
|
||||||
|
--brand-chart-grid: var(--brand-chart-grid-light);
|
||||||
|
--brand-chart-crosshair: var(--brand-chart-crosshair-light);
|
||||||
|
|
||||||
|
--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: var(--brand-600);
|
||||||
|
--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: var(--brand-500);
|
||||||
|
--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: var(--brand-600);
|
||||||
|
--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.17 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.235 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.235 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: var(--brand-600);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.285 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.285 0 0);
|
||||||
|
--muted-foreground: oklch(0.83 0 0);
|
||||||
|
--accent: oklch(0.285 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 18%);
|
||||||
|
--input: oklch(1 0 0 / 22%);
|
||||||
|
--ring: var(--brand-500);
|
||||||
|
--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.235 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: var(--brand-600);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.285 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 18%);
|
||||||
|
--sidebar-ring: oklch(0.78 0 0);
|
||||||
|
|
||||||
|
/* 다크 테마용 차트 배경/격자 대비 */
|
||||||
|
--brand-chart-background: var(--brand-chart-background-dark);
|
||||||
|
--brand-chart-text: var(--brand-chart-text-dark);
|
||||||
|
--brand-chart-border: var(--brand-chart-border-dark);
|
||||||
|
--brand-chart-grid: var(--brand-chart-grid-dark);
|
||||||
|
--brand-chart-crosshair: var(--brand-chart-crosshair-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/layout.tsx
Normal file
77
app/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* @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: "Jurini - 감이 아닌 전략으로 시작하는 자동매매",
|
||||||
|
description:
|
||||||
|
"주린이를 위한 자동매매 파트너 Jurini. 손실 방어 규칙, 데이터 신호 분석, 실시간 자동 실행으로 초보의 첫 수익 루틴을 만듭니다.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
22
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
58
components/form-message.tsx
Normal file
58
components/form-message.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
components/theme-provider.tsx
Normal file
25
components/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
64
components/theme-toggle.tsx
Normal file
64
components/theme-toggle.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* @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";
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
className?: string;
|
||||||
|
iconClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테마 토글 컴포넌트
|
||||||
|
* @remarks next-themes의 useTheme 훅 사용
|
||||||
|
* @returns 단일 클릭으로 라이트/다크를 전환하는 버튼
|
||||||
|
* @see features/layout/components/header.tsx Header 액션 영역 - 사용자 테마 전환 버튼
|
||||||
|
*/
|
||||||
|
export function ThemeToggle({ className, iconClassName }: ThemeToggleProps) {
|
||||||
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
|
const handleToggleTheme = React.useCallback(() => {
|
||||||
|
// 시스템 테마 사용 중에도 현재 화면 기준으로 명확히 라이트/다크를 토글합니다.
|
||||||
|
setTheme(resolvedTheme === "dark" ? "light" : "dark");
|
||||||
|
}, [resolvedTheme, setTheme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={className}
|
||||||
|
onClick={handleToggleTheme}
|
||||||
|
aria-label="테마 전환"
|
||||||
|
>
|
||||||
|
{/* ========== LIGHT ICON ========== */}
|
||||||
|
<Sun
|
||||||
|
className={cn(
|
||||||
|
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||||
|
iconClassName,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{/* ========== DARK ICON ========== */}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
150
components/ui/alert-dialog.tsx
Normal file
150
components/ui/alert-dialog.tsx
Normal 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
109
components/ui/avatar.tsx
Normal 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
48
components/ui/badge.tsx
Normal 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
64
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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,
|
||||||
|
}
|
||||||
32
components/ui/checkbox.tsx
Normal file
32
components/ui/checkbox.tsx
Normal 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 }
|
||||||
257
components/ui/dropdown-menu.tsx
Normal file
257
components/ui/dropdown-menu.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
24
components/ui/label.tsx
Normal 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 }
|
||||||
109
components/ui/loading-spinner.tsx
Normal file
109
components/ui/loading-spinner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
components/ui/scroll-area.tsx
Normal file
58
components/ui/scroll-area.tsx
Normal 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 }
|
||||||
28
components/ui/separator.tsx
Normal file
28
components/ui/separator.tsx
Normal 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 }
|
||||||
244
components/ui/shader-background.tsx
Normal file
244
components/ui/shader-background.tsx
Normal 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;
|
||||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal 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
91
components/ui/tabs.tsx
Normal 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 }
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal 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
428
features/auth/actions.ts
Normal 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" } });
|
||||||
|
}
|
||||||
214
features/auth/components/login-form.tsx
Normal file
214
features/auth/components/login-form.tsx
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
"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);
|
||||||
|
|
||||||
|
// ========== 폼 제출 핸들러 ==========
|
||||||
|
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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
</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-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
비밀번호 찾기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 로그인 버튼 ========== */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="h-11 w-full bg-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<InlineSpinner />
|
||||||
|
로그인 중...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"로그인"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* ========== 회원가입 링크 ========== */}
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
계정이 없으신가요?{" "}
|
||||||
|
<Link
|
||||||
|
href={AUTH_ROUTES.SIGNUP}
|
||||||
|
className="font-semibold text-brand-600 transition-colors hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300"
|
||||||
|
>
|
||||||
|
회원가입 하기
|
||||||
|
</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-muted-foreground dark:bg-brand-950">
|
||||||
|
또는 소셜 로그인
|
||||||
|
</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-brand-200/50 bg-white shadow-sm transition-all duration-200 hover:bg-brand-50 hover:shadow-md dark:border-brand-800/50 dark:bg-brand-950/50 dark:hover:bg-brand-900/50"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
features/auth/components/reset-password-form.tsx
Normal file
144
features/auth/components/reset-password-form.tsx
Normal 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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
{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-brand-600 dark:text-brand-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-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<InlineSpinner />
|
||||||
|
변경 중...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"비밀번호 변경"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
171
features/auth/components/session-manager.tsx
Normal file
171
features/auth/components/session-manager.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
const SESSION_RELATED_STORAGE_KEYS = [
|
||||||
|
"session-storage",
|
||||||
|
"auth-storage",
|
||||||
|
"autotrade-kis-runtime-store",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세션 관리자 컴포넌트
|
||||||
|
* 사용자 활동을 감지하여 세션 연장 및 타임아웃 처리
|
||||||
|
* @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();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 세션 만료 로그아웃 시 세션 관련 로컬 스토리지를 정리합니다.
|
||||||
|
* @see features/layout/components/user-menu.tsx 수동 로그아웃 경로에서도 동일한 키를 제거합니다.
|
||||||
|
*/
|
||||||
|
const clearSessionRelatedStorage = useCallback(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로그아웃 처리 핸들러
|
||||||
|
* @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();
|
||||||
|
clearSessionRelatedStorage();
|
||||||
|
|
||||||
|
// [Step 4] 로그인 페이지로 리다이렉트 및 메시지 표시
|
||||||
|
router.push("/login?message=세션이 만료되었습니다. 다시 로그인해주세요.");
|
||||||
|
router.refresh();
|
||||||
|
}, [clearSessionRelatedStorage, router]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthPage) return;
|
||||||
|
|
||||||
|
// 마지막 활동 시간 업데이트 함수
|
||||||
|
const updateLastActive = () => {
|
||||||
|
setLastActive(Date.now());
|
||||||
|
if (showWarning) setShowWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// [Step 0] 인증 페이지에서 메인 페이지로 진입한 직후를 "활동"으로 간주해
|
||||||
|
// 이전 세션 잔여 시간(예: 00:00)으로 즉시 로그아웃되는 현상을 방지합니다.
|
||||||
|
updateLastActive();
|
||||||
|
|
||||||
|
// [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>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
features/auth/components/session-timer.tsx
Normal file
79
features/auth/components/session-timer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
175
features/auth/components/signup-form.tsx
Normal file
175
features/auth/components/signup-form.tsx
Normal 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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
{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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
최소 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 focus-visible:ring-brand-500"
|
||||||
|
/>
|
||||||
|
{/* 비밀번호 불일치 시 실시간 피드백 */}
|
||||||
|
{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-brand-600 dark:text-brand-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-linear-to-r from-brand-500 to-brand-700 font-semibold text-white shadow-lg shadow-brand-500/20 transition-all duration-200 hover:from-brand-600 hover:to-brand-800 hover:shadow-xl hover:shadow-brand-500/25"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<InlineSpinner />
|
||||||
|
회원가입 중...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"회원가입 완료"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
257
features/auth/constants.ts
Normal file
257
features/auth/constants.ts
Normal 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
30
features/auth/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
69
features/auth/schemas/auth-schema.ts
Normal file
69
features/auth/schemas/auth-schema.ts
Normal 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>;
|
||||||
115
features/dashboard/apis/dashboard.api.ts
Normal file
115
features/dashboard/apis/dashboard.api.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import type {
|
||||||
|
DashboardActivityResponse,
|
||||||
|
DashboardBalanceResponse,
|
||||||
|
DashboardIndicesResponse,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file features/dashboard/apis/dashboard.api.ts
|
||||||
|
* @description 대시보드 잔고/지수 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계좌 잔고/보유종목을 조회합니다.
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @returns 잔고 응답
|
||||||
|
* @see app/api/kis/domestic/balance/route.ts 서버 라우트
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardBalance(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardBalanceResponse> {
|
||||||
|
const response = await fetch("/api/kis/domestic/balance", {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildKisRequestHeaders(credentials),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as
|
||||||
|
| DashboardBalanceResponse
|
||||||
|
| { error?: string };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"error" in payload ? payload.error : "잔고 조회 중 오류가 발생했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as DashboardBalanceResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시장 지수(KOSPI/KOSDAQ)를 조회합니다.
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @returns 지수 응답
|
||||||
|
* @see app/api/kis/domestic/indices/route.ts 서버 라우트
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardIndices(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardIndicesResponse> {
|
||||||
|
const response = await fetch("/api/kis/domestic/indices", {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildKisRequestHeaders(credentials),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as
|
||||||
|
| DashboardIndicesResponse
|
||||||
|
| { error?: string };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"error" in payload ? payload.error : "지수 조회 중 오류가 발생했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as DashboardIndicesResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문내역/매매일지(활동 데이터)를 조회합니다.
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @returns 활동 데이터 응답
|
||||||
|
* @see app/api/kis/domestic/activity/route.ts 서버 라우트
|
||||||
|
*/
|
||||||
|
export async function fetchDashboardActivity(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardActivityResponse> {
|
||||||
|
const response = await fetch("/api/kis/domestic/activity", {
|
||||||
|
method: "GET",
|
||||||
|
headers: buildKisRequestHeaders(credentials),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as
|
||||||
|
| DashboardActivityResponse
|
||||||
|
| { error?: string };
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
"error" in payload ? payload.error : "활동 데이터 조회 중 오류가 발생했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload as DashboardActivityResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 API 공통 헤더를 구성합니다.
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @returns KIS 전달 헤더
|
||||||
|
* @see features/dashboard/apis/dashboard.api.ts fetchDashboardBalance/fetchDashboardIndices
|
||||||
|
*/
|
||||||
|
function buildKisRequestHeaders(credentials: KisRuntimeCredentials) {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"x-kis-app-key": credentials.appKey,
|
||||||
|
"x-kis-app-secret": credentials.appSecret,
|
||||||
|
"x-kis-trading-env": credentials.tradingEnv,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (credentials.accountNo?.trim()) {
|
||||||
|
headers["x-kis-account-no"] = credentials.accountNo.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
331
features/dashboard/components/ActivitySection.tsx
Normal file
331
features/dashboard/components/ActivitySection.tsx
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
import { AlertCircle, ClipboardList, FileText, RefreshCcw } from "lucide-react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import type {
|
||||||
|
DashboardActivityResponse,
|
||||||
|
DashboardTradeSide,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercent,
|
||||||
|
getChangeToneClass,
|
||||||
|
} from "@/features/dashboard/utils/dashboard-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ActivitySectionProps {
|
||||||
|
activity: DashboardActivityResponse | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 하단 주문내역/매매일지 섹션입니다.
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> ActivitySection -> tabs(주문내역/매매일지) -> 리스트 렌더링
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 하단 영역에서 호출합니다.
|
||||||
|
* @see app/api/kis/domestic/activity/route.ts 주문내역/매매일지 데이터 소스
|
||||||
|
*/
|
||||||
|
export function ActivitySection({
|
||||||
|
activity,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: ActivitySectionProps) {
|
||||||
|
const orders = activity?.orders ?? [];
|
||||||
|
const journalRows = activity?.tradeJournal ?? [];
|
||||||
|
const summary = activity?.journalSummary;
|
||||||
|
const warnings = activity?.warnings ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
{/* ========== TITLE ========== */}
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ClipboardList className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
주문내역 · 매매일지
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
최근 주문 체결 내역과 실현손익 기록을 확인합니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{isLoading && !activity && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
주문내역/매매일지를 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||||
|
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||||
|
주문/매매일지 API는 장중 혼잡 시간에 간헐적 실패가 발생할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
주문/매매일지 다시 불러오기
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<Badge
|
||||||
|
key={warning}
|
||||||
|
variant="outline"
|
||||||
|
className="border-amber-300 bg-amber-50 text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3" />
|
||||||
|
{warning}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== TABS ========== */}
|
||||||
|
<Tabs defaultValue="orders" className="gap-3">
|
||||||
|
<TabsList className="w-full justify-start">
|
||||||
|
<TabsTrigger value="orders">주문내역 {orders.length}건</TabsTrigger>
|
||||||
|
<TabsTrigger value="journal">매매일지 {journalRows.length}건</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="orders">
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/70">
|
||||||
|
<div className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span>일시</span>
|
||||||
|
<span>종목</span>
|
||||||
|
<span>주문</span>
|
||||||
|
<span>체결</span>
|
||||||
|
<span>평균체결가</span>
|
||||||
|
<span>상태</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[280px]">
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
표시할 주문내역이 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{orders.map((order) => (
|
||||||
|
<div
|
||||||
|
key={`${order.orderNo}-${order.orderDate}-${order.orderTime}`}
|
||||||
|
className="grid grid-cols-[100px_1fr_140px_120px_120px_90px] items-center gap-2 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{/* ========== ORDER DATETIME ========== */}
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<p>{order.orderDate}</p>
|
||||||
|
<p>{order.orderTime}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== STOCK INFO ========== */}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{order.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{order.symbol} · {getSideLabel(order.side)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== ORDER INFO ========== */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<p>수량 {order.orderQuantity.toLocaleString("ko-KR")}주</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{order.orderTypeName} · {formatCurrency(order.orderPrice)}원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== FILLED INFO ========== */}
|
||||||
|
<div className="text-xs">
|
||||||
|
<p>체결 {order.filledQuantity.toLocaleString("ko-KR")}주</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
금액 {formatCurrency(order.filledAmount)}원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== AVG PRICE ========== */}
|
||||||
|
<div className="text-xs font-medium text-foreground">
|
||||||
|
{formatCurrency(order.averageFilledPrice)}원
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== STATUS ========== */}
|
||||||
|
<div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"text-[11px]",
|
||||||
|
order.isCanceled
|
||||||
|
? "border-slate-300 text-slate-600 dark:border-slate-700 dark:text-slate-300"
|
||||||
|
: order.remainingQuantity > 0
|
||||||
|
? "border-amber-300 text-amber-700 dark:border-amber-700 dark:text-amber-300"
|
||||||
|
: "border-emerald-300 text-emerald-700 dark:border-emerald-700 dark:text-emerald-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{order.isCanceled
|
||||||
|
? "취소"
|
||||||
|
: order.remainingQuantity > 0
|
||||||
|
? "미체결"
|
||||||
|
: "체결완료"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="journal" className="space-y-3">
|
||||||
|
{/* ========== JOURNAL SUMMARY ========== */}
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<SummaryMetric
|
||||||
|
label="총 실현손익"
|
||||||
|
value={summary ? `${formatCurrency(summary.totalRealizedProfit)}원` : "-"}
|
||||||
|
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryMetric
|
||||||
|
label="총 수익률"
|
||||||
|
value={summary ? formatPercent(summary.totalRealizedRate) : "-"}
|
||||||
|
toneClass={summary ? getChangeToneClass(summary.totalRealizedProfit) : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryMetric
|
||||||
|
label="총 매수금액"
|
||||||
|
value={summary ? `${formatCurrency(summary.totalBuyAmount)}원` : "-"}
|
||||||
|
/>
|
||||||
|
<SummaryMetric
|
||||||
|
label="총 매도금액"
|
||||||
|
value={summary ? `${formatCurrency(summary.totalSellAmount)}원` : "-"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-border/70">
|
||||||
|
<div className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] gap-2 bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span>일자</span>
|
||||||
|
<span>종목</span>
|
||||||
|
<span>매매구분</span>
|
||||||
|
<span>매수/매도금액</span>
|
||||||
|
<span>실현손익(률)</span>
|
||||||
|
<span>비용</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-[280px]">
|
||||||
|
{journalRows.length === 0 ? (
|
||||||
|
<p className="px-3 py-4 text-sm text-muted-foreground">
|
||||||
|
표시할 매매일지가 없습니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border/60">
|
||||||
|
{journalRows.map((row) => {
|
||||||
|
const toneClass = getChangeToneClass(row.realizedProfit);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${row.tradeDate}-${row.symbol}-${row.realizedProfit}-${row.buyAmount}-${row.sellAmount}`}
|
||||||
|
className="grid grid-cols-[100px_1fr_120px_130px_120px_110px] items-center gap-2 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.tradeDate}</p>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">{row.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{row.symbol}</p>
|
||||||
|
</div>
|
||||||
|
<p className={cn("text-xs font-medium", getSideToneClass(row.side))}>
|
||||||
|
{getSideLabel(row.side)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
매수 {formatCurrency(row.buyAmount)}원 / 매도 {formatCurrency(row.sellAmount)}원
|
||||||
|
</p>
|
||||||
|
<p className={cn("text-xs font-medium", toneClass)}>
|
||||||
|
{formatCurrency(row.realizedProfit)}원 ({formatPercent(row.realizedRate)})
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
수수료 {formatCurrency(row.fee)}원
|
||||||
|
<br />
|
||||||
|
세금 {formatCurrency(row.tax)}원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{!isLoading && !error && !activity && (
|
||||||
|
<p className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
활동 데이터가 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryMetricProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
toneClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 매매일지 요약 지표 카드입니다.
|
||||||
|
* @param label 지표명
|
||||||
|
* @param value 지표값
|
||||||
|
* @param toneClass 값 색상 클래스
|
||||||
|
* @see features/dashboard/components/ActivitySection.tsx 매매일지 상단 요약 표시
|
||||||
|
*/
|
||||||
|
function SummaryMetric({ label, value, toneClass }: SummaryMetricProps) {
|
||||||
|
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={cn("mt-1 text-sm font-semibold text-foreground", toneClass)}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 매수/매도 라벨 텍스트를 반환합니다.
|
||||||
|
* @param side 매수/매도 구분값
|
||||||
|
* @returns 라벨 문자열
|
||||||
|
* @see features/dashboard/components/ActivitySection.tsx 주문내역/매매일지 표시
|
||||||
|
*/
|
||||||
|
function getSideLabel(side: DashboardTradeSide) {
|
||||||
|
if (side === "buy") return "매수";
|
||||||
|
if (side === "sell") return "매도";
|
||||||
|
return "기타";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 매수/매도 라벨 색상 클래스를 반환합니다.
|
||||||
|
* @param side 매수/매도 구분값
|
||||||
|
* @returns Tailwind 텍스트 클래스
|
||||||
|
* @see features/dashboard/components/ActivitySection.tsx 매매구분 표시
|
||||||
|
*/
|
||||||
|
function getSideToneClass(side: DashboardTradeSide) {
|
||||||
|
if (side === "buy") return "text-red-600 dark:text-red-400";
|
||||||
|
if (side === "sell") return "text-blue-600 dark:text-blue-400";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
36
features/dashboard/components/DashboardAccessGate.tsx
Normal file
36
features/dashboard/components/DashboardAccessGate.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface DashboardAccessGateProps {
|
||||||
|
canAccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 인증 여부에 따라 대시보드 접근 가이드를 렌더링합니다.
|
||||||
|
* @param canAccess 대시보드 접근 가능 여부
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 인증되지 않은 경우 이 컴포넌트를 렌더링합니다.
|
||||||
|
*/
|
||||||
|
export function DashboardAccessGate({ canAccess }: DashboardAccessGateProps) {
|
||||||
|
if (canAccess) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<section className="w-full max-w-xl rounded-2xl border border-brand-200 bg-background p-6 shadow-sm dark:border-brand-800/45 dark:bg-brand-900/18">
|
||||||
|
{/* ========== UNVERIFIED NOTICE ========== */}
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
대시보드를 보려면 한국투자증권 연결이 필요합니다.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
설정 페이지에서 앱키, 앱시크릿키, 계좌번호를 입력하고 연결을 완료해 주세요.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ========== ACTION ========== */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<Button asChild className="bg-brand-600 hover:bg-brand-700">
|
||||||
|
<Link href="/settings">설정 페이지로 이동</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
features/dashboard/components/DashboardContainer.tsx
Normal file
362
features/dashboard/components/DashboardContainer.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { ActivitySection } from "@/features/dashboard/components/ActivitySection";
|
||||||
|
import { DashboardAccessGate } from "@/features/dashboard/components/DashboardAccessGate";
|
||||||
|
import { DashboardSkeleton } from "@/features/dashboard/components/DashboardSkeleton";
|
||||||
|
import { HoldingsList } from "@/features/dashboard/components/HoldingsList";
|
||||||
|
import { MarketSummary } from "@/features/dashboard/components/MarketSummary";
|
||||||
|
import { StatusHeader } from "@/features/dashboard/components/StatusHeader";
|
||||||
|
import { StockDetailPreview } from "@/features/dashboard/components/StockDetailPreview";
|
||||||
|
import { useDashboardData } from "@/features/dashboard/hooks/use-dashboard-data";
|
||||||
|
import { useMarketRealtime } from "@/features/dashboard/hooks/use-market-realtime";
|
||||||
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||||
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import { useHoldingsRealtime } from "@/features/dashboard/hooks/use-holdings-realtime";
|
||||||
|
import type {
|
||||||
|
DashboardBalanceSummary,
|
||||||
|
DashboardHoldingItem,
|
||||||
|
DashboardMarketIndexItem,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import type { KisRealtimeStockTick } from "@/features/dashboard/utils/kis-stock-realtime.utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file DashboardContainer.tsx
|
||||||
|
* @description 대시보드 메인 레이아웃 및 데이터 통합 관리 컴포넌트
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components / Container
|
||||||
|
* - [사용자 행동] 대시보드 진입 -> 전체 자산/시장 지수/보유 종목 확인 -> 특정 종목 선택 상세 확인
|
||||||
|
* - [데이터 흐름] API(REST/WS) -> Hooks(useDashboardData, useMarketRealtime, useHoldingsRealtime) -> UI 병합 -> 하위 컴포넌트 전파
|
||||||
|
* - [연관 파일] use-dashboard-data.ts, use-holdings-realtime.ts, StatusHeader.tsx, HoldingsList.tsx
|
||||||
|
* @author jihoon87.lee
|
||||||
|
*/
|
||||||
|
export function DashboardContainer() {
|
||||||
|
// [Store] KIS 런타임 설정 상태 (인증 여부, 접속 계좌, 웹소켓 정보 등)
|
||||||
|
const {
|
||||||
|
verifiedCredentials,
|
||||||
|
isKisVerified,
|
||||||
|
isKisProfileVerified,
|
||||||
|
verifiedAccountNo,
|
||||||
|
_hasHydrated,
|
||||||
|
wsApprovalKey,
|
||||||
|
wsUrl,
|
||||||
|
} = useKisRuntimeStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
verifiedCredentials: state.verifiedCredentials,
|
||||||
|
isKisVerified: state.isKisVerified,
|
||||||
|
isKisProfileVerified: state.isKisProfileVerified,
|
||||||
|
verifiedAccountNo: state.verifiedAccountNo,
|
||||||
|
_hasHydrated: state._hasHydrated,
|
||||||
|
wsApprovalKey: state.wsApprovalKey,
|
||||||
|
wsUrl: state.wsUrl,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// KIS 접근 가능 여부 판단
|
||||||
|
const canAccess = isKisVerified && Boolean(verifiedCredentials);
|
||||||
|
|
||||||
|
// [Hooks] 기본적인 대시보드 데이터(잔고, 지수, 활동내역) 조회 및 선택 상태 관리
|
||||||
|
// @see use-dashboard-data.ts - 초기 데이터 로딩 및 폴링 처리
|
||||||
|
const {
|
||||||
|
activity,
|
||||||
|
balance,
|
||||||
|
indices: initialIndices,
|
||||||
|
selectedSymbol,
|
||||||
|
setSelectedSymbol,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
activityError,
|
||||||
|
balanceError,
|
||||||
|
indicesError,
|
||||||
|
lastUpdatedAt,
|
||||||
|
refresh,
|
||||||
|
} = useDashboardData(canAccess ? verifiedCredentials : null);
|
||||||
|
|
||||||
|
// [Hooks] 시장 지수(코스피/코스닥) 실시간 웹소켓 데이터 구독
|
||||||
|
// @see use-market-realtime.ts - 웹소켓 연결 및 지수 파싱
|
||||||
|
const { realtimeIndices, isConnected: isWsConnected } = useMarketRealtime(
|
||||||
|
verifiedCredentials,
|
||||||
|
isKisVerified,
|
||||||
|
);
|
||||||
|
|
||||||
|
// [Hooks] 보유 종목 실시간 시세 웹소켓 데이터 구독
|
||||||
|
// @see use-holdings-realtime.ts - 보유 종목 리스트 기반 시세 업데이트
|
||||||
|
const { realtimeData: realtimeHoldings } = useHoldingsRealtime(
|
||||||
|
balance?.holdings ?? [],
|
||||||
|
);
|
||||||
|
const reconnectWebSocket = useKisWebSocketStore((state) => state.reconnect);
|
||||||
|
|
||||||
|
// [Step 1] REST API로 가져온 기본 지수 정보와 실시간 웹소켓 시세 병합
|
||||||
|
const indices = useMemo(() => {
|
||||||
|
if (initialIndices.length === 0) {
|
||||||
|
return buildRealtimeOnlyIndices(realtimeIndices);
|
||||||
|
}
|
||||||
|
|
||||||
|
return initialIndices.map((item) => {
|
||||||
|
const realtime = realtimeIndices[item.code];
|
||||||
|
if (!realtime) return item;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
price: realtime.price,
|
||||||
|
change: realtime.change,
|
||||||
|
changeRate: realtime.changeRate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [initialIndices, realtimeIndices]);
|
||||||
|
|
||||||
|
// [Step 2] 초기 잔고 데이터와 실시간 보유 종목 시세를 병합하여 손익 재계산
|
||||||
|
const mergedHoldings = useMemo(
|
||||||
|
() => mergeHoldingsWithRealtime(balance?.holdings ?? [], realtimeHoldings),
|
||||||
|
[balance?.holdings, realtimeHoldings],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isKisRestConnected = Boolean(
|
||||||
|
(balance && !balanceError) ||
|
||||||
|
(initialIndices.length > 0 && !indicesError) ||
|
||||||
|
(activity && !activityError),
|
||||||
|
);
|
||||||
|
const hasRealtimeStreaming =
|
||||||
|
Object.keys(realtimeIndices).length > 0 ||
|
||||||
|
Object.keys(realtimeHoldings).length > 0;
|
||||||
|
const isRealtimePending = Boolean(
|
||||||
|
wsApprovalKey && wsUrl && isWsConnected && !hasRealtimeStreaming,
|
||||||
|
);
|
||||||
|
const effectiveIndicesError = indices.length === 0 ? indicesError : null;
|
||||||
|
const indicesWarning =
|
||||||
|
indices.length > 0 && indicesError
|
||||||
|
? "지수 API 재요청 중입니다. 화면 값은 실시간/최근 성공 데이터입니다."
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 수동 새로고침 시 REST 조회 + 웹소켓 재연결을 함께 수행합니다.
|
||||||
|
* @remarks UI 흐름: StatusHeader/각 카드 다시 불러오기 버튼 -> handleRefreshAll -> REST 재조회 + WS 완전 종료 후 재연결
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 상단 다시 불러오기 버튼
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts reconnect
|
||||||
|
*/
|
||||||
|
const handleRefreshAll = async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
refresh(),
|
||||||
|
reconnectWebSocket({ refreshApproval: false }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실시간 보유종목 데이터를 기반으로 전체 자산 요약을 계산합니다.
|
||||||
|
* @returns 실시간 요약 데이터 (총자산, 손익, 평가금액 등)
|
||||||
|
*/
|
||||||
|
const mergedSummary = useMemo(
|
||||||
|
() => buildRealtimeSummary(balance?.summary ?? null, mergedHoldings),
|
||||||
|
[balance?.summary, mergedHoldings],
|
||||||
|
);
|
||||||
|
|
||||||
|
// [Step 3] 실시간 병합 데이터에서 현재 선택된 종목 정보를 추출
|
||||||
|
// @see StockDetailPreview.tsx - 선택된 종목의 상세 정보 표시
|
||||||
|
const realtimeSelectedHolding = useMemo(() => {
|
||||||
|
if (!selectedSymbol || mergedHoldings.length === 0) return null;
|
||||||
|
return (
|
||||||
|
mergedHoldings.find((item) => item.symbol === selectedSymbol) ?? null
|
||||||
|
);
|
||||||
|
}, [mergedHoldings, selectedSymbol]);
|
||||||
|
|
||||||
|
// 하이드레이션 이전에는 로딩 스피너 표시
|
||||||
|
if (!_hasHydrated) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-6">
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// KIS 인증이 되지 않은 경우 접근 제한 게이트 표시
|
||||||
|
if (!canAccess) {
|
||||||
|
return <DashboardAccessGate canAccess={canAccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 로딩 중이며 아직 데이터가 없는 경우 스켈레톤 표시
|
||||||
|
if (isLoading && !balance && indices.length === 0) {
|
||||||
|
return <DashboardSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||||
|
{/* ========== 상단 상태 영역: 계좌 연결 정보 및 새로고침 ========== */}
|
||||||
|
<StatusHeader
|
||||||
|
summary={mergedSummary}
|
||||||
|
isKisRestConnected={isKisRestConnected}
|
||||||
|
isWebSocketReady={Boolean(wsApprovalKey && wsUrl && isWsConnected)}
|
||||||
|
isRealtimePending={isRealtimePending}
|
||||||
|
isProfileVerified={isKisProfileVerified}
|
||||||
|
verifiedAccountNo={verifiedAccountNo}
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
lastUpdatedAt={lastUpdatedAt}
|
||||||
|
onRefresh={() => {
|
||||||
|
void handleRefreshAll();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ========== 메인 그리드 구성 ========== */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||||
|
{/* 왼쪽 섹션: 보유 종목 목록 리스트 */}
|
||||||
|
<HoldingsList
|
||||||
|
holdings={mergedHoldings}
|
||||||
|
selectedSymbol={selectedSymbol}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={balanceError}
|
||||||
|
onRetry={() => {
|
||||||
|
void handleRefreshAll();
|
||||||
|
}}
|
||||||
|
onSelect={setSelectedSymbol}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 오른쪽 섹션: 시장 지수 요약 및 선택 종목 상세 정보 */}
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{/* 시장 지수 현황 (코스피/코스닥) */}
|
||||||
|
<MarketSummary
|
||||||
|
items={indices}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={effectiveIndicesError}
|
||||||
|
warning={indicesWarning}
|
||||||
|
isRealtimePending={isRealtimePending}
|
||||||
|
onRetry={() => {
|
||||||
|
void handleRefreshAll();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 선택된 종목의 실시간 상세 요약 정보 */}
|
||||||
|
<StockDetailPreview
|
||||||
|
holding={realtimeSelectedHolding}
|
||||||
|
totalAmount={mergedSummary?.totalAmount ?? 0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 하단 섹션: 최근 매매/충전 활동 내역 ========== */}
|
||||||
|
<ActivitySection
|
||||||
|
activity={activity}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={activityError}
|
||||||
|
onRetry={() => {
|
||||||
|
void handleRefreshAll();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description REST 지수 데이터가 없을 때 실시간 수신값만으로 코스피/코스닥 표시 데이터를 구성합니다.
|
||||||
|
* @param realtimeIndices 실시간 지수 맵
|
||||||
|
* @returns 화면 렌더링용 지수 배열
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> buildRealtimeOnlyIndices -> MarketSummary 렌더링
|
||||||
|
* @see features/dashboard/hooks/use-market-realtime.ts 실시간 지수 수신 훅
|
||||||
|
*/
|
||||||
|
function buildRealtimeOnlyIndices(
|
||||||
|
realtimeIndices: Record<string, { price: number; change: number; changeRate: number }>,
|
||||||
|
) {
|
||||||
|
const baseItems: DashboardMarketIndexItem[] = [
|
||||||
|
{ market: "KOSPI", code: "0001", name: "코스피", price: 0, change: 0, changeRate: 0 },
|
||||||
|
{ market: "KOSDAQ", code: "1001", name: "코스닥", price: 0, change: 0, changeRate: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return baseItems
|
||||||
|
.map((item) => {
|
||||||
|
const realtime = realtimeIndices[item.code];
|
||||||
|
if (!realtime) return null;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
price: realtime.price,
|
||||||
|
change: realtime.change,
|
||||||
|
changeRate: realtime.changeRate,
|
||||||
|
} satisfies DashboardMarketIndexItem;
|
||||||
|
})
|
||||||
|
.filter((item): item is DashboardMarketIndexItem => Boolean(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 보유종목 리스트에 실시간 체결가를 병합해 현재가/평가금액/손익을 재계산합니다.
|
||||||
|
* @param holdings REST 기준 보유종목
|
||||||
|
* @param realtimeHoldings 종목별 실시간 체결 데이터
|
||||||
|
* @returns 병합된 보유종목 리스트
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> mergeHoldingsWithRealtime -> HoldingsList/StockDetailPreview 반영
|
||||||
|
* @see features/dashboard/hooks/use-holdings-realtime.ts 보유종목 실시간 체결 구독
|
||||||
|
*/
|
||||||
|
function mergeHoldingsWithRealtime(
|
||||||
|
holdings: DashboardHoldingItem[],
|
||||||
|
realtimeHoldings: Record<string, KisRealtimeStockTick>,
|
||||||
|
) {
|
||||||
|
if (holdings.length === 0 || Object.keys(realtimeHoldings).length === 0) {
|
||||||
|
return holdings;
|
||||||
|
}
|
||||||
|
|
||||||
|
return holdings.map((item) => {
|
||||||
|
const tick = realtimeHoldings[item.symbol];
|
||||||
|
if (!tick) return item;
|
||||||
|
|
||||||
|
const currentPrice = tick.currentPrice;
|
||||||
|
const purchaseAmount = item.averagePrice * item.quantity;
|
||||||
|
const evaluationAmount = currentPrice * item.quantity;
|
||||||
|
const profitLoss = evaluationAmount - purchaseAmount;
|
||||||
|
const profitRate = purchaseAmount > 0 ? (profitLoss / purchaseAmount) * 100 : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
currentPrice,
|
||||||
|
evaluationAmount,
|
||||||
|
profitLoss,
|
||||||
|
profitRate,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 실시간 보유종목 기준으로 대시보드 요약(총자산/손익)을 일관되게 재계산합니다.
|
||||||
|
* @param summary REST API 요약 값
|
||||||
|
* @param holdings 실시간 병합된 보유종목
|
||||||
|
* @returns 재계산된 요약 값
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> buildRealtimeSummary -> StatusHeader 카드 반영
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 상단 요약 렌더링
|
||||||
|
*/
|
||||||
|
function buildRealtimeSummary(
|
||||||
|
summary: DashboardBalanceSummary | null,
|
||||||
|
holdings: DashboardHoldingItem[],
|
||||||
|
) {
|
||||||
|
if (!summary) return null;
|
||||||
|
if (holdings.length === 0) return summary;
|
||||||
|
|
||||||
|
const evaluationAmount = holdings.reduce(
|
||||||
|
(total, item) => total + item.evaluationAmount,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const purchaseAmount = holdings.reduce(
|
||||||
|
(total, item) => total + item.averagePrice * item.quantity,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const totalProfitLoss = evaluationAmount - purchaseAmount;
|
||||||
|
const totalProfitRate =
|
||||||
|
purchaseAmount > 0 ? (totalProfitLoss / purchaseAmount) * 100 : 0;
|
||||||
|
|
||||||
|
const evaluationDelta = evaluationAmount - summary.evaluationAmount;
|
||||||
|
const baseTotalAmount =
|
||||||
|
summary.apiReportedNetAssetAmount > 0
|
||||||
|
? summary.apiReportedNetAssetAmount
|
||||||
|
: summary.totalAmount;
|
||||||
|
|
||||||
|
// 실시간은 "기준 순자산 + 평가금 증감분"으로만 반영합니다.
|
||||||
|
const totalAmount = Math.max(baseTotalAmount + evaluationDelta, 0);
|
||||||
|
const netAssetAmount = totalAmount;
|
||||||
|
const cashBalance = Math.max(totalAmount - evaluationAmount, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...summary,
|
||||||
|
totalAmount,
|
||||||
|
netAssetAmount,
|
||||||
|
cashBalance,
|
||||||
|
evaluationAmount,
|
||||||
|
purchaseAmount,
|
||||||
|
totalProfitLoss,
|
||||||
|
totalProfitRate,
|
||||||
|
} satisfies DashboardBalanceSummary;
|
||||||
|
}
|
||||||
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
59
features/dashboard/components/DashboardSkeleton.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 초기 로딩 스켈레톤 UI입니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx isLoading 상태에서 렌더링합니다.
|
||||||
|
*/
|
||||||
|
export function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-4 p-4 md:p-6">
|
||||||
|
{/* ========== HEADER SKELETON ========== */}
|
||||||
|
<Card className="border-brand-200 dark:border-brand-800/50">
|
||||||
|
<CardContent className="grid gap-3 p-4 md:grid-cols-4">
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
<Skeleton className="h-20 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ========== BODY SKELETON ========== */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1.15fr_1fr]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-28" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{Array.from({ length: 6 }).map((_, index) => (
|
||||||
|
<Skeleton key={index} className="h-14 w-full" />
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
<Skeleton className="h-16 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<Skeleton className="h-5 w-40" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<Skeleton className="h-14 w-full" />
|
||||||
|
<Skeleton className="h-14 w-full" />
|
||||||
|
<Skeleton className="h-14 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
features/dashboard/components/HoldingsList.tsx
Normal file
228
features/dashboard/components/HoldingsList.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* @file HoldingsList.tsx
|
||||||
|
* @description 대시보드 좌측 영역의 보유 종목 리스트 컴포넌트
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components / UI
|
||||||
|
* - [사용자 행동] 종목 리스트 스크롤 -> 특정 종목 클릭(선택) -> 우측 상세 프레뷰 갱신
|
||||||
|
* - [데이터 흐름] DashboardContainer(mergedHoldings) -> HoldingsList -> HoldingItemRow -> onSelect(Callback)
|
||||||
|
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||||
|
* @author jihoon87.lee
|
||||||
|
*/
|
||||||
|
import { AlertCircle, Wallet2 } from "lucide-react";
|
||||||
|
import { RefreshCcw } from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercent,
|
||||||
|
getChangeToneClass,
|
||||||
|
} from "@/features/dashboard/utils/dashboard-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||||
|
|
||||||
|
interface HoldingsListProps {
|
||||||
|
/** 보유 종목 데이터 리스트 (실시간 시세 병합됨) */
|
||||||
|
holdings: DashboardHoldingItem[];
|
||||||
|
/** 현재 선택된 종목의 심볼 (없으면 null) */
|
||||||
|
selectedSymbol: string | null;
|
||||||
|
/** 데이터 로딩 상태 */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** 에러 메시지 (없으면 null) */
|
||||||
|
error: string | null;
|
||||||
|
/** 섹션 재조회 핸들러 */
|
||||||
|
onRetry?: () => void;
|
||||||
|
/** 종목 선택 시 호출되는 핸들러 */
|
||||||
|
onSelect: (symbol: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [컴포넌트] 보유 종목 리스트
|
||||||
|
* 사용자의 잔고 정보를 바탕으로 실시간 시세가 반영된 종목 카드 목록을 렌더링합니다.
|
||||||
|
*
|
||||||
|
* @param props HoldingsListProps
|
||||||
|
* @see DashboardContainer.tsx - 좌측 메인 영역에서 실시간 병합 데이터를 전달받아 호출
|
||||||
|
* @see DashboardContainer.tsx - setSelectedSymbol 핸들러를 onSelect로 전달
|
||||||
|
*/
|
||||||
|
export function HoldingsList({
|
||||||
|
holdings,
|
||||||
|
selectedSymbol,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
onSelect,
|
||||||
|
}: HoldingsListProps) {
|
||||||
|
return (
|
||||||
|
<Card className="h-full">
|
||||||
|
{/* ========== 카드 헤더: 타이틀 및 설명 ========== */}
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Wallet2 className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
보유 종목
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
현재 보유 중인 종목을 선택하면 우측 상세가 갱신됩니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{/* ========== 카드 본문: 상태별 메시지 및 리스트 ========== */}
|
||||||
|
<CardContent>
|
||||||
|
{/* 로딩 중 상태 (데이터가 아직 없는 경우) */}
|
||||||
|
{isLoading && holdings.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
보유 종목을 불러오는 중입니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 에러 발생 상태 */}
|
||||||
|
{error && (
|
||||||
|
<div className="mb-2 rounded-md border border-red-200 bg-red-50/60 p-2.5 dark:border-red-900/40 dark:bg-red-950/20">
|
||||||
|
<p className="flex items-start gap-1.5 text-sm text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-red-700/80 dark:text-red-300/80">
|
||||||
|
한국투자증권 API가 일시적으로 불안정할 수 있습니다. 잠시 후 다시 시도해 주세요.
|
||||||
|
</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
보유종목 다시 불러오기
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 없음 상태 */}
|
||||||
|
{!isLoading && holdings.length === 0 && !error && (
|
||||||
|
<p className="text-sm text-muted-foreground">보유 종목이 없습니다.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 종목 리스트 렌더링 영역 */}
|
||||||
|
{holdings.length > 0 && (
|
||||||
|
<ScrollArea className="h-[420px] pr-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{holdings.map((holding) => (
|
||||||
|
<HoldingItemRow
|
||||||
|
key={holding.symbol}
|
||||||
|
holding={holding}
|
||||||
|
isSelected={selectedSymbol === holding.symbol}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HoldingItemRowProps {
|
||||||
|
/** 개별 종목 정보 */
|
||||||
|
holding: DashboardHoldingItem;
|
||||||
|
/** 선택 여부 */
|
||||||
|
isSelected: boolean;
|
||||||
|
/** 클릭 핸들러 */
|
||||||
|
onSelect: (symbol: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [컴포넌트] 보유 종목 개별 행 (아이템)
|
||||||
|
* 종목의 기본 정보와 실시간 시세, 현재 손익 상태를 표시합니다.
|
||||||
|
*
|
||||||
|
* @param props HoldingItemRowProps
|
||||||
|
* @see HoldingsList.tsx - holdings.map 내에서 호출
|
||||||
|
* @see use-price-flash.ts - 현재가 변경 감지 및 애니메이션 효과 트리거
|
||||||
|
*/
|
||||||
|
function HoldingItemRow({
|
||||||
|
holding,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
}: HoldingItemRowProps) {
|
||||||
|
// [State/Hook] 현재가 기반 가격 반짝임 효과 상태 관리
|
||||||
|
// @see use-price-flash.ts - 현재가 변경 감지 시 symbol을 기준으로 이펙트 발생 여부 결정
|
||||||
|
const flash = usePriceFlash(holding.currentPrice, holding.symbol);
|
||||||
|
|
||||||
|
// [UI] 손익 상태에 따른 텍스트 색상 클래스 결정 (상승: red, 하락: blue)
|
||||||
|
const toneClass = getChangeToneClass(holding.profitLoss);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
// [Step 1] 종목 클릭 시 부모의 선택 핸들러 호출
|
||||||
|
onClick={() => onSelect(holding.symbol)}
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-xl border px-3 py-3 text-left transition-all relative overflow-hidden",
|
||||||
|
isSelected
|
||||||
|
? "border-brand-400 bg-brand-50/60 ring-1 ring-brand-300 dark:border-brand-600 dark:bg-brand-900/20 dark:ring-brand-700"
|
||||||
|
: "border-border/70 bg-background hover:border-brand-200 hover:bg-brand-50/30 dark:hover:border-brand-700 dark:hover:bg-brand-900/15",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ========== 행 상단: 종목명, 심볼 및 현재가 정보 ========== */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
{/* 종목명 및 기본 정보 */}
|
||||||
|
<p className="text-sm font-semibold text-foreground">
|
||||||
|
{holding.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{holding.symbol} · {holding.market} · {holding.quantity}주
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="relative inline-flex items-center justify-end gap-1">
|
||||||
|
{/* 시세 변동 애니메이션 (Flash) 표시 영역 */}
|
||||||
|
{flash && (
|
||||||
|
<span
|
||||||
|
key={flash.id}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute -left-12 top-0 whitespace-nowrap text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||||
|
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flash.type === "up" ? "+" : ""}
|
||||||
|
{flash.val.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* 실시간 현재가 */}
|
||||||
|
<p className="text-sm font-semibold text-foreground transition-colors duration-300">
|
||||||
|
{formatCurrency(holding.currentPrice)}원
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* 실시간 수익률 */}
|
||||||
|
<p className={cn("text-xs font-medium", toneClass)}>
|
||||||
|
{formatPercent(holding.profitRate)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 행 하단: 평단가, 평가액 및 실시간 손익 ========== */}
|
||||||
|
<div className="mt-2 grid grid-cols-3 gap-1 text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
평균 {formatCurrency(holding.averagePrice)}원
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
평가 {formatCurrency(holding.evaluationAmount)}원
|
||||||
|
</span>
|
||||||
|
<span className={cn("text-right font-medium", toneClass)}>
|
||||||
|
손익 {formatCurrency(holding.profitLoss)}원
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
192
features/dashboard/components/MarketSummary.tsx
Normal file
192
features/dashboard/components/MarketSummary.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { BarChart3, TrendingDown, TrendingUp } from "lucide-react";
|
||||||
|
import { RefreshCcw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { DashboardMarketIndexItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatSignedCurrency,
|
||||||
|
formatSignedPercent,
|
||||||
|
} from "@/features/dashboard/utils/dashboard-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||||
|
|
||||||
|
interface MarketSummaryProps {
|
||||||
|
items: DashboardMarketIndexItem[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
warning?: string | null;
|
||||||
|
isRealtimePending?: boolean;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 코스피/코스닥 지수 요약 카드입니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 우측 상단 영역에서 호출합니다.
|
||||||
|
*/
|
||||||
|
export function MarketSummary({
|
||||||
|
items,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
warning = null,
|
||||||
|
isRealtimePending = false,
|
||||||
|
onRetry,
|
||||||
|
}: MarketSummaryProps) {
|
||||||
|
return (
|
||||||
|
<Card className="overflow-hidden border-brand-200/80 bg-linear-to-br from-brand-50/50 to-background dark:border-brand-800/45 dark:from-brand-950/20 dark:to-background">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base text-brand-700 dark:text-brand-300">
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
시장 지수
|
||||||
|
</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription>실시간 코스피/코스닥 지수 현황입니다.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2">
|
||||||
|
{/* ========== LOADING STATE ========== */}
|
||||||
|
{isLoading && items.length === 0 && (
|
||||||
|
<div className="col-span-full py-4 text-center text-sm text-muted-foreground animate-pulse">
|
||||||
|
지수 데이터를 불러오는 중입니다...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== REALTIME PENDING STATE ========== */}
|
||||||
|
{isRealtimePending && items.length === 0 && !isLoading && !error && (
|
||||||
|
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||||
|
실시간 시세 연결은 완료되었고 첫 지수 데이터를 기다리는 중입니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== ERROR/WARNING STATE ========== */}
|
||||||
|
{error && (
|
||||||
|
<div className="col-span-full rounded-md bg-red-50 p-3 text-sm text-red-600 dark:bg-red-950/30 dark:text-red-400">
|
||||||
|
<p>지수 정보를 가져오는데 실패했습니다.</p>
|
||||||
|
<p className="mt-1 text-xs opacity-80">
|
||||||
|
{toCompactErrorMessage(error)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs opacity-80">
|
||||||
|
토큰이 정상이어도 한국투자증권 API 점검/지연 시 일시적으로 실패할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRetry}
|
||||||
|
className="mt-2 border-red-300 text-red-700 hover:bg-red-100 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
지수 다시 불러오기
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!error && warning && (
|
||||||
|
<div className="col-span-full rounded-md bg-amber-50 p-3 text-sm text-amber-700 dark:bg-amber-950/25 dark:text-amber-400">
|
||||||
|
{warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== INDEX CARDS ========== */}
|
||||||
|
{items.map((item) => (
|
||||||
|
<IndexItem key={item.code} item={item} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isLoading && items.length === 0 && !error && (
|
||||||
|
<div className="col-span-full py-4 text-center text-sm text-muted-foreground">
|
||||||
|
표시할 데이터가 없습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 길고 복잡한 서버 오류를 대시보드 카드에 맞는 짧은 문구로 축약합니다.
|
||||||
|
* @param error 원본 오류 문자열
|
||||||
|
* @returns 화면 노출용 오류 메시지
|
||||||
|
* @see features/dashboard/components/MarketSummary.tsx 지수 오류 배너 상세 문구
|
||||||
|
*/
|
||||||
|
function toCompactErrorMessage(error: string) {
|
||||||
|
const normalized = error.replaceAll(/\s+/g, " ").trim();
|
||||||
|
if (!normalized) return "잠시 후 다시 시도해 주세요.";
|
||||||
|
if (normalized.length <= 120) return normalized;
|
||||||
|
return `${normalized.slice(0, 120)}...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function IndexItem({ item }: { item: DashboardMarketIndexItem }) {
|
||||||
|
const isUp = item.change > 0;
|
||||||
|
const isDown = item.change < 0;
|
||||||
|
const toneClass = isUp
|
||||||
|
? "text-red-600 dark:text-red-400"
|
||||||
|
: isDown
|
||||||
|
? "text-blue-600 dark:text-blue-400"
|
||||||
|
: "text-muted-foreground";
|
||||||
|
|
||||||
|
const bgClass = isUp
|
||||||
|
? "bg-red-50/50 dark:bg-red-950/10 border-red-100 dark:border-red-900/30"
|
||||||
|
: isDown
|
||||||
|
? "bg-blue-50/50 dark:bg-blue-950/10 border-blue-100 dark:border-blue-900/30"
|
||||||
|
: "bg-muted/50 border-border/50";
|
||||||
|
|
||||||
|
const flash = usePriceFlash(item.price, item.code);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col justify-between rounded-xl border p-4 transition-all hover:bg-background/80",
|
||||||
|
bgClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{item.market}
|
||||||
|
</span>
|
||||||
|
{isUp ? (
|
||||||
|
<TrendingUp className="h-4 w-4 text-red-500/70" />
|
||||||
|
) : isDown ? (
|
||||||
|
<TrendingDown className="h-4 w-4 text-blue-500/70" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-2xl font-bold tracking-tight relative w-fit">
|
||||||
|
{formatCurrency(item.price)}
|
||||||
|
|
||||||
|
{/* Flash Indicator */}
|
||||||
|
{flash && (
|
||||||
|
<div
|
||||||
|
key={flash.id} // Force re-render for animation restart using state ID
|
||||||
|
className={cn(
|
||||||
|
"absolute left-full top-1 ml-2 text-sm font-bold animate-out fade-out slide-out-to-top-2 duration-1000 fill-mode-forwards pointer-events-none whitespace-nowrap",
|
||||||
|
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flash.type === "up" ? "+" : ""}
|
||||||
|
{flash.val.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex items-center gap-2 text-sm font-medium",
|
||||||
|
toneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{formatSignedCurrency(item.change)}</span>
|
||||||
|
<span className="rounded-md bg-background/50 px-1.5 py-0.5 text-xs shadow-sm">
|
||||||
|
{formatSignedPercent(item.changeRate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
features/dashboard/components/StatusHeader.tsx
Normal file
219
features/dashboard/components/StatusHeader.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Activity, RefreshCcw, Settings2, Wifi } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import type { DashboardBalanceSummary } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatSignedCurrency,
|
||||||
|
formatSignedPercent,
|
||||||
|
getChangeToneClass,
|
||||||
|
} from "@/features/dashboard/utils/dashboard-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface StatusHeaderProps {
|
||||||
|
summary: DashboardBalanceSummary | null;
|
||||||
|
isKisRestConnected: boolean;
|
||||||
|
isWebSocketReady: boolean;
|
||||||
|
isRealtimePending: boolean;
|
||||||
|
isProfileVerified: boolean;
|
||||||
|
verifiedAccountNo: string | null;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
onRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 상단 상태 헤더(총자산/손익/연결상태/빠른액션) 컴포넌트입니다.
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트에서 상태 값을 전달받아 렌더링합니다.
|
||||||
|
*/
|
||||||
|
export function StatusHeader({
|
||||||
|
summary,
|
||||||
|
isKisRestConnected,
|
||||||
|
isWebSocketReady,
|
||||||
|
isRealtimePending,
|
||||||
|
isProfileVerified,
|
||||||
|
verifiedAccountNo,
|
||||||
|
isRefreshing,
|
||||||
|
lastUpdatedAt,
|
||||||
|
onRefresh,
|
||||||
|
}: StatusHeaderProps) {
|
||||||
|
const toneClass = getChangeToneClass(summary?.totalProfitLoss ?? 0);
|
||||||
|
const updatedLabel = lastUpdatedAt
|
||||||
|
? new Date(lastUpdatedAt).toLocaleTimeString("ko-KR", {
|
||||||
|
hour12: false,
|
||||||
|
})
|
||||||
|
: "--:--:--";
|
||||||
|
const hasApiTotalAmount =
|
||||||
|
Boolean(summary) && (summary?.apiReportedTotalAmount ?? 0) > 0;
|
||||||
|
const hasApiNetAssetAmount =
|
||||||
|
Boolean(summary) && (summary?.apiReportedNetAssetAmount ?? 0) > 0;
|
||||||
|
const isApiTotalAmountDifferent =
|
||||||
|
Boolean(summary) &&
|
||||||
|
Math.abs(
|
||||||
|
(summary?.apiReportedTotalAmount ?? 0) - (summary?.totalAmount ?? 0),
|
||||||
|
) >= 1;
|
||||||
|
const realtimeStatusLabel = isWebSocketReady
|
||||||
|
? isRealtimePending
|
||||||
|
? "수신 대기중"
|
||||||
|
: "연결됨"
|
||||||
|
: "미연결";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="relative overflow-hidden border-brand-200 shadow-sm dark:border-brand-800/50">
|
||||||
|
{/* ========== BACKGROUND DECORATION ========== */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 h-20 bg-linear-to-r from-brand-100/70 via-brand-50/50 to-transparent dark:from-brand-900/35 dark:via-brand-900/20" />
|
||||||
|
|
||||||
|
<CardContent className="relative grid gap-3 p-4 md:grid-cols-[1fr_1fr_1fr_auto]">
|
||||||
|
{/* ========== TOTAL ASSET ========== */}
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">내 자산 (순자산 실시간)</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold tracking-tight">
|
||||||
|
{summary ? `${formatCurrency(summary.totalAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
현금(예수금) {summary ? `${formatCurrency(summary.cashBalance)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
주식 평가금{" "}
|
||||||
|
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
총예수금(KIS){" "}
|
||||||
|
{summary ? `${formatCurrency(summary.totalDepositAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[11px] text-muted-foreground/80">
|
||||||
|
총예수금은 결제 대기 금액이 포함될 수 있어 체감 현금과 다를 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
순자산(대출 반영){" "}
|
||||||
|
{summary ? `${formatCurrency(summary.netAssetAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
{hasApiTotalAmount && isApiTotalAmountDifferent ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
KIS 집계 총자산 {formatCurrency(summary?.apiReportedTotalAmount ?? 0)}원
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{hasApiNetAssetAmount ? (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
KIS 집계 순자산 {formatCurrency(summary?.apiReportedNetAssetAmount ?? 0)}원
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== PROFIT/LOSS ========== */}
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">현재 손익</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-xl font-semibold tracking-tight",
|
||||||
|
toneClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{summary ? `${formatSignedCurrency(summary.totalProfitLoss)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className={cn("mt-1 text-xs font-medium", toneClass)}>
|
||||||
|
{summary ? formatSignedPercent(summary.totalProfitRate) : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
현재 평가금액{" "}
|
||||||
|
{summary ? `${formatCurrency(summary.evaluationAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
총 매수금액{" "}
|
||||||
|
{summary ? `${formatCurrency(summary.purchaseAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== CONNECTION STATUS ========== */}
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/90 px-4 py-3">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">연결 상태</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||||
|
isKisRestConnected
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wifi className="h-3.5 w-3.5" />
|
||||||
|
서버 {isKisRestConnected ? "연결됨" : "연결 끊김"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||||
|
isWebSocketReady
|
||||||
|
? isRealtimePending
|
||||||
|
? "bg-amber-500/10 text-amber-700 dark:text-amber-400"
|
||||||
|
: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
실시간 시세 {realtimeStatusLabel}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1 rounded-full px-2 py-1",
|
||||||
|
isProfileVerified
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
|
||||||
|
: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
계좌 인증 {isProfileVerified ? "완료" : "미완료"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
마지막 업데이트 {updatedLabel}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
계좌 {maskAccountNo(verifiedAccountNo)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
대출금 {summary ? `${formatCurrency(summary.loanAmount)}원` : "-"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== QUICK ACTIONS ========== */}
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row md:flex-col md:items-stretch md:justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="w-full border-brand-200 text-brand-700 hover:bg-brand-50 dark:border-brand-700 dark:text-brand-300 dark:hover:bg-brand-900/30"
|
||||||
|
>
|
||||||
|
<RefreshCcw
|
||||||
|
className={cn("h-4 w-4 mr-2", isRefreshing ? "animate-spin" : "")}
|
||||||
|
/>
|
||||||
|
지금 다시 불러오기
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full bg-brand-600 text-white hover:bg-brand-700 dark:bg-brand-600 dark:text-white dark:hover:bg-brand-500"
|
||||||
|
>
|
||||||
|
<Link href="/settings">
|
||||||
|
<Settings2 className="h-4 w-4 mr-2" />
|
||||||
|
연결 설정
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 계좌번호를 마스킹해 표시합니다.
|
||||||
|
* @param value 계좌번호(8-2)
|
||||||
|
* @returns 마스킹 문자열
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 시스템 상태 영역 계좌 표시
|
||||||
|
*/
|
||||||
|
function maskAccountNo(value: string | null) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const digits = value.replace(/\D/g, "");
|
||||||
|
if (digits.length !== 10) return "********";
|
||||||
|
return "********-**";
|
||||||
|
}
|
||||||
228
features/dashboard/components/StockDetailPreview.tsx
Normal file
228
features/dashboard/components/StockDetailPreview.tsx
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* @file StockDetailPreview.tsx
|
||||||
|
* @description 대시보드 우측 영역의 선택 종목 상세 정보 및 실시간 시세 반영 컴포넌트
|
||||||
|
* @remarks
|
||||||
|
* - [레이어] Components / UI
|
||||||
|
* - [사용자 행동] 종목 리스트에서 항목 선택 -> 상세 정보 조회 -> 실시간 시세 변동 확인
|
||||||
|
* - [데이터 흐름] DashboardContainer(realtimeSelectedHolding) -> StockDetailPreview -> Metric(UI)
|
||||||
|
* - [연관 파일] DashboardContainer.tsx, dashboard.types.ts, use-price-flash.ts
|
||||||
|
* @author jihoon87.lee
|
||||||
|
*/
|
||||||
|
import { BarChartBig, ExternalLink, MousePointerClick } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { usePriceFlash } from "@/features/dashboard/hooks/use-price-flash";
|
||||||
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
formatPercent,
|
||||||
|
getChangeToneClass,
|
||||||
|
} from "@/features/dashboard/utils/dashboard-format";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface StockDetailPreviewProps {
|
||||||
|
/** 선택된 종목 정보 (없으면 null) */
|
||||||
|
holding: DashboardHoldingItem | null;
|
||||||
|
/** 현재 총 자산 (비중 계산용) */
|
||||||
|
totalAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [컴포넌트] 선택 종목 상세 요약 카드
|
||||||
|
* 대시보드에서 선택된 특정 종목의 매입가, 현재가, 수익률 등 상세 지표를 실시간으로 보여줍니다.
|
||||||
|
*
|
||||||
|
* @param props StockDetailPreviewProps
|
||||||
|
* @see DashboardContainer.tsx - HoldingsList 선택 결과를 실시간 데이터로 전달받아 렌더링
|
||||||
|
*/
|
||||||
|
export function StockDetailPreview({
|
||||||
|
holding,
|
||||||
|
totalAmount,
|
||||||
|
}: StockDetailPreviewProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
// [State/Hook] 실시간 가격 변동 애니메이션 상태 관리
|
||||||
|
// @remarks 종목이 선택되지 않았을 때를 대비해 safe value(0)를 전달하며, 종목 변경 시 효과를 초기화하도록 symbol 전달
|
||||||
|
const currentPrice = holding?.currentPrice ?? 0;
|
||||||
|
const priceFlash = usePriceFlash(currentPrice, holding?.symbol);
|
||||||
|
|
||||||
|
// [Step 1] 종목이 선택되지 않은 경우 초기 안내 화면 렌더링
|
||||||
|
if (!holding) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
선택 종목 정보
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
보유 종목을 선택하면 자세한 정보가 표시됩니다.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
왼쪽 보유 종목 리스트에서 종목을 선택해 주세요.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Step 2] 수익/손실 여부에 따른 UI 톤(색상) 결정
|
||||||
|
const profitToneClass = getChangeToneClass(holding.profitLoss);
|
||||||
|
|
||||||
|
// [Step 3] 총 자산 대비 비중 계산
|
||||||
|
const allocationRate =
|
||||||
|
totalAmount > 0
|
||||||
|
? Math.min((holding.evaluationAmount / totalAmount) * 100, 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
{/* ========== 카드 헤더: 종목명 및 기본 정보 ========== */}
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<BarChartBig className="h-4 w-4 text-brand-600 dark:text-brand-400" />
|
||||||
|
선택 종목 정보
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
router.push(
|
||||||
|
`/trade?symbol=${holding.symbol}&name=${encodeURIComponent(holding.name)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-1.5 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5",
|
||||||
|
"text-sm font-bold text-brand-700 transition-all cursor-pointer",
|
||||||
|
"hover:border-brand-400 hover:bg-brand-100 hover:shadow-sm",
|
||||||
|
"dark:border-brand-800/60 dark:bg-brand-900/40 dark:text-brand-400 dark:hover:border-brand-600 dark:hover:bg-brand-900/60",
|
||||||
|
)}
|
||||||
|
title={`${holding.name} 종목 상세 거래로 이동`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{holding.name}</span>
|
||||||
|
<span className="text-[10px] font-medium opacity-70">
|
||||||
|
({holding.symbol})
|
||||||
|
</span>
|
||||||
|
<ExternalLink className="h-3 w-3 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
· {holding.market}
|
||||||
|
</span>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* ========== 실시간 주요 지표 영역 (Grid) ========== */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<Metric
|
||||||
|
label="보유 수량"
|
||||||
|
value={`${holding.quantity.toLocaleString("ko-KR")}주`}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="매입 평균가"
|
||||||
|
value={`${formatCurrency(holding.averagePrice)}원`}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="현재가"
|
||||||
|
value={`${formatCurrency(holding.currentPrice)}원`}
|
||||||
|
flash={priceFlash}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="수익률"
|
||||||
|
value={formatPercent(holding.profitRate)}
|
||||||
|
valueClassName={profitToneClass}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="현재 손익"
|
||||||
|
value={`${formatCurrency(holding.profitLoss)}원`}
|
||||||
|
valueClassName={profitToneClass}
|
||||||
|
/>
|
||||||
|
<Metric
|
||||||
|
label="평가금액"
|
||||||
|
value={`${formatCurrency(holding.evaluationAmount)}원`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 자산 비중 그래프 영역 ========== */}
|
||||||
|
<div className="rounded-xl border border-border/70 bg-background/70 p-3">
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||||
|
<span>총 자산 대비 비중</span>
|
||||||
|
<span>{formatPercent(allocationRate)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full bg-linear-to-r from-brand-500 to-brand-700"
|
||||||
|
style={{ width: `${allocationRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ========== 추가 기능 예고 영역 (Placeholder) ========== */}
|
||||||
|
<div className="rounded-xl border border-dashed border-border/80 bg-muted/30 p-3">
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-medium text-foreground/80">
|
||||||
|
<MousePointerClick className="h-4 w-4 text-brand-500" />
|
||||||
|
빠른 주문(준비 중)
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
향후 이 영역에서 선택 종목의 빠른 매수/매도 기능을 제공합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricProps {
|
||||||
|
/** 지표 레이블 */
|
||||||
|
label: string;
|
||||||
|
/** 표시될 값 */
|
||||||
|
value: string;
|
||||||
|
/** 값 텍스트 추가 스타일 */
|
||||||
|
valueClassName?: string;
|
||||||
|
/** 가격 변동 애니메이션 상태 */
|
||||||
|
flash?: { type: "up" | "down"; val: number; id: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [컴포넌트] 상세 카드용 개별 지표 아이템
|
||||||
|
* 레이블과 값을 박스 형태로 렌더링하며, 필요한 경우 시세 변동 Flash 애니메이션을 처리합니다.
|
||||||
|
*
|
||||||
|
* @param props MetricProps
|
||||||
|
* @see StockDetailPreview.tsx - 내부 그리드 영역에서 여러 개 호출
|
||||||
|
*/
|
||||||
|
function Metric({ label, value, valueClassName, flash }: MetricProps) {
|
||||||
|
return (
|
||||||
|
<div className="relative overflow-hidden rounded-xl border border-border/70 bg-background/70 p-3 transition-colors">
|
||||||
|
{/* 시세 변동 시 나타나는 일시적인 수치 표시 (Flash) */}
|
||||||
|
{flash && (
|
||||||
|
<span
|
||||||
|
key={flash.id}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none absolute right-2 top-2 text-xs font-bold animate-in fade-in slide-in-from-bottom-1 fill-mode-forwards duration-300",
|
||||||
|
flash.type === "up" ? "text-red-500" : "text-blue-500",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{flash.type === "up" ? "+" : ""}
|
||||||
|
{flash.val.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 지표 레이블 및 본체 값 */}
|
||||||
|
<p className="text-xs text-muted-foreground">{label}</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"mt-1 text-sm font-semibold text-foreground transition-colors",
|
||||||
|
valueClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
features/dashboard/hooks/use-dashboard-data.ts
Normal file
198
features/dashboard/hooks/use-dashboard-data.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
fetchDashboardActivity,
|
||||||
|
fetchDashboardBalance,
|
||||||
|
fetchDashboardIndices,
|
||||||
|
} from "@/features/dashboard/apis/dashboard.api";
|
||||||
|
import type {
|
||||||
|
DashboardActivityResponse,
|
||||||
|
DashboardBalanceResponse,
|
||||||
|
DashboardIndicesResponse,
|
||||||
|
} from "@/features/dashboard/types/dashboard.types";
|
||||||
|
|
||||||
|
interface UseDashboardDataResult {
|
||||||
|
activity: DashboardActivityResponse | null;
|
||||||
|
balance: DashboardBalanceResponse | null;
|
||||||
|
indices: DashboardIndicesResponse["items"];
|
||||||
|
selectedSymbol: string | null;
|
||||||
|
setSelectedSymbol: (symbol: string) => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
activityError: string | null;
|
||||||
|
balanceError: string | null;
|
||||||
|
indicesError: string | null;
|
||||||
|
lastUpdatedAt: string | null;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLLING_INTERVAL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 잔고/지수 상태를 관리하는 훅입니다.
|
||||||
|
* @param credentials KIS 인증 정보
|
||||||
|
* @returns 대시보드 데이터/로딩/오류 상태
|
||||||
|
* @remarks UI 흐름: 대시보드 진입 -> refresh("initial") -> balance/indices API 병렬 호출 -> 카드별 상태 반영
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 대시보드 루트 컨테이너
|
||||||
|
* @see features/dashboard/apis/dashboard.api.ts 실제 API 호출 함수
|
||||||
|
*/
|
||||||
|
export function useDashboardData(
|
||||||
|
credentials: KisRuntimeCredentials | null,
|
||||||
|
): UseDashboardDataResult {
|
||||||
|
const [activity, setActivity] = useState<DashboardActivityResponse | null>(null);
|
||||||
|
const [balance, setBalance] = useState<DashboardBalanceResponse | null>(null);
|
||||||
|
const [indices, setIndices] = useState<DashboardIndicesResponse["items"]>([]);
|
||||||
|
const [selectedSymbol, setSelectedSymbolState] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [activityError, setActivityError] = useState<string | null>(null);
|
||||||
|
const [balanceError, setBalanceError] = useState<string | null>(null);
|
||||||
|
const [indicesError, setIndicesError] = useState<string | null>(null);
|
||||||
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const requestSeqRef = useRef(0);
|
||||||
|
|
||||||
|
const hasAccountNo = Boolean(credentials?.accountNo?.trim());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 잔고/지수 데이터를 병렬로 갱신합니다.
|
||||||
|
* @param mode 초기 로드/수동 새로고침/주기 갱신 구분
|
||||||
|
* @see features/dashboard/hooks/use-dashboard-data.ts useEffect 초기 호출/폴링/수동 새로고침
|
||||||
|
*/
|
||||||
|
const refreshInternal = useCallback(
|
||||||
|
async (mode: "initial" | "manual" | "polling") => {
|
||||||
|
if (!credentials) return;
|
||||||
|
|
||||||
|
const requestSeq = ++requestSeqRef.current;
|
||||||
|
const isInitial = mode === "initial";
|
||||||
|
|
||||||
|
if (isInitial) {
|
||||||
|
setIsLoading(true);
|
||||||
|
} else {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: [
|
||||||
|
Promise<DashboardBalanceResponse | null>,
|
||||||
|
Promise<DashboardIndicesResponse>,
|
||||||
|
Promise<DashboardActivityResponse | null>,
|
||||||
|
] = [
|
||||||
|
hasAccountNo
|
||||||
|
? fetchDashboardBalance(credentials)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
fetchDashboardIndices(credentials),
|
||||||
|
hasAccountNo
|
||||||
|
? fetchDashboardActivity(credentials)
|
||||||
|
: Promise.resolve(null),
|
||||||
|
];
|
||||||
|
|
||||||
|
const [balanceResult, indicesResult, activityResult] = await Promise.allSettled(tasks);
|
||||||
|
if (requestSeq !== requestSeqRef.current) return;
|
||||||
|
|
||||||
|
let hasAnySuccess = false;
|
||||||
|
|
||||||
|
if (!hasAccountNo) {
|
||||||
|
setBalance(null);
|
||||||
|
setBalanceError(
|
||||||
|
"계좌번호가 없어 잔고를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
|
||||||
|
);
|
||||||
|
setActivity(null);
|
||||||
|
setActivityError(
|
||||||
|
"계좌번호가 없어 주문내역/매매일지를 조회할 수 없습니다. 설정에서 계좌번호(8-2)를 입력해 주세요.",
|
||||||
|
);
|
||||||
|
setSelectedSymbolState(null);
|
||||||
|
} else if (balanceResult.status === "fulfilled") {
|
||||||
|
hasAnySuccess = true;
|
||||||
|
setBalance(balanceResult.value);
|
||||||
|
setBalanceError(null);
|
||||||
|
|
||||||
|
setSelectedSymbolState((prev) => {
|
||||||
|
const nextHoldings = balanceResult.value?.holdings ?? [];
|
||||||
|
if (nextHoldings.length === 0) return null;
|
||||||
|
if (prev && nextHoldings.some((item) => item.symbol === prev)) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
return nextHoldings[0]?.symbol ?? null;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setBalanceError(balanceResult.reason instanceof Error ? balanceResult.reason.message : "잔고 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAccountNo && activityResult.status === "fulfilled") {
|
||||||
|
hasAnySuccess = true;
|
||||||
|
setActivity(activityResult.value);
|
||||||
|
setActivityError(null);
|
||||||
|
} else if (hasAccountNo && activityResult.status === "rejected") {
|
||||||
|
setActivityError(activityResult.reason instanceof Error ? activityResult.reason.message : "주문내역/매매일지 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (indicesResult.status === "fulfilled") {
|
||||||
|
hasAnySuccess = true;
|
||||||
|
setIndices(indicesResult.value.items);
|
||||||
|
setIndicesError(null);
|
||||||
|
} else {
|
||||||
|
setIndicesError(indicesResult.reason instanceof Error ? indicesResult.reason.message : "시장 지수 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasAnySuccess) {
|
||||||
|
setLastUpdatedAt(new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitial) {
|
||||||
|
setIsLoading(false);
|
||||||
|
} else {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[credentials, hasAccountNo],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 대시보드 수동 새로고침 핸들러입니다.
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 새로고침 버튼 onClick
|
||||||
|
*/
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
await refreshInternal("manual");
|
||||||
|
}, [refreshInternal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!credentials) return;
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void refreshInternal("initial");
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeout);
|
||||||
|
}, [credentials, refreshInternal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!credentials) return;
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void refreshInternal("polling");
|
||||||
|
}, POLLING_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [credentials, refreshInternal]);
|
||||||
|
|
||||||
|
const setSelectedSymbol = useCallback((symbol: string) => {
|
||||||
|
setSelectedSymbolState(symbol);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
balance,
|
||||||
|
indices,
|
||||||
|
selectedSymbol,
|
||||||
|
setSelectedSymbol,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
activityError,
|
||||||
|
balanceError,
|
||||||
|
indicesError,
|
||||||
|
lastUpdatedAt,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
76
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
76
features/dashboard/hooks/use-holdings-realtime.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { DashboardHoldingItem } from "@/features/dashboard/types/dashboard.types";
|
||||||
|
import {
|
||||||
|
type KisRealtimeStockTick,
|
||||||
|
parseKisRealtimeStockTick,
|
||||||
|
} from "@/features/dashboard/utils/kis-stock-realtime.utils";
|
||||||
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||||
|
|
||||||
|
const STOCK_REALTIME_TR_ID = "H0STCNT0";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 보유 종목 목록에 대한 실시간 체결 데이터를 구독합니다.
|
||||||
|
* @param holdings 보유 종목 목록
|
||||||
|
* @returns 종목별 실시간 체결 데이터/연결 상태
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> useHoldingsRealtime -> HoldingsList/summary 실시간 반영
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 보유종목 실시간 병합
|
||||||
|
*/
|
||||||
|
export function useHoldingsRealtime(holdings: DashboardHoldingItem[]) {
|
||||||
|
const [realtimeData, setRealtimeData] = useState<
|
||||||
|
Record<string, KisRealtimeStockTick>
|
||||||
|
>({});
|
||||||
|
const { subscribe, connect, isConnected } = useKisWebSocketStore();
|
||||||
|
|
||||||
|
const uniqueSymbols = useMemo(
|
||||||
|
() => Array.from(new Set((holdings ?? []).map((item) => item.symbol))).sort(),
|
||||||
|
[holdings],
|
||||||
|
);
|
||||||
|
const symbolKey = useMemo(() => uniqueSymbols.join(","), [uniqueSymbols]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (uniqueSymbols.length === 0) {
|
||||||
|
const resetTimerId = window.setTimeout(() => {
|
||||||
|
setRealtimeData({});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(resetTimerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConnected) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubs: (() => void)[] = [];
|
||||||
|
|
||||||
|
uniqueSymbols.forEach((symbol) => {
|
||||||
|
const unsub = subscribe(STOCK_REALTIME_TR_ID, symbol, (data) => {
|
||||||
|
const tick = parseKisRealtimeStockTick(data);
|
||||||
|
if (tick) {
|
||||||
|
setRealtimeData((prev) => {
|
||||||
|
const prevTick = prev[tick.symbol];
|
||||||
|
if (
|
||||||
|
prevTick?.currentPrice === tick.currentPrice &&
|
||||||
|
prevTick?.change === tick.change &&
|
||||||
|
prevTick?.changeRate === tick.changeRate
|
||||||
|
) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[tick.symbol]: tick,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
unsubs.push(unsub);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubs.forEach((unsub) => unsub());
|
||||||
|
};
|
||||||
|
}, [symbolKey, uniqueSymbols, connect, subscribe, isConnected]);
|
||||||
|
|
||||||
|
return { realtimeData, isConnected };
|
||||||
|
}
|
||||||
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
77
features/dashboard/hooks/use-market-realtime.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
parseKisRealtimeIndexTick,
|
||||||
|
type KisRealtimeIndexTick,
|
||||||
|
} from "@/features/dashboard/utils/kis-index-realtime.utils";
|
||||||
|
import { useKisWebSocket } from "@/features/kis-realtime/hooks/useKisWebSocket";
|
||||||
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
|
||||||
|
const INDEX_TR_ID = "H0UPCNT0";
|
||||||
|
const KOSPI_SYMBOL = "0001";
|
||||||
|
const KOSDAQ_SYMBOL = "1001";
|
||||||
|
|
||||||
|
interface UseMarketRealtimeResult {
|
||||||
|
realtimeIndices: Record<string, KisRealtimeIndexTick>;
|
||||||
|
isConnected: boolean;
|
||||||
|
hasReceivedTick: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
lastTickAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 코스피/코스닥 실시간 지수 웹소켓 구독 상태를 관리합니다.
|
||||||
|
* @param credentials KIS 인증 정보(하위 호환 파라미터)
|
||||||
|
* @param isVerified KIS 연결 인증 여부
|
||||||
|
* @returns 실시간 지수 맵/연결 상태/수신 대기 상태
|
||||||
|
* @remarks UI 흐름: DashboardContainer -> useMarketRealtime -> MarketSummary/StatusHeader 렌더링 반영
|
||||||
|
* @see features/dashboard/components/DashboardContainer.tsx 지수 데이터 통합 및 상태 전달
|
||||||
|
*/
|
||||||
|
export function useMarketRealtime(
|
||||||
|
_credentials: KisRuntimeCredentials | null, // 하위 호환성을 위해 남겨둠 (실제로는 스토어 사용)
|
||||||
|
isVerified: boolean, // 하위 호환성을 위해 남겨둠
|
||||||
|
): UseMarketRealtimeResult {
|
||||||
|
const [realtimeIndices, setRealtimeIndices] = useState<
|
||||||
|
Record<string, KisRealtimeIndexTick>
|
||||||
|
>({});
|
||||||
|
const [lastTickAt, setLastTickAt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleMessage = useCallback((data: string) => {
|
||||||
|
const tick = parseKisRealtimeIndexTick(data);
|
||||||
|
if (tick) {
|
||||||
|
setLastTickAt(new Date().toISOString());
|
||||||
|
setRealtimeIndices((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tick.symbol]: tick,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// KOSPI 구독
|
||||||
|
const { isConnected: isKospiConnected } = useKisWebSocket({
|
||||||
|
symbol: KOSPI_SYMBOL,
|
||||||
|
trId: INDEX_TR_ID,
|
||||||
|
onMessage: handleMessage,
|
||||||
|
enabled: isVerified,
|
||||||
|
});
|
||||||
|
|
||||||
|
// KOSDAQ 구독
|
||||||
|
const { isConnected: isKosdaqConnected } = useKisWebSocket({
|
||||||
|
symbol: KOSDAQ_SYMBOL,
|
||||||
|
trId: INDEX_TR_ID,
|
||||||
|
onMessage: handleMessage,
|
||||||
|
enabled: isVerified,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasReceivedTick = Object.keys(realtimeIndices).length > 0;
|
||||||
|
const isPending = isVerified && (isKospiConnected || isKosdaqConnected) && !hasReceivedTick;
|
||||||
|
|
||||||
|
return {
|
||||||
|
realtimeIndices,
|
||||||
|
isConnected: isKospiConnected || isKosdaqConnected,
|
||||||
|
hasReceivedTick,
|
||||||
|
isPending,
|
||||||
|
lastTickAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
features/dashboard/hooks/use-price-flash.ts
Normal file
73
features/dashboard/hooks/use-price-flash.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const FLASH_DURATION_MS = 2_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 가격 변동 시 일시 플래시(+/-) 값을 생성합니다.
|
||||||
|
* @param currentPrice 현재가
|
||||||
|
* @param key 종목 식별 키(종목 변경 시 상태 초기화)
|
||||||
|
* @returns 플래시 값(up/down, 변화량) 또는 null
|
||||||
|
* @remarks UI 흐름: 시세 변경 -> usePriceFlash -> 플래시 값 노출 -> 2초 후 자동 제거
|
||||||
|
* @see features/dashboard/components/HoldingsList.tsx 보유종목 현재가 플래시
|
||||||
|
* @see features/dashboard/components/StockDetailPreview.tsx 상세 카드 현재가 플래시
|
||||||
|
*/
|
||||||
|
export function usePriceFlash(currentPrice: number, key?: string) {
|
||||||
|
const [flash, setFlash] = useState<{
|
||||||
|
val: number;
|
||||||
|
type: "up" | "down";
|
||||||
|
id: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const prevKeyRef = useRef<string | undefined>(key);
|
||||||
|
const prevPriceRef = useRef<number>(currentPrice);
|
||||||
|
const timerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyChanged = prevKeyRef.current !== key;
|
||||||
|
|
||||||
|
if (keyChanged) {
|
||||||
|
prevKeyRef.current = key;
|
||||||
|
prevPriceRef.current = currentPrice;
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
const resetTimerId = window.setTimeout(() => {
|
||||||
|
setFlash(null);
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(resetTimerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPrice = prevPriceRef.current;
|
||||||
|
const diff = currentPrice - prevPrice;
|
||||||
|
prevPriceRef.current = currentPrice;
|
||||||
|
|
||||||
|
if (prevPrice === 0 || Math.abs(diff) === 0) return;
|
||||||
|
|
||||||
|
// 플래시가 보이는 동안에는 새 플래시를 덮어쓰지 않아 화면 잔상이 지속되지 않게 합니다.
|
||||||
|
if (timerRef.current !== null) return;
|
||||||
|
|
||||||
|
setFlash({
|
||||||
|
val: diff,
|
||||||
|
type: diff > 0 ? "up" : "down",
|
||||||
|
id: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
timerRef.current = window.setTimeout(() => {
|
||||||
|
setFlash(null);
|
||||||
|
timerRef.current = null;
|
||||||
|
}, FLASH_DURATION_MS);
|
||||||
|
}, [currentPrice, key]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timerRef.current !== null) {
|
||||||
|
window.clearTimeout(timerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return flash;
|
||||||
|
}
|
||||||
141
features/dashboard/types/dashboard.types.ts
Normal file
141
features/dashboard/types/dashboard.types.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* @file features/dashboard/types/dashboard.types.ts
|
||||||
|
* @description 대시보드(잔고/지수/보유종목) 전용 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { KisTradingEnv } from "@/features/trade/types/trade.types";
|
||||||
|
|
||||||
|
export type DashboardMarket = "KOSPI" | "KOSDAQ";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 잔고 요약
|
||||||
|
*/
|
||||||
|
export interface DashboardBalanceSummary {
|
||||||
|
totalAmount: number;
|
||||||
|
cashBalance: number;
|
||||||
|
totalDepositAmount: number;
|
||||||
|
totalProfitLoss: number;
|
||||||
|
totalProfitRate: number;
|
||||||
|
netAssetAmount: number;
|
||||||
|
evaluationAmount: number;
|
||||||
|
purchaseAmount: number;
|
||||||
|
loanAmount: number;
|
||||||
|
apiReportedTotalAmount: number;
|
||||||
|
apiReportedNetAssetAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 보유 종목 항목
|
||||||
|
*/
|
||||||
|
export interface DashboardHoldingItem {
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
market: DashboardMarket;
|
||||||
|
quantity: number;
|
||||||
|
averagePrice: number;
|
||||||
|
currentPrice: number;
|
||||||
|
evaluationAmount: number;
|
||||||
|
profitLoss: number;
|
||||||
|
profitRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문/매매 공통 매수/매도 구분
|
||||||
|
*/
|
||||||
|
export type DashboardTradeSide = "buy" | "sell" | "unknown";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 주문내역 항목
|
||||||
|
*/
|
||||||
|
export interface DashboardOrderHistoryItem {
|
||||||
|
orderDate: string;
|
||||||
|
orderTime: string;
|
||||||
|
orderNo: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
side: DashboardTradeSide;
|
||||||
|
orderTypeName: string;
|
||||||
|
orderPrice: number;
|
||||||
|
orderQuantity: number;
|
||||||
|
filledQuantity: number;
|
||||||
|
filledAmount: number;
|
||||||
|
averageFilledPrice: number;
|
||||||
|
remainingQuantity: number;
|
||||||
|
isCanceled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 매매일지 항목
|
||||||
|
*/
|
||||||
|
export interface DashboardTradeJournalItem {
|
||||||
|
tradeDate: string;
|
||||||
|
symbol: string;
|
||||||
|
name: string;
|
||||||
|
side: DashboardTradeSide;
|
||||||
|
buyQuantity: number;
|
||||||
|
buyAmount: number;
|
||||||
|
sellQuantity: number;
|
||||||
|
sellAmount: number;
|
||||||
|
realizedProfit: number;
|
||||||
|
realizedRate: number;
|
||||||
|
fee: number;
|
||||||
|
tax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 매매일지 요약
|
||||||
|
*/
|
||||||
|
export interface DashboardTradeJournalSummary {
|
||||||
|
totalRealizedProfit: number;
|
||||||
|
totalRealizedRate: number;
|
||||||
|
totalBuyAmount: number;
|
||||||
|
totalSellAmount: number;
|
||||||
|
totalFee: number;
|
||||||
|
totalTax: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계좌 잔고 API 응답 모델
|
||||||
|
*/
|
||||||
|
export interface DashboardBalanceResponse {
|
||||||
|
source: "kis";
|
||||||
|
tradingEnv: KisTradingEnv;
|
||||||
|
summary: DashboardBalanceSummary;
|
||||||
|
holdings: DashboardHoldingItem[];
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시장 지수 항목
|
||||||
|
*/
|
||||||
|
export interface DashboardMarketIndexItem {
|
||||||
|
market: DashboardMarket;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
changeRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시장 지수 API 응답 모델
|
||||||
|
*/
|
||||||
|
export interface DashboardIndicesResponse {
|
||||||
|
source: "kis";
|
||||||
|
tradingEnv: KisTradingEnv;
|
||||||
|
items: DashboardMarketIndexItem[];
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문내역/매매일지 API 응답 모델
|
||||||
|
*/
|
||||||
|
export interface DashboardActivityResponse {
|
||||||
|
source: "kis";
|
||||||
|
tradingEnv: KisTradingEnv;
|
||||||
|
orders: DashboardOrderHistoryItem[];
|
||||||
|
tradeJournal: DashboardTradeJournalItem[];
|
||||||
|
journalSummary: DashboardTradeJournalSummary;
|
||||||
|
warnings: string[];
|
||||||
|
fetchedAt: string;
|
||||||
|
}
|
||||||
66
features/dashboard/utils/dashboard-format.ts
Normal file
66
features/dashboard/utils/dashboard-format.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* @file features/dashboard/utils/dashboard-format.ts
|
||||||
|
* @description 대시보드 숫자/색상 표현 유틸
|
||||||
|
*/
|
||||||
|
|
||||||
|
const KRW_FORMATTER = new Intl.NumberFormat("ko-KR");
|
||||||
|
const PERCENT_FORMATTER = new Intl.NumberFormat("ko-KR", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원화 금액을 포맷합니다.
|
||||||
|
* @param value 숫자 값
|
||||||
|
* @returns 쉼표 포맷 문자열
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 자산/손익 금액 표시
|
||||||
|
*/
|
||||||
|
export function formatCurrency(value: number) {
|
||||||
|
return KRW_FORMATTER.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 퍼센트 값을 포맷합니다.
|
||||||
|
* @param value 숫자 값
|
||||||
|
* @returns 소수점 2자리 퍼센트 문자열
|
||||||
|
* @see features/dashboard/components/StatusHeader.tsx 수익률 표시
|
||||||
|
*/
|
||||||
|
export function formatPercent(value: number) {
|
||||||
|
return `${PERCENT_FORMATTER.format(value)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값의 부호를 포함한 금액 문자열을 만듭니다.
|
||||||
|
* @param value 숫자 값
|
||||||
|
* @returns + 또는 - 부호가 포함된 금액 문자열
|
||||||
|
* @see features/dashboard/components/MarketSummary.tsx 전일 대비 수치 표시
|
||||||
|
*/
|
||||||
|
export function formatSignedCurrency(value: number) {
|
||||||
|
if (value > 0) return `+${formatCurrency(value)}`;
|
||||||
|
if (value < 0) return `-${formatCurrency(Math.abs(value))}`;
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값의 부호를 포함한 퍼센트 문자열을 만듭니다.
|
||||||
|
* @param value 숫자 값
|
||||||
|
* @returns + 또는 - 부호가 포함된 퍼센트 문자열
|
||||||
|
* @see features/dashboard/components/MarketSummary.tsx 전일 대비율 표시
|
||||||
|
*/
|
||||||
|
export function formatSignedPercent(value: number) {
|
||||||
|
if (value > 0) return `+${formatPercent(value)}`;
|
||||||
|
if (value < 0) return `-${formatPercent(Math.abs(value))}`;
|
||||||
|
return "0.00%";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 숫자 값의 상승/하락/보합 텍스트 색상을 반환합니다.
|
||||||
|
* @param value 숫자 값
|
||||||
|
* @returns Tailwind 텍스트 클래스
|
||||||
|
* @see features/dashboard/components/HoldingsList.tsx 수익률/손익 색상 적용
|
||||||
|
*/
|
||||||
|
export function getChangeToneClass(value: number) {
|
||||||
|
if (value > 0) return "text-red-600 dark:text-red-400";
|
||||||
|
if (value < 0) return "text-blue-600 dark:text-blue-400";
|
||||||
|
return "text-muted-foreground";
|
||||||
|
}
|
||||||
62
features/dashboard/utils/kis-index-realtime.utils.ts
Normal file
62
features/dashboard/utils/kis-index-realtime.utils.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
export interface KisRealtimeIndexTick {
|
||||||
|
symbol: string; // 업종코드 (0001: KOSPI, 1001: KOSDAQ)
|
||||||
|
price: number; // 현재가
|
||||||
|
change: number; // 전일대비
|
||||||
|
changeRate: number; // 전일대비율
|
||||||
|
sign: string; // 대비부호
|
||||||
|
time: string; // 체결시간
|
||||||
|
}
|
||||||
|
|
||||||
|
const INDEX_REALTIME_TR_ID = "H0UPCNT0";
|
||||||
|
|
||||||
|
const INDEX_FIELD_INDEX = {
|
||||||
|
symbol: 0, // bstp_cls_code
|
||||||
|
time: 1, // bsop_hour
|
||||||
|
price: 2, // prpr_nmix
|
||||||
|
sign: 3, // prdy_vrss_sign
|
||||||
|
change: 4, // bstp_nmix_prdy_vrss
|
||||||
|
accumulatedVolume: 5, // acml_vol
|
||||||
|
accumulatedAmount: 6, // acml_tr_pbmn
|
||||||
|
changeRate: 9, // prdy_ctrt
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function parseKisRealtimeIndexTick(
|
||||||
|
raw: string,
|
||||||
|
): KisRealtimeIndexTick | null {
|
||||||
|
// Format: 0|H0UPCNT0|001|0001^123456^...
|
||||||
|
if (!/^([01])\|/.test(raw)) return null;
|
||||||
|
|
||||||
|
const parts = raw.split("|");
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
|
// Check TR ID
|
||||||
|
if (parts[1] !== INDEX_REALTIME_TR_ID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = parts[3].split("^");
|
||||||
|
if (values.length < 10) return null; // Ensure minimum fields exist
|
||||||
|
|
||||||
|
const symbol = values[INDEX_FIELD_INDEX.symbol];
|
||||||
|
const price = parseFloat(values[INDEX_FIELD_INDEX.price]);
|
||||||
|
const sign = values[INDEX_FIELD_INDEX.sign];
|
||||||
|
const changeRaw = parseFloat(values[INDEX_FIELD_INDEX.change]);
|
||||||
|
const changeRateRaw = parseFloat(values[INDEX_FIELD_INDEX.changeRate]);
|
||||||
|
|
||||||
|
// Adjust sign for negative values if necessary (usually API sends absolute values for change)
|
||||||
|
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
|
||||||
|
|
||||||
|
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
|
||||||
|
const changeRate = isNegative
|
||||||
|
? -Math.abs(changeRateRaw)
|
||||||
|
: Math.abs(changeRateRaw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
time: values[INDEX_FIELD_INDEX.time],
|
||||||
|
price,
|
||||||
|
change,
|
||||||
|
changeRate,
|
||||||
|
sign,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
features/dashboard/utils/kis-stock-realtime.utils.ts
Normal file
69
features/dashboard/utils/kis-stock-realtime.utils.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
export interface KisRealtimeStockTick {
|
||||||
|
symbol: string; // 종목코드
|
||||||
|
time: string; // 체결시간
|
||||||
|
currentPrice: number; // 현재가
|
||||||
|
sign: string; // 전일대비부호 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락)
|
||||||
|
change: number; // 전일대비
|
||||||
|
changeRate: number; // 전일대비율
|
||||||
|
accumulatedVolume: number; // 누적거래량
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOCK_realtime_TR_ID = "H0STCNT0";
|
||||||
|
|
||||||
|
// H0STCNT0 Output format indices based on typical KIS Realtime API
|
||||||
|
// Format: MKSC_SHRN_ISCD^STCK_CNTG_HOUR^STCK_PRPR^PRDY_VRSS_SIGN^PRDY_VRSS^PRDY_CTRT^...
|
||||||
|
const STOCK_FIELD_INDEX = {
|
||||||
|
symbol: 0, // MKSC_SHRN_ISCD
|
||||||
|
time: 1, // STCK_CNTG_HOUR
|
||||||
|
currentPrice: 2, // STCK_PRPR
|
||||||
|
sign: 3, // PRDY_VRSS_SIGN
|
||||||
|
change: 4, // PRDY_VRSS
|
||||||
|
changeRate: 5, // PRDY_CTRT
|
||||||
|
accumulatedVolume: 12, // ACML_VOL (Usually at index 12 or similar, need to be careful here)
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function parseKisRealtimeStockTick(
|
||||||
|
raw: string,
|
||||||
|
): KisRealtimeStockTick | null {
|
||||||
|
// Format: 0|H0STCNT0|001|SYMBOL^TIME^PRICE^SIGN^CHANGE^...
|
||||||
|
if (!/^([01])\|/.test(raw)) return null;
|
||||||
|
|
||||||
|
const parts = raw.split("|");
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
|
// Check TR ID
|
||||||
|
if (parts[1] !== STOCK_realtime_TR_ID) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = parts[3].split("^");
|
||||||
|
if (values.length < 6) return null; // Ensure minimum fields exist
|
||||||
|
|
||||||
|
const symbol = values[STOCK_FIELD_INDEX.symbol];
|
||||||
|
const currentPrice = parseFloat(values[STOCK_FIELD_INDEX.currentPrice]);
|
||||||
|
const sign = values[STOCK_FIELD_INDEX.sign];
|
||||||
|
const changeRaw = parseFloat(values[STOCK_FIELD_INDEX.change]);
|
||||||
|
const changeRateRaw = parseFloat(values[STOCK_FIELD_INDEX.changeRate]);
|
||||||
|
|
||||||
|
// Adjust sign for negative values if necessary
|
||||||
|
const isNegative = sign === "5" || sign === "4"; // 5: 하락, 4: 하한
|
||||||
|
|
||||||
|
const change = isNegative ? -Math.abs(changeRaw) : Math.abs(changeRaw);
|
||||||
|
const changeRate = isNegative
|
||||||
|
? -Math.abs(changeRateRaw)
|
||||||
|
: Math.abs(changeRateRaw);
|
||||||
|
|
||||||
|
// Validate numeric values
|
||||||
|
if (isNaN(currentPrice)) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbol,
|
||||||
|
time: values[STOCK_FIELD_INDEX.time],
|
||||||
|
currentPrice,
|
||||||
|
sign,
|
||||||
|
change,
|
||||||
|
changeRate,
|
||||||
|
accumulatedVolume:
|
||||||
|
parseFloat(values[STOCK_FIELD_INDEX.accumulatedVolume]) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
29
features/home/components/spline-scene.tsx
Normal file
29
features/home/components/spline-scene.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
features/kis-realtime/hooks/useKisWebSocket.ts
Normal file
51
features/kis-realtime/hooks/useKisWebSocket.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useKisWebSocketStore } from "@/features/kis-realtime/stores/kisWebSocketStore";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file features/kis-realtime/hooks/useKisWebSocket.ts
|
||||||
|
* @description KIS 실시간 데이터를 구독하기 위한 통합 훅입니다.
|
||||||
|
* 컴포넌트 마운트/언마운트 시 자동으로 구독 및 해제를 처리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type RealtimeCallback = (data: string) => void;
|
||||||
|
|
||||||
|
interface UseKisWebSocketParams {
|
||||||
|
symbol?: string; // 종목코드 (없으면 구독 안 함)
|
||||||
|
trId?: string; // 거래 ID (예: H0STCNT0)
|
||||||
|
onMessage?: RealtimeCallback; // 데이터 수신 콜백
|
||||||
|
enabled?: boolean; // 구독 활성화 여부
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKisWebSocket({
|
||||||
|
symbol,
|
||||||
|
trId,
|
||||||
|
onMessage,
|
||||||
|
enabled = true,
|
||||||
|
}: UseKisWebSocketParams) {
|
||||||
|
const { subscribe, connect, isConnected } = useKisWebSocketStore();
|
||||||
|
const callbackRef = useRef(onMessage);
|
||||||
|
|
||||||
|
// 콜백 함수가 바뀌어도 재구독하지 않도록 ref 사용
|
||||||
|
useEffect(() => {
|
||||||
|
callbackRef.current = onMessage;
|
||||||
|
}, [onMessage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !symbol || !trId) return;
|
||||||
|
|
||||||
|
// 연결 시도 (이미 연결 중이면 스토어에서 무시됨)
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// 구독 요청
|
||||||
|
const unsubscribe = subscribe(trId, symbol, (data) => {
|
||||||
|
callbackRef.current?.(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 언마운트 시 구독 해제
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, [symbol, trId, enabled, connect, subscribe]);
|
||||||
|
|
||||||
|
return { isConnected };
|
||||||
|
}
|
||||||
386
features/kis-realtime/stores/kisWebSocketStore.ts
Normal file
386
features/kis-realtime/stores/kisWebSocketStore.ts
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import { buildKisRealtimeMessage } from "@/features/kis-realtime/utils/websocketUtils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @file features/kis-realtime/stores/kisWebSocketStore.ts
|
||||||
|
* @description KIS 실시간 웹소켓 연결을 전역에서 하나로 관리하는 스토어입니다.
|
||||||
|
* 중복 연결을 방지하고, 여러 컴포넌트에서 동일한 데이터를 구독할 때 효율적으로 처리합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type RealtimeCallback = (data: string) => void;
|
||||||
|
|
||||||
|
interface KisWebSocketState {
|
||||||
|
isConnected: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹소켓 연결을 수립합니다.
|
||||||
|
* 이미 연결되어 있거나 연결 중이면 무시합니다.
|
||||||
|
*/
|
||||||
|
connect: (options?: { forceApprovalRefresh?: boolean }) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹소켓 연결을 강제로 재시작합니다.
|
||||||
|
* 필요 시 승인키를 새로 발급받아 재연결합니다.
|
||||||
|
*/
|
||||||
|
reconnect: (options?: { refreshApproval?: boolean }) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 웹소켓 연결을 종료합니다.
|
||||||
|
* 모든 구독이 해제됩니다.
|
||||||
|
*/
|
||||||
|
disconnect: () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 TR ID와 종목 코드로 실시간 데이터를 구독합니다.
|
||||||
|
* @param trId 거래 ID (예: H0STCNT0)
|
||||||
|
* @param symbol 종목 코드 (예: 005930)
|
||||||
|
* @param callback 데이터 수신 시 실행할 콜백 함수
|
||||||
|
* @returns 구독 해제 함수 (useEffect cleanup에서 호출하세요)
|
||||||
|
*/
|
||||||
|
subscribe: (
|
||||||
|
trId: string,
|
||||||
|
symbol: string,
|
||||||
|
callback: RealtimeCallback,
|
||||||
|
) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구독자 목록 관리 (Key: "TR_ID|SYMBOL", Value: Set<Callback>)
|
||||||
|
// 스토어 외부 변수로 관리하여 불필요한 리렌더링을 방지합니다.
|
||||||
|
const subscribers = new Map<string, Set<RealtimeCallback>>();
|
||||||
|
const subscriberCounts = new Map<string, number>(); // 실제 소켓 구독 요청 여부 추적용
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null;
|
||||||
|
let pingInterval: number | undefined;
|
||||||
|
let isConnecting = false; // 연결 진행 중 상태 잠금
|
||||||
|
let reconnectRetryTimer: number | undefined;
|
||||||
|
let lastAppKeyConflictAt = 0;
|
||||||
|
|
||||||
|
export const useKisWebSocketStore = create<KisWebSocketState>((set, get) => ({
|
||||||
|
isConnected: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
connect: async (options) => {
|
||||||
|
const forceApprovalRefresh = options?.forceApprovalRefresh ?? false;
|
||||||
|
const currentSocket = socket;
|
||||||
|
|
||||||
|
if (currentSocket?.readyState === WebSocket.CLOSING) {
|
||||||
|
await waitForSocketClose(currentSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 이미 연결되어 있거나, 연결 시도 중이면 중복 실행 방지
|
||||||
|
if (
|
||||||
|
socket?.readyState === WebSocket.OPEN ||
|
||||||
|
socket?.readyState === WebSocket.CONNECTING ||
|
||||||
|
socket?.readyState === WebSocket.CLOSING ||
|
||||||
|
isConnecting
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isConnecting = true;
|
||||||
|
const { getOrFetchWsConnection, clearWsConnectionCache } =
|
||||||
|
useKisRuntimeStore.getState();
|
||||||
|
if (forceApprovalRefresh) {
|
||||||
|
clearWsConnectionCache();
|
||||||
|
}
|
||||||
|
const wsConnection = await getOrFetchWsConnection();
|
||||||
|
|
||||||
|
// 비동기 대기 중에 다른 연결이 성사되었는지 다시 확인
|
||||||
|
if (
|
||||||
|
socket?.readyState === WebSocket.OPEN ||
|
||||||
|
socket?.readyState === WebSocket.CONNECTING
|
||||||
|
) {
|
||||||
|
isConnecting = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wsConnection) {
|
||||||
|
throw new Error("웹소켓 접속 키 발급에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 소켓 생성
|
||||||
|
// socket 변수에 할당하기 전에 로컬 변수로 제어하여 이벤트 핸들러 클로저 문제 방지
|
||||||
|
const ws = new WebSocket(`${wsConnection.wsUrl}/tryitout`);
|
||||||
|
socket = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
isConnecting = false;
|
||||||
|
// socket 변수가 다른 인스턴스로 바뀌었을 가능성은 낮지만(락 때문),
|
||||||
|
// 안전을 위해 이벤트 발생 주체인 ws를 사용 또는 현재 socket 확인
|
||||||
|
if (socket !== ws) return;
|
||||||
|
|
||||||
|
set({ isConnected: true, error: null });
|
||||||
|
console.log("[KisWebSocket] Connected");
|
||||||
|
|
||||||
|
// 재연결 시 기존 구독 복구
|
||||||
|
const approvalKey = wsConnection.approvalKey;
|
||||||
|
if (approvalKey) {
|
||||||
|
subscriberCounts.forEach((_, key) => {
|
||||||
|
const [trId, symbol] = key.split("|");
|
||||||
|
|
||||||
|
// OPEN 상태일 때만 전송
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
sendSubscription(ws, approvalKey, trId, symbol, "1"); // 구독
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PINGPONG (Keep-alive)
|
||||||
|
window.clearInterval(pingInterval);
|
||||||
|
pingInterval = window.setInterval(() => {
|
||||||
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send("PINGPONG"); // 일부 환경에서는 PINGPONG 텍스트 전송
|
||||||
|
}
|
||||||
|
}, 100_000); // 100초 주기
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (socket === ws) {
|
||||||
|
isConnecting = false;
|
||||||
|
set({ isConnected: false });
|
||||||
|
console.log("[KisWebSocket] Disconnected");
|
||||||
|
window.clearInterval(pingInterval);
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (event) => {
|
||||||
|
if (socket === ws) {
|
||||||
|
isConnecting = false;
|
||||||
|
console.error("[KisWebSocket] Error", event);
|
||||||
|
set({
|
||||||
|
isConnected: false,
|
||||||
|
error: "웹소켓 연결 중 오류가 발생했습니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = event.data;
|
||||||
|
if (typeof data !== "string") return;
|
||||||
|
|
||||||
|
// PINGPONG 응답 또는 제어 메시지 처리
|
||||||
|
if (data.startsWith("{")) {
|
||||||
|
const control = parseControlMessage(data);
|
||||||
|
if (control?.rt_cd && control.rt_cd !== "0") {
|
||||||
|
const errorMessage = buildControlErrorMessage(control);
|
||||||
|
set({
|
||||||
|
error: errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
// KIS 제어 메시지: ALREADY IN USE appkey
|
||||||
|
// 이전 세션이 닫히기 전에 재연결될 때 간헐적으로 발생합니다.
|
||||||
|
if (control.msg_cd === "OPSP8996") {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastAppKeyConflictAt > 5_000) {
|
||||||
|
lastAppKeyConflictAt = now;
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
reconnectRetryTimer = window.setTimeout(() => {
|
||||||
|
void get().reconnect({ refreshApproval: false });
|
||||||
|
}, 1_200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data[0] === "0" || data[0] === "1") {
|
||||||
|
// 데이터 포맷: 0|TR_ID|KEY|...
|
||||||
|
const parts = data.split("|");
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
const trId = parts[1];
|
||||||
|
// 데이터 부분 (마지막 부분)에서 종목코드를 찾아야 함.
|
||||||
|
// 하지만 응답에는 종목코드가 명시적으로 없는 경우가 많음 (순서로 추론).
|
||||||
|
// 다행히 KIS API는 요청했던 TR_ID와 수신된 데이터의 호가/체결 데이터를 매핑해야 함.
|
||||||
|
// 여기서는 모든 구독자에게 브로드캐스트하는 방식을 사용 (TR_ID 기준).
|
||||||
|
|
||||||
|
// 더 정확한 라우팅을 위해:
|
||||||
|
// 실시간 체결/호가 데이터에는 종목코드가 포함되어 있음.
|
||||||
|
// 체결(H0STCNT0): data.split("^")[0] (유가증권 단축종목코드)
|
||||||
|
const body = parts[3];
|
||||||
|
const values = body.split("^");
|
||||||
|
const symbol = values[0]; // 대부분 첫 번째 필드가 종목코드
|
||||||
|
|
||||||
|
const key = `${trId}|${symbol}`;
|
||||||
|
const callbacks = subscribers.get(key);
|
||||||
|
callbacks?.forEach((cb) => cb(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
isConnecting = false;
|
||||||
|
set({
|
||||||
|
isConnected: false,
|
||||||
|
error: err instanceof Error ? err.message : "연결 실패",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reconnect: async (options) => {
|
||||||
|
const refreshApproval = options?.refreshApproval ?? false;
|
||||||
|
const currentSocket = socket;
|
||||||
|
get().disconnect();
|
||||||
|
if (currentSocket && currentSocket.readyState !== WebSocket.CLOSED) {
|
||||||
|
await waitForSocketClose(currentSocket);
|
||||||
|
}
|
||||||
|
await get().connect({
|
||||||
|
forceApprovalRefresh: refreshApproval,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect: () => {
|
||||||
|
const currentSocket = socket;
|
||||||
|
if (
|
||||||
|
currentSocket &&
|
||||||
|
(currentSocket.readyState === WebSocket.OPEN ||
|
||||||
|
currentSocket.readyState === WebSocket.CONNECTING ||
|
||||||
|
currentSocket.readyState === WebSocket.CLOSING)
|
||||||
|
) {
|
||||||
|
currentSocket.close();
|
||||||
|
}
|
||||||
|
if (currentSocket?.readyState === WebSocket.CLOSED && socket === currentSocket) {
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
set({ isConnected: false });
|
||||||
|
window.clearInterval(pingInterval);
|
||||||
|
window.clearTimeout(reconnectRetryTimer);
|
||||||
|
isConnecting = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: (trId, symbol, callback) => {
|
||||||
|
const key = `${trId}|${symbol}`;
|
||||||
|
|
||||||
|
// 1. 구독자 목록에 추가
|
||||||
|
if (!subscribers.has(key)) {
|
||||||
|
subscribers.set(key, new Set());
|
||||||
|
}
|
||||||
|
subscribers.get(key)!.add(callback);
|
||||||
|
|
||||||
|
// 2. 소켓 서버에 구독 요청 (첫 번째 구독자인 경우)
|
||||||
|
const currentCount = subscriberCounts.get(key) || 0;
|
||||||
|
if (currentCount === 0) {
|
||||||
|
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
||||||
|
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
||||||
|
sendSubscription(socket, wsApprovalKey, trId, symbol, "1"); // "1": 등록
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subscriberCounts.set(key, currentCount + 1);
|
||||||
|
|
||||||
|
// **연결이 안 되어 있으면 연결 시도**
|
||||||
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||||
|
get().connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 구독 해제 함수 반환
|
||||||
|
return () => {
|
||||||
|
const callbacks = subscribers.get(key);
|
||||||
|
if (callbacks) {
|
||||||
|
callbacks.delete(callback);
|
||||||
|
if (callbacks.size === 0) {
|
||||||
|
subscribers.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = subscriberCounts.get(key) || 0;
|
||||||
|
if (count > 0) {
|
||||||
|
subscriberCounts.set(key, count - 1);
|
||||||
|
if (count - 1 === 0) {
|
||||||
|
// 마지막 구독자가 사라지면 소켓 구독 해제
|
||||||
|
const { wsApprovalKey } = useKisRuntimeStore.getState();
|
||||||
|
if (socket?.readyState === WebSocket.OPEN && wsApprovalKey) {
|
||||||
|
sendSubscription(socket, wsApprovalKey, trId, symbol, "2"); // "2": 해제
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 헬퍼: 구독/해제 메시지 전송
|
||||||
|
function sendSubscription(
|
||||||
|
ws: WebSocket,
|
||||||
|
appKey: string,
|
||||||
|
trId: string,
|
||||||
|
symbol: string,
|
||||||
|
trType: "1" | "2",
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const msg = buildKisRealtimeMessage(appKey, symbol, trId, trType);
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
console.debug(
|
||||||
|
`[KisWebSocket] ${trType === "1" ? "Sub" : "Unsub"} ${trId} ${symbol}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[KisWebSocket] Send error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KisWsControlMessage {
|
||||||
|
rt_cd?: string;
|
||||||
|
msg_cd?: string;
|
||||||
|
msg1?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 웹소켓 제어 메시지(JSON)를 파싱합니다.
|
||||||
|
* @param rawData 원본 메시지 문자열
|
||||||
|
* @returns 파싱된 제어 메시지 또는 null
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
||||||
|
*/
|
||||||
|
function parseControlMessage(rawData: string): KisWsControlMessage | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawData) as KisWsControlMessage;
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 웹소켓 제어 오류를 사용자용 짧은 문구로 변환합니다.
|
||||||
|
* @param message KIS 제어 메시지
|
||||||
|
* @returns 표시용 오류 문자열
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts ws.onmessage
|
||||||
|
*/
|
||||||
|
function buildControlErrorMessage(message: KisWsControlMessage) {
|
||||||
|
if (message.msg_cd === "OPSP8996") {
|
||||||
|
return "실시간 연결이 다른 세션과 충돌해 재연결을 시도합니다.";
|
||||||
|
}
|
||||||
|
const detail = [message.msg1, message.msg_cd].filter(Boolean).join(" / ");
|
||||||
|
return detail ? `실시간 제어 메시지 오류: ${detail}` : "실시간 제어 메시지 오류";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 특정 웹소켓 인스턴스가 완전히 닫힐 때까지 대기합니다.
|
||||||
|
* @param target 대기할 웹소켓 인스턴스
|
||||||
|
* @param timeoutMs 최대 대기 시간(ms)
|
||||||
|
* @returns close/error/timeout 중 먼저 완료되면 resolve
|
||||||
|
* @see features/kis-realtime/stores/kisWebSocketStore.ts connect/reconnect
|
||||||
|
*/
|
||||||
|
function waitForSocketClose(target: WebSocket, timeoutMs = 2_000) {
|
||||||
|
if (target.readyState === WebSocket.CLOSED) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const onClose = () => finish();
|
||||||
|
const onError = () => finish();
|
||||||
|
const timeoutId = window.setTimeout(() => finish(), timeoutMs);
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
target.removeEventListener("close", onClose);
|
||||||
|
target.removeEventListener("error", onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
target.addEventListener("close", onClose);
|
||||||
|
target.addEventListener("error", onError);
|
||||||
|
});
|
||||||
|
}
|
||||||
29
features/kis-realtime/utils/websocketUtils.ts
Normal file
29
features/kis-realtime/utils/websocketUtils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* @file features/kis-realtime/utils/websocketUtils.ts
|
||||||
|
* @description KIS 웹소켓 메시지 생성 및 파싱 관련 유틸리티 함수 모음
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
108
features/layout/components/Logo.tsx
Normal file
108
features/layout/components/Logo.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
variant?: "symbol" | "full";
|
||||||
|
/** 배경과 섞이는 모드 (홈 화면 등). 로고가 흰색으로 표시됩니다. */
|
||||||
|
blendWithBackground?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Logo({
|
||||||
|
className,
|
||||||
|
variant = "full",
|
||||||
|
blendWithBackground = false,
|
||||||
|
}: LogoProps) {
|
||||||
|
// 색상 클래스 정의
|
||||||
|
const mainColorClass = blendWithBackground
|
||||||
|
? "fill-brand-500 stroke-brand-500" // 배경 혼합 모드에서도 심볼은 브랜드 컬러 유지
|
||||||
|
: "fill-brand-600 stroke-brand-600 dark:fill-brand-500 dark:stroke-brand-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative flex items-center gap-2 select-none", className)}
|
||||||
|
aria-label="Jurini Logo"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={cn(
|
||||||
|
"shrink-0",
|
||||||
|
variant === "full" ? "h-10 w-10" : "h-full w-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Mask for the cutout effect around the arrow */}
|
||||||
|
<mask id="arrow-cutout">
|
||||||
|
<rect width="100%" height="100%" fill="white" />
|
||||||
|
<path
|
||||||
|
d="M10 75 C 35 45, 55 85, 90 25"
|
||||||
|
fill="none"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="12"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Arrow Head Cutout */}
|
||||||
|
<path
|
||||||
|
d="M90 25 L 78 32 L 85 42 Z"
|
||||||
|
fill="black"
|
||||||
|
stroke="black"
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
transform="rotate(-15 90 25)"
|
||||||
|
/>
|
||||||
|
</mask>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* ========== BARS (Masked) ========== */}
|
||||||
|
<g
|
||||||
|
mask="url(#arrow-cutout)"
|
||||||
|
className={
|
||||||
|
blendWithBackground
|
||||||
|
? "fill-brand-500" // 배경 혼합 모드에서도 브랜드 컬러 사용
|
||||||
|
: "fill-brand-600 dark:fill-brand-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Bar 1 (Left, Short) */}
|
||||||
|
<rect x="15" y="45" width="18" height="40" rx="4" />
|
||||||
|
{/* Bar 2 (Middle, Medium) */}
|
||||||
|
<rect x="41" y="30" width="18" height="55" rx="4" />
|
||||||
|
{/* Bar 3 (Right, Tall) */}
|
||||||
|
<rect x="67" y="10" width="18" height="75" rx="4" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* ========== ARROW (Foreground) ========== */}
|
||||||
|
<g className={mainColorClass}>
|
||||||
|
{/* Arrow Path */}
|
||||||
|
<path
|
||||||
|
d="M10 75 C 35 45, 55 85, 90 25"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="7"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
{/* Arrow Head */}
|
||||||
|
<path
|
||||||
|
d="M90 25 L 78 32 L 85 42 Z"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
transform="rotate(-15 90 25)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* ========== TEXT (Optional) ========== */}
|
||||||
|
{variant === "full" && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-bold tracking-tight",
|
||||||
|
blendWithBackground
|
||||||
|
? "text-white opacity-95"
|
||||||
|
: "text-brand-900 dark:text-brand-50",
|
||||||
|
)}
|
||||||
|
style={{ fontSize: "1.35rem", fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
JOORIN-E
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
features/layout/components/header.tsx
Normal file
143
features/layout/components/header.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* @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";
|
||||||
|
import { Logo } from "@/features/layout/components/Logo";
|
||||||
|
|
||||||
|
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 ========== */}
|
||||||
|
{/* ========== LEFT: LOGO SECTION ========== */}
|
||||||
|
<Link href={AUTH_ROUTES.HOME} className="group flex items-center gap-2">
|
||||||
|
<Logo
|
||||||
|
variant="full"
|
||||||
|
className="h-10 text-xl transition-transform duration-200 group-hover:scale-105"
|
||||||
|
blendWithBackground={blendWithBackground}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
features/layout/components/sidebar.tsx
Normal file
212
features/layout/components/sidebar.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
BarChart2,
|
||||||
|
ChevronLeft,
|
||||||
|
Home,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Wallet,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MenuItem } from "../types";
|
||||||
|
|
||||||
|
const MENU_ITEMS: MenuItem[] = [
|
||||||
|
{
|
||||||
|
title: "대시보드",
|
||||||
|
href: "/dashboard",
|
||||||
|
icon: Home,
|
||||||
|
variant: "default",
|
||||||
|
matchExact: true,
|
||||||
|
showInBottomNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "자동매매",
|
||||||
|
href: "/trade",
|
||||||
|
icon: BarChart2,
|
||||||
|
variant: "ghost",
|
||||||
|
badge: "LIVE",
|
||||||
|
showInBottomNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "자산현황",
|
||||||
|
href: "/assets",
|
||||||
|
icon: Wallet,
|
||||||
|
variant: "ghost",
|
||||||
|
showInBottomNav: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "프로필",
|
||||||
|
href: "/profile",
|
||||||
|
icon: User,
|
||||||
|
variant: "ghost",
|
||||||
|
showInBottomNav: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "설정",
|
||||||
|
href: "/settings",
|
||||||
|
icon: Settings,
|
||||||
|
variant: "ghost",
|
||||||
|
showInBottomNav: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
|
||||||
|
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
|
||||||
|
*/
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
"relative hidden h-[calc(100vh-4rem)] shrink-0 overflow-x-visible overflow-y-auto border-r border-brand-100 bg-white px-2 py-5 transition-[width] duration-200 dark:border-brand-900/40 dark:bg-background md:sticky md:top-16 md:block",
|
||||||
|
isExpanded ? "md:w-64" : "md:w-[74px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded((prev) => !prev)}
|
||||||
|
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
||||||
|
className={cn(
|
||||||
|
"absolute -right-3 top-20 z-50 hidden h-8 w-8 items-center justify-center rounded-full",
|
||||||
|
"border border-zinc-200/50 bg-white/80 shadow-lg backdrop-blur-md transition-all duration-300",
|
||||||
|
"hover:scale-110 hover:bg-white active:scale-95",
|
||||||
|
"dark:border-zinc-800/50 dark:bg-zinc-900/80 dark:hover:bg-zinc-900",
|
||||||
|
"md:flex",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ChevronLeft
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4 text-zinc-600 transition-transform duration-300 dark:text-zinc-300",
|
||||||
|
isExpanded ? "rotate-0" : "rotate-180",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-1.5" />
|
||||||
|
{/* ========== SIDEBAR ITEMS ========== */}
|
||||||
|
<div className="flex flex-col space-y-1.5">
|
||||||
|
{MENU_ITEMS.map((item) => {
|
||||||
|
const isActive = item.matchExact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
title={item.title}
|
||||||
|
className={cn(
|
||||||
|
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
||||||
|
"hover:bg-brand-50 hover:text-brand-800 dark:hover:bg-brand-900/30 dark:hover:text-brand-100",
|
||||||
|
isActive
|
||||||
|
? "bg-brand-100 text-brand-800 shadow-sm dark:bg-brand-900/40 dark:text-brand-100"
|
||||||
|
: "text-muted-foreground dark:text-brand-200/80",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* ========== ACTIVE BAR ========== */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
|
||||||
|
isActive ? "w-1.5 bg-brand-500" : "w-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ========== ICON + DOT BADGE ========== */}
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
"h-5 w-5 shrink-0 transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-brand-700 dark:text-brand-200"
|
||||||
|
: "text-zinc-400 group-hover/item:text-brand-700 dark:text-brand-300/70 dark:group-hover/item:text-brand-200",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{item.badge && !isExpanded && (
|
||||||
|
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ========== LABEL (EXPAND ON TOGGLE) ========== */}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200",
|
||||||
|
isExpanded
|
||||||
|
? "max-w-[180px] opacity-100"
|
||||||
|
: "max-w-0 opacity-0",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate font-medium">{item.title}</span>
|
||||||
|
{item.badge && (
|
||||||
|
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 모바일 하단 빠른 탭 네비게이션.
|
||||||
|
* @see features/layout/components/sidebar.tsx Sidebar와 같은 MENU_ITEMS를 공유해 중복 정의를 줄입니다.
|
||||||
|
*/
|
||||||
|
export function MobileBottomNav() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const bottomItems = MENU_ITEMS.filter(
|
||||||
|
(item) => item.showInBottomNav !== false,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="모바일 빠른 메뉴"
|
||||||
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-brand-100 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-brand-900/40 dark:bg-background/95 dark:supports-backdrop-filter:bg-background/80 md:hidden"
|
||||||
|
>
|
||||||
|
{/* ========== BOTTOM NAV ITEMS ========== */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"grid",
|
||||||
|
bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{bottomItems.map((item) => {
|
||||||
|
const isActive = item.matchExact
|
||||||
|
? pathname === item.href
|
||||||
|
: pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`bottom-${item.href}`}
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "text-brand-700"
|
||||||
|
: "text-muted-foreground hover:text-brand-700 dark:text-brand-200/80 dark:hover:text-brand-200",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="relative">
|
||||||
|
<item.icon
|
||||||
|
className={cn("h-4 w-4", isActive && "text-brand-600")}
|
||||||
|
/>
|
||||||
|
{item.badge && (
|
||||||
|
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="leading-none">{item.title}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
features/layout/components/user-menu.tsx
Normal file
123
features/layout/components/user-menu.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* @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";
|
||||||
|
|
||||||
|
const SESSION_RELATED_STORAGE_KEYS = [
|
||||||
|
"session-storage",
|
||||||
|
"auth-storage",
|
||||||
|
"autotrade-kis-runtime-store",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 로그아웃 제출 직전에 세션 관련 로컬 스토리지를 정리합니다.
|
||||||
|
* @see features/auth/actions.ts signout - 서버 세션 종료를 담당합니다.
|
||||||
|
*/
|
||||||
|
const clearSessionRelatedStorage = () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
for (const key of SESSION_RELATED_STORAGE_KEYS) {
|
||||||
|
window.localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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} onSubmit={clearSessionRelatedStorage}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
features/layout/types/index.ts
Normal file
11
features/layout/types/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface MenuItem {
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
variant: "default" | "ghost";
|
||||||
|
matchExact?: boolean;
|
||||||
|
badge?: string;
|
||||||
|
showInBottomNav?: boolean;
|
||||||
|
}
|
||||||
97
features/settings/apis/kis-auth.api.ts
Normal file
97
features/settings/apis/kis-auth.api.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import type { KisRuntimeCredentials } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import type {
|
||||||
|
DashboardKisProfileValidateResponse,
|
||||||
|
DashboardKisRevokeResponse,
|
||||||
|
DashboardKisValidateResponse,
|
||||||
|
DashboardKisWsApprovalResponse,
|
||||||
|
} from "@/features/trade/types/trade.types";
|
||||||
|
|
||||||
|
interface KisApiBaseResponse {
|
||||||
|
ok: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postKisAuthApi<T extends KisApiBaseResponse>(
|
||||||
|
endpoint: string,
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
fallbackErrorMessage: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(credentials),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as T;
|
||||||
|
|
||||||
|
if (!response.ok || !payload.ok) {
|
||||||
|
throw new Error(payload.message || fallbackErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS API 키를 검증합니다.
|
||||||
|
* @see app/api/kis/validate/route.ts
|
||||||
|
*/
|
||||||
|
export async function validateKisCredentials(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardKisValidateResponse> {
|
||||||
|
return postKisAuthApi<DashboardKisValidateResponse>(
|
||||||
|
"/api/kis/validate",
|
||||||
|
credentials,
|
||||||
|
"앱키 검증에 실패했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description KIS 액세스 토큰을 폐기합니다.
|
||||||
|
* @see app/api/kis/revoke/route.ts
|
||||||
|
*/
|
||||||
|
export async function revokeKisCredentials(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardKisRevokeResponse> {
|
||||||
|
return postKisAuthApi<DashboardKisRevokeResponse>(
|
||||||
|
"/api/kis/revoke",
|
||||||
|
credentials,
|
||||||
|
"API 토큰 폐기에 실패했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 웹소켓 승인키와 WS URL을 조회합니다.
|
||||||
|
* @see app/api/kis/ws/approval/route.ts
|
||||||
|
*/
|
||||||
|
export async function fetchKisWebSocketApproval(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardKisWsApprovalResponse> {
|
||||||
|
const payload = await postKisAuthApi<DashboardKisWsApprovalResponse>(
|
||||||
|
"/api/kis/ws/approval",
|
||||||
|
credentials,
|
||||||
|
"웹소켓 승인키 발급에 실패했습니다.",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payload.approvalKey || !payload.wsUrl) {
|
||||||
|
throw new Error(payload.message || "웹소켓 연결 정보가 누락되었습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 계좌번호를 검증합니다.
|
||||||
|
* @see app/api/kis/validate-profile/route.ts
|
||||||
|
*/
|
||||||
|
export async function validateKisProfile(
|
||||||
|
credentials: KisRuntimeCredentials,
|
||||||
|
): Promise<DashboardKisProfileValidateResponse> {
|
||||||
|
return postKisAuthApi<DashboardKisProfileValidateResponse>(
|
||||||
|
"/api/kis/validate-profile",
|
||||||
|
credentials,
|
||||||
|
"계좌 검증에 실패했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
310
features/settings/components/KisAuthForm.tsx
Normal file
310
features/settings/components/KisAuthForm.tsx
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useKisRuntimeStore } from "@/features/settings/store/use-kis-runtime-store";
|
||||||
|
import {
|
||||||
|
revokeKisCredentials,
|
||||||
|
validateKisCredentials,
|
||||||
|
} from "@/features/settings/apis/kis-auth.api";
|
||||||
|
import {
|
||||||
|
KeyRound,
|
||||||
|
ShieldCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Lock,
|
||||||
|
Link2,
|
||||||
|
Unlink2,
|
||||||
|
Activity,
|
||||||
|
Zap,
|
||||||
|
KeySquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
import { InlineSpinner } from "@/components/ui/loading-spinner";
|
||||||
|
import { SettingsCard } from "./SettingsCard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 한국투자증권 앱키/앱시크릿키 인증 폼입니다.
|
||||||
|
* @remarks UI 흐름: /settings -> 앱키/앱시크릿키 입력 -> 연결 확인 버튼 -> /api/kis/validate -> 연결 상태 반영
|
||||||
|
* @see app/api/kis/validate/route.ts 앱키 검증 API
|
||||||
|
* @see features/settings/store/use-kis-runtime-store.ts 인증 상태 저장소
|
||||||
|
*/
|
||||||
|
export function KisAuthForm() {
|
||||||
|
const {
|
||||||
|
kisTradingEnvInput,
|
||||||
|
kisAppKeyInput,
|
||||||
|
kisAppSecretInput,
|
||||||
|
verifiedAccountNo,
|
||||||
|
verifiedCredentials,
|
||||||
|
isKisVerified,
|
||||||
|
setKisTradingEnvInput,
|
||||||
|
setKisAppKeyInput,
|
||||||
|
setKisAppSecretInput,
|
||||||
|
setVerifiedKisSession,
|
||||||
|
invalidateKisVerification,
|
||||||
|
clearKisRuntimeSession,
|
||||||
|
} = useKisRuntimeStore(
|
||||||
|
useShallow((state) => ({
|
||||||
|
kisTradingEnvInput: state.kisTradingEnvInput,
|
||||||
|
kisAppKeyInput: state.kisAppKeyInput,
|
||||||
|
kisAppSecretInput: state.kisAppSecretInput,
|
||||||
|
verifiedAccountNo: state.verifiedAccountNo,
|
||||||
|
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("앱키와 앱시크릿키를 모두 입력해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentials = {
|
||||||
|
appKey,
|
||||||
|
appSecret,
|
||||||
|
tradingEnv: kisTradingEnvInput,
|
||||||
|
accountNo: verifiedAccountNo ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await validateKisCredentials(credentials);
|
||||||
|
setVerifiedKisSession(credentials, result.tradingEnv);
|
||||||
|
setStatusMessage(
|
||||||
|
`${result.message} (${result.tradingEnv === "real" ? "실전" : "모의"})`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
invalidateKisVerification();
|
||||||
|
setErrorMessage(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "앱키 확인 중 오류가 발생했습니다.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<SettingsCard
|
||||||
|
icon={KeyRound}
|
||||||
|
title="한국투자증권 앱키 연결"
|
||||||
|
description="Open API에서 발급받은 앱키와 앱시크릿키를 입력해 연결을 완료하세요."
|
||||||
|
badge={
|
||||||
|
isKisVerified ? (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 ring-1 ring-green-600/20 dark:bg-green-500/10 dark:text-green-400 dark:ring-green-500/30">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
연결됨
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
footer={{
|
||||||
|
actions: (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleValidate}
|
||||||
|
disabled={
|
||||||
|
isValidating ||
|
||||||
|
!kisAppKeyInput.trim() ||
|
||||||
|
!kisAppSecretInput.trim()
|
||||||
|
}
|
||||||
|
className="h-9 rounded-lg bg-brand-600 px-4 text-xs font-semibold text-white shadow-sm transition-all hover:bg-brand-700 hover:shadow disabled:opacity-50 disabled:shadow-none dark:bg-brand-600 dark:hover:bg-brand-500"
|
||||||
|
>
|
||||||
|
{isValidating ? (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<InlineSpinner className="h-3 w-3 text-white" />
|
||||||
|
검증 중
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-brand-100" />
|
||||||
|
앱키 연결 확인
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isKisVerified && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleRevoke}
|
||||||
|
disabled={isRevoking}
|
||||||
|
className="h-9 rounded-lg border-zinc-200 bg-white px-4 text-xs text-zinc-600 hover:bg-zinc-50 hover:text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
|
||||||
|
>
|
||||||
|
{isRevoking ? (
|
||||||
|
"해제 중"
|
||||||
|
) : (
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Unlink2 className="h-3.5 w-3.5" />
|
||||||
|
연결 해제
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
status: (
|
||||||
|
<div className="flex min-h-5 items-center justify-start sm:justify-end">
|
||||||
|
{errorMessage && (
|
||||||
|
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-red-500">
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
{errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{statusMessage && (
|
||||||
|
<p className="animate-in fade-in slide-in-from-right-4 flex items-center gap-1.5 text-xs font-semibold text-brand-600 dark:text-brand-400">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||||
|
{statusMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!errorMessage && !statusMessage && !isKisVerified && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-zinc-400 dark:text-zinc-600">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-zinc-300 dark:bg-zinc-700" />
|
||||||
|
미연결 상태입니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
className="h-full"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ========== TRADING MODE ========== */}
|
||||||
|
<section className="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/30">
|
||||||
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-semibold text-zinc-700 dark:text-zinc-200">
|
||||||
|
<ShieldCheck className="h-3.5 w-3.5 text-brand-500" />
|
||||||
|
투자 모드 선택
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKisTradingEnvInput("real")}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
|
||||||
|
kisTradingEnvInput === "real"
|
||||||
|
? "border-brand-500 bg-brand-600 text-white shadow-sm"
|
||||||
|
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
실전 투자
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setKisTradingEnvInput("mock")}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 items-center justify-center gap-1.5 rounded-lg border text-xs font-semibold transition",
|
||||||
|
kisTradingEnvInput === "mock"
|
||||||
|
? "border-zinc-700 bg-zinc-800 text-white shadow-sm dark:border-zinc-500 dark:bg-zinc-700"
|
||||||
|
: "border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Activity className="h-3.5 w-3.5" />
|
||||||
|
모의 투자
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ========== APP KEY INPUTS ========== */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<CredentialInput
|
||||||
|
id="kis-app-key"
|
||||||
|
label="앱키"
|
||||||
|
placeholder="한국투자증권 앱키 입력"
|
||||||
|
value={kisAppKeyInput}
|
||||||
|
onChange={setKisAppKeyInput}
|
||||||
|
icon={KeySquare}
|
||||||
|
/>
|
||||||
|
<CredentialInput
|
||||||
|
id="kis-app-secret"
|
||||||
|
label="앱시크릿키"
|
||||||
|
placeholder="한국투자증권 앱시크릿키 입력"
|
||||||
|
value={kisAppSecretInput}
|
||||||
|
onChange={setKisAppSecretInput}
|
||||||
|
icon={Lock}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 앱키/시크릿키 입력 전용 필드 블록입니다.
|
||||||
|
* @see features/settings/components/KisAuthForm.tsx 입력 UI 렌더링
|
||||||
|
*/
|
||||||
|
function CredentialInput({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={id} className="text-xs font-semibold text-zinc-600">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
<div className="group/input flex items-center overflow-hidden rounded-xl border border-zinc-200 bg-white transition-colors focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 dark:border-zinc-700 dark:bg-zinc-900/20">
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center border-r border-zinc-100 bg-zinc-50 text-zinc-400 group-focus-within/input:text-brand-500 dark:border-zinc-800 dark:bg-zinc-800/40">
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="h-10 border-none bg-transparent px-3 text-sm shadow-none focus-visible:ring-0"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user