refactor: 코드베이스 정리 - Appwrite/Lovable 완전 제거
주요 변경사항: • Appwrite SDK 및 관련 의존성 완전 제거 • Lovable 관련 도구 및 설정 제거 • 기존 Appwrite 기반 컴포넌트 및 훅 삭제 • Login/Register 페이지를 Clerk 기반으로 완전 전환 제거된 구성요소: • src/lib/appwrite/ - 전체 디렉토리 • src/contexts/auth/ - 기존 인증 컨텍스트 • 구형 auth 컴포넌트들 (RegisterForm, LoginForm 등) • useAuthQueries, useTransactionQueries 훅 • Appwrite 기반 테스트 파일들 설정 변경: • package.json - appwrite, lovable-tagger 의존성 제거 • .env 파일 - Appwrite 환경변수 제거 • vercel.json - Supabase/Clerk 환경변수로 교체 • vite.config.ts - 청크 분할 설정 업데이트 성능 개선: • 번들 크기 최적화 (Appwrite → Clerk + Supabase) • 불필요한 코드 및 타입 정의 제거 • 테스트 설정을 Clerk/Supabase 모킹으로 업데이트 Task 11.4 완료: 기존 Appwrite 코드 완전 제거 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
9
.env
9
.env
@@ -5,15 +5,6 @@ VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFz
|
|||||||
# 데이터베이스 연결 (관리용)
|
# 데이터베이스 연결 (관리용)
|
||||||
DATABASE_URL=postgresql://postgres.qnerebtvwwfobfzdoftx:K9mP2xR7nL4wQ8vT3@aws-0-ap-northeast-2.pooler.supabase.com:5432/postgres
|
DATABASE_URL=postgresql://postgres.qnerebtvwwfobfzdoftx:K9mP2xR7nL4wQ8vT3@aws-0-ap-northeast-2.pooler.supabase.com:5432/postgres
|
||||||
|
|
||||||
# Appwrite 관련 설정
|
|
||||||
VITE_APPWRITE_ENDPOINT=https://a11.ism.kr/v1
|
|
||||||
VITE_APPWRITE_PROJECT_ID=68182a300039f6d700a6
|
|
||||||
VITE_APPWRITE_DATABASE_ID=default
|
|
||||||
VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions
|
|
||||||
# VITE_APPWRITE_API_KEY - 클라이언트에 노출되므로 제거
|
|
||||||
# API 키는 서버 사이드에서만 사용하도록 이동
|
|
||||||
|
|
||||||
VITE_DISABLE_LOVABLE_BANNER=true
|
|
||||||
|
|
||||||
# Clerk 인증 설정
|
# Clerk 인증 설정
|
||||||
VITE_CLERK_PUBLISHABLE_KEY=pk_test_am9pbnQtY2hlZXRhaC04Ni5jbGVyay5hY2NvdW50cy5kZXYk
|
VITE_CLERK_PUBLISHABLE_KEY=pk_test_am9pbnQtY2hlZXRhaC04Ni5jbGVyay5hY2NvdW50cy5kZXYk
|
||||||
|
|||||||
@@ -7,14 +7,6 @@ SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key_here
|
|||||||
# VITE_SUPABASE_URL=http://localhost:54321
|
# VITE_SUPABASE_URL=http://localhost:54321
|
||||||
# VITE_SUPABASE_ANON_KEY=your_local_supabase_anon_key
|
# VITE_SUPABASE_ANON_KEY=your_local_supabase_anon_key
|
||||||
|
|
||||||
# Appwrite 관련 설정
|
|
||||||
VITE_APPWRITE_ENDPOINT=https://your_appwrite_endpoint/v1
|
|
||||||
VITE_APPWRITE_PROJECT_ID=your_project_id
|
|
||||||
VITE_APPWRITE_DATABASE_ID=default
|
|
||||||
VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions
|
|
||||||
VITE_APPWRITE_API_KEY=your_appwrite_api_key_here
|
|
||||||
|
|
||||||
VITE_DISABLE_LOVABLE_BANNER=true
|
|
||||||
|
|
||||||
# Clerk 인증 설정
|
# Clerk 인증 설정
|
||||||
VITE_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here
|
VITE_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key_here
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
# Appwrite 전환 가이드
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
Zellyy Finance는 백엔드 서비스를 Supabase에서 Appwrite로 전환합니다. 이 문서는 전환 과정과 새로운 코드 구조에 대한 가이드를 제공합니다.
|
|
||||||
|
|
||||||
## 전환 이유
|
|
||||||
|
|
||||||
1. **더 나은 성능**: Appwrite는 경량화된 서비스로 더 빠른 응답 시간 제공
|
|
||||||
2. **확장성**: 사용자 증가에 따른 확장성 개선
|
|
||||||
3. **기능 세트**: Appwrite의 실시간 데이터베이스와 인증 시스템 활용
|
|
||||||
4. **유지보수 용이성**: 단일 백엔드 서비스로 통합하여 유지보수 간소화
|
|
||||||
|
|
||||||
## 코드 구조
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── lib/
|
|
||||||
│ ├── appwrite/ (Appwrite 서비스)
|
|
||||||
│ │ ├── index.ts (단일 진입점)
|
|
||||||
│ │ ├── client.ts (클라이언트 설정)
|
|
||||||
│ │ ├── config.ts (환경 설정)
|
|
||||||
│ │ └── setup.ts (데이터베이스 설정)
|
|
||||||
│ └── capacitor/ (네이티브 기능)
|
|
||||||
│ ├── index.ts (단일 진입점)
|
|
||||||
│ ├── buildInfo.ts (빌드 정보 관련)
|
|
||||||
│ ├── notification.ts (알림 관련)
|
|
||||||
│ └── permissions.ts (권한 관련)
|
|
||||||
├── hooks/
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ └── useAppwriteAuth.ts (인증 관련 훅)
|
|
||||||
│ └── transactions/
|
|
||||||
│ └── useAppwriteTransactions.ts (트랜잭션 관련 훅)
|
|
||||||
├── components/
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ └── AppwriteConnectionStatus.tsx (연결 상태 표시)
|
|
||||||
│ ├── migration/
|
|
||||||
│ │ └── SupabaseToAppwriteMigration.tsx (마이그레이션 도구)
|
|
||||||
│ └── native/
|
|
||||||
│ ├── PermissionRequest.tsx (권한 요청 UI)
|
|
||||||
│ └── NotificationSettings.tsx (알림 설정 UI)
|
|
||||||
└── utils/
|
|
||||||
└── appwriteTransactionUtils.ts (트랜잭션 유틸리티)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 환경 설정
|
|
||||||
|
|
||||||
`.env` 파일에 다음 환경 변수를 설정합니다:
|
|
||||||
|
|
||||||
```
|
|
||||||
# Appwrite 설정
|
|
||||||
VITE_APPWRITE_ENDPOINT=https://a11.ism.kr/v1
|
|
||||||
VITE_APPWRITE_PROJECT_ID=zellyy-finance
|
|
||||||
VITE_APPWRITE_DATABASE_ID=zellyy-finance
|
|
||||||
VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID=transactions
|
|
||||||
|
|
||||||
# 네이티브 설정
|
|
||||||
VITE_ANDROID_MIN_API_LEVEL=21
|
|
||||||
VITE_ANDROID_TARGET_API_LEVEL=33
|
|
||||||
VITE_ANDROID_NOTIFICATION_CHANNEL_ID=zellyy_finance_notifications
|
|
||||||
```
|
|
||||||
|
|
||||||
## 마이그레이션 단계
|
|
||||||
|
|
||||||
1. **데이터베이스 설정**
|
|
||||||
- Appwrite 데이터베이스 및 컬렉션 생성
|
|
||||||
- 필요한 인덱스 및 권한 설정
|
|
||||||
|
|
||||||
2. **인증 시스템 전환**
|
|
||||||
- Appwrite 인증 시스템 설정
|
|
||||||
- 사용자 계정 마이그레이션
|
|
||||||
|
|
||||||
3. **데이터 마이그레이션**
|
|
||||||
- 트랜잭션 데이터 마이그레이션
|
|
||||||
- 데이터 무결성 검증
|
|
||||||
|
|
||||||
4. **Supabase 코드 제거**
|
|
||||||
- 마이그레이션 완료 후 Supabase 관련 코드 제거
|
|
||||||
- 환경 변수 정리
|
|
||||||
|
|
||||||
## 주요 컴포넌트 및 훅
|
|
||||||
|
|
||||||
### 1. Appwrite 클라이언트 설정
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/lib/appwrite/client.ts
|
|
||||||
import { Client, Account, Databases, Storage } from 'appwrite';
|
|
||||||
import { config } from './config';
|
|
||||||
|
|
||||||
// 클라이언트 초기화
|
|
||||||
export const client = new Client()
|
|
||||||
.setEndpoint(config.endpoint)
|
|
||||||
.setProject(config.projectId);
|
|
||||||
|
|
||||||
// 서비스 초기화
|
|
||||||
export const account = new Account(client);
|
|
||||||
export const databases = new Databases(client);
|
|
||||||
export const storage = new Storage(client);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 인증 훅
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/hooks/auth/useAppwriteAuth.ts
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { account } from '../../lib/appwrite';
|
|
||||||
|
|
||||||
export const useAppwriteAuth = () => {
|
|
||||||
const [user, setUser] = useState(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// 사용자 세션 확인
|
|
||||||
useEffect(() => {
|
|
||||||
const checkSession = async () => {
|
|
||||||
try {
|
|
||||||
const session = await account.getSession('current');
|
|
||||||
if (session) {
|
|
||||||
const currentUser = await account.get();
|
|
||||||
setUser(currentUser);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('세션 확인 오류:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkSession();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 로그인 함수
|
|
||||||
const login = async (email, password) => {
|
|
||||||
try {
|
|
||||||
await account.createEmailSession(email, password);
|
|
||||||
const currentUser = await account.get();
|
|
||||||
setUser(currentUser);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('로그인 오류:', error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 로그아웃 함수
|
|
||||||
const logout = async () => {
|
|
||||||
try {
|
|
||||||
await account.deleteSession('current');
|
|
||||||
setUser(null);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('로그아웃 오류:', error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return { user, loading, login, logout };
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 트랜잭션 훅
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// src/hooks/transactions/useAppwriteTransactions.ts
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { databases } from '../../lib/appwrite';
|
|
||||||
import { config } from '../../lib/appwrite/config';
|
|
||||||
import { Query } from 'appwrite';
|
|
||||||
|
|
||||||
export const useAppwriteTransactions = (userId) => {
|
|
||||||
const [transactions, setTransactions] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
// 트랜잭션 불러오기
|
|
||||||
const fetchTransactions = useCallback(async () => {
|
|
||||||
if (!userId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await databases.listDocuments(
|
|
||||||
config.databaseId,
|
|
||||||
config.transactionsCollectionId,
|
|
||||||
[Query.equal('userId', userId)]
|
|
||||||
);
|
|
||||||
setTransactions(response.documents);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('트랜잭션 불러오기 오류:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [userId]);
|
|
||||||
|
|
||||||
// 초기 데이터 로드
|
|
||||||
useEffect(() => {
|
|
||||||
fetchTransactions();
|
|
||||||
}, [fetchTransactions]);
|
|
||||||
|
|
||||||
// 트랜잭션 추가
|
|
||||||
const addTransaction = async (transaction) => {
|
|
||||||
try {
|
|
||||||
const newTransaction = await databases.createDocument(
|
|
||||||
config.databaseId,
|
|
||||||
config.transactionsCollectionId,
|
|
||||||
'unique()',
|
|
||||||
{
|
|
||||||
...transaction,
|
|
||||||
userId,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
setTransactions((prev) => [...prev, newTransaction]);
|
|
||||||
return { success: true, transaction: newTransaction };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('트랜잭션 추가 오류:', error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 트랜잭션 업데이트
|
|
||||||
const updateTransaction = async (id, data) => {
|
|
||||||
try {
|
|
||||||
const updatedTransaction = await databases.updateDocument(
|
|
||||||
config.databaseId,
|
|
||||||
config.transactionsCollectionId,
|
|
||||||
id,
|
|
||||||
data
|
|
||||||
);
|
|
||||||
setTransactions((prev) =>
|
|
||||||
prev.map((t) => (t.$id === id ? updatedTransaction : t))
|
|
||||||
);
|
|
||||||
return { success: true, transaction: updatedTransaction };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('트랜잭션 업데이트 오류:', error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 트랜잭션 삭제
|
|
||||||
const deleteTransaction = async (id) => {
|
|
||||||
try {
|
|
||||||
await databases.deleteDocument(
|
|
||||||
config.databaseId,
|
|
||||||
config.transactionsCollectionId,
|
|
||||||
id
|
|
||||||
);
|
|
||||||
setTransactions((prev) => prev.filter((t) => t.$id !== id));
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
console.error('트랜잭션 삭제 오류:', error);
|
|
||||||
return { success: false, error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
transactions,
|
|
||||||
loading,
|
|
||||||
fetchTransactions,
|
|
||||||
addTransaction,
|
|
||||||
updateTransaction,
|
|
||||||
deleteTransaction,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 마이그레이션 도구 사용법
|
|
||||||
|
|
||||||
1. 설정 페이지에서 "Appwrite 설정" 메뉴 선택
|
|
||||||
2. "Supabase에서 Appwrite로 마이그레이션" 섹션에서 "마이그레이션 시작" 버튼 클릭
|
|
||||||
3. 마이그레이션 진행 상황 확인
|
|
||||||
4. 완료 후 데이터 검증
|
|
||||||
|
|
||||||
## 주의사항
|
|
||||||
|
|
||||||
1. 마이그레이션 중에는 데이터 변경을 최소화하세요.
|
|
||||||
2. 마이그레이션 전에 데이터 백업을 수행하세요.
|
|
||||||
3. 마이그레이션 후 모든 기능이 정상 작동하는지 테스트하세요.
|
|
||||||
4. 문제 발생 시 개발팀에 즉시 보고하세요.
|
|
||||||
@@ -1,396 +0,0 @@
|
|||||||
# Lovable UI 컴포넌트 정리
|
|
||||||
|
|
||||||
## 개요
|
|
||||||
|
|
||||||
Zellyy Finance 앱은 Flutter에서 React와 Tailwind CSS를 사용한 Capacitor 기반 웹 앱으로 전환되었으며, UI 디자인은 뉴모피즘 스타일의 Lovable UI 컴포넌트를 적용하여 차별화된 사용자 경험을 제공하고자 합니다. 이 문서는 Lovable UI 컴포넌트와 관련된 내용을 정리합니다.
|
|
||||||
|
|
||||||
## Lovable UI 컴포넌트 관련 문서
|
|
||||||
|
|
||||||
### 1. UI/UX 설계 문서에서의 Lovable UI
|
|
||||||
|
|
||||||
**위치**: `/01_기획_및_설계/02_UI_UX_설계.md`
|
|
||||||
|
|
||||||
**주요 내용**:
|
|
||||||
- **컴포넌트 라이브러리**: Lovable UI 컴포넌트 라이브러리 구축
|
|
||||||
- **뉴모피즘 스타일**: 입체적이고 부드러운 UI 디자인 적용
|
|
||||||
- **디자인 철학**: 웹 기반 기술과 Capacitor를 활용하여 다양한 플랫폼에서 일관된 사용자 경험을 제공하면서도, 뉴모피즘 스타일의 Lovable UI 컴포넌트를 통해 차별화된 디자인을 구현
|
|
||||||
|
|
||||||
### 2. 시스템 아키텍처 문서에서의 Lovable UI
|
|
||||||
|
|
||||||
**위치**: `/02_기술_문서/01_시스템_아키텍처.md`
|
|
||||||
|
|
||||||
**주요 내용**:
|
|
||||||
- **프레젠테이션 계층**: 뉴모피즘 스타일 UI 컴포넌트 적용
|
|
||||||
|
|
||||||
### 3. 개발 로드맵 문서에서의 Lovable UI
|
|
||||||
|
|
||||||
**위치**: `/03_개발_단계/01_개발_로드맵.md`
|
|
||||||
|
|
||||||
**주요 내용**:
|
|
||||||
- **개발 작업**: 뉴모피즘 스타일 UI 컴포넌트 개발 계획
|
|
||||||
|
|
||||||
### 4. README 문서에서의 Lovable UI
|
|
||||||
|
|
||||||
**위치**: `/README.md`
|
|
||||||
|
|
||||||
**주요 내용**:
|
|
||||||
- **변경 사항**: 2025-03-09: 개발 방법 변경 - Flutter에서 React, Tailwind CSS, Capacitor 기반 웹 앱으로 전환, Lovable UI 컴포넌트 스타일 적용
|
|
||||||
|
|
||||||
## 필요한 Lovable UI 컴포넌트 목록
|
|
||||||
|
|
||||||
Zellyy Finance 앱의 홈 화면을 Lovable UI 컴포넌트로 변경하는 작업을 진행 중입니다. 기존 홈 화면의 기능을 유지하면서 Lovable UI 디자인 시스템의 컴포넌트로 UI를 개선하려고 합니다.
|
|
||||||
|
|
||||||
### 주요 변경 사항
|
|
||||||
1. FloatingActionButton을 LovableAddTransactionButton으로 교체
|
|
||||||
2. 기존 Card를 LovableCard로 교체
|
|
||||||
3. 거래 내역 리스트 아이템을 LovableTransactionCard로 교체
|
|
||||||
4. 전체적인 UI 디자인을 뉴모피즘 스타일로 변경
|
|
||||||
|
|
||||||
### 필요한 컴포넌트
|
|
||||||
- **LovableButton**: 기본 버튼 컴포넌트
|
|
||||||
- **LovableCard**: 카드 컴포넌트
|
|
||||||
- **LovableTransactionCard**: 거래 내역 표시용 카드 컴포넌트
|
|
||||||
- **LovableAddTransactionButton**: 거래 추가용 플로팅 액션 버튼
|
|
||||||
|
|
||||||
### 홈 화면 파일 경로
|
|
||||||
`/Users/hansoo./Dev/Zellyy_Finance/neumofinance/src/pages/Home.tsx`
|
|
||||||
|
|
||||||
## 뉴모피즘 디자인 특징
|
|
||||||
|
|
||||||
뉴모피즘(Neumorphism)은 다음과 같은 특징을 가진 UI 디자인 스타일입니다:
|
|
||||||
|
|
||||||
1. **입체감**: 요소가 배경에서 살짝 튀어나온 것처럼 보이는 효과
|
|
||||||
2. **부드러운 그림자**: 요소의 위쪽과 왼쪽에는 밝은 그림자, 아래쪽과 오른쪽에는 어두운 그림자를 적용
|
|
||||||
3. **단일 색상 사용**: 배경과 요소가 비슷한 색상을 사용하여 미묘한 차이로 구분
|
|
||||||
4. **미니멀리즘**: 깔끔하고 단순한 디자인 요소 사용
|
|
||||||
5. **낮은 대비**: 강한 색상 대비보다는 미묘한 그림자와 하이라이트로 구분
|
|
||||||
|
|
||||||
## 구현 방향
|
|
||||||
|
|
||||||
1. **Tailwind CSS 활용**: 웹 기반 앱에서는 Tailwind CSS를 활용하여 뉴모피즘 효과 구현
|
|
||||||
2. **React 컴포넌트 모듈화**: 재사용 가능한 React 컴포넌트로 설계
|
|
||||||
3. **반응형 디자인**: 다양한 화면 크기에 대응하는 디자인 적용
|
|
||||||
4. **접근성 고려**: 시각적 효과가 접근성을 해치지 않도록 주의
|
|
||||||
5. **성능 최적화**: 그림자 효과 등이 성능에 영향을 미치지 않도록 최적화
|
|
||||||
|
|
||||||
## React 기반 Lovable UI 컴포넌트 구현
|
|
||||||
|
|
||||||
### 1. LovableButton 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
interface LovableButtonProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
className?: string;
|
|
||||||
variant?: 'primary' | 'secondary' | 'outline';
|
|
||||||
size?: 'sm' | 'md' | 'lg';
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LovableButton: React.FC<LovableButtonProps> = ({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
className = '',
|
|
||||||
variant = 'primary',
|
|
||||||
size = 'md',
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const baseStyles = 'rounded-xl font-medium transition-all duration-200 focus:outline-none';
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
primary: 'bg-primary-100 text-primary-900 shadow-neumo hover:shadow-neumo-pressed active:shadow-neumo-pressed',
|
|
||||||
secondary: 'bg-secondary-100 text-secondary-900 shadow-neumo hover:shadow-neumo-pressed active:shadow-neumo-pressed',
|
|
||||||
outline: 'bg-transparent border-2 border-primary-200 text-primary-900 hover:bg-primary-50 active:bg-primary-100',
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeStyles = {
|
|
||||||
sm: 'px-3 py-1 text-sm',
|
|
||||||
md: 'px-4 py-2',
|
|
||||||
lg: 'px-6 py-3 text-lg',
|
|
||||||
};
|
|
||||||
|
|
||||||
const disabledStyles = disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
className={twMerge(
|
|
||||||
baseStyles,
|
|
||||||
variantStyles[variant],
|
|
||||||
sizeStyles[size],
|
|
||||||
disabledStyles,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LovableButton;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. LovableCard 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
interface LovableCardProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
elevated?: boolean;
|
|
||||||
pressed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LovableCard: React.FC<LovableCardProps> = ({
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
onClick,
|
|
||||||
elevated = false,
|
|
||||||
pressed = false,
|
|
||||||
}) => {
|
|
||||||
const baseStyles = 'bg-primary-50 rounded-2xl p-4 transition-all duration-200';
|
|
||||||
|
|
||||||
const shadowStyles = {
|
|
||||||
default: 'shadow-neumo',
|
|
||||||
elevated: 'shadow-neumo-elevated',
|
|
||||||
pressed: 'shadow-neumo-pressed',
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedShadow = pressed ? shadowStyles.pressed : (elevated ? shadowStyles.elevated : shadowStyles.default);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={twMerge(
|
|
||||||
baseStyles,
|
|
||||||
selectedShadow,
|
|
||||||
onClick ? 'cursor-pointer' : '',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LovableCard;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. LovableTransactionCard 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import LovableCard from './LovableCard';
|
|
||||||
import { formatCurrency } from '../utils/formatters';
|
|
||||||
|
|
||||||
interface TransactionType {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
amount: number;
|
|
||||||
date: string;
|
|
||||||
category: string;
|
|
||||||
type: 'income' | 'expense';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LovableTransactionCardProps {
|
|
||||||
transaction: TransactionType;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LovableTransactionCard: React.FC<LovableTransactionCardProps> = ({
|
|
||||||
transaction,
|
|
||||||
className = '',
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const { title, amount, date, category, type } = transaction;
|
|
||||||
|
|
||||||
// 카테고리에 따른 아이콘 선택
|
|
||||||
const getCategoryIcon = (category: string) => {
|
|
||||||
switch (category.toLowerCase()) {
|
|
||||||
case '식비':
|
|
||||||
return '🍴';
|
|
||||||
case '교통':
|
|
||||||
return '🚗';
|
|
||||||
case '쇼핑':
|
|
||||||
return '🛍️';
|
|
||||||
case '여가':
|
|
||||||
return '⛲️';
|
|
||||||
case '금융':
|
|
||||||
return '💰';
|
|
||||||
case '급여':
|
|
||||||
return '💸';
|
|
||||||
default:
|
|
||||||
return '📃';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formattedDate = new Date(date).toLocaleDateString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LovableCard
|
|
||||||
className={twMerge('flex items-center justify-between', className)}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center mr-3 shadow-neumo-sm">
|
|
||||||
<span className="text-xl">{getCategoryIcon(category)}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-medium text-primary-900">{title}</h3>
|
|
||||||
<p className="text-sm text-primary-600">{formattedDate}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`font-bold ${type === 'income' ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{type === 'income' ? '+' : '-'}{formatCurrency(amount)}
|
|
||||||
</div>
|
|
||||||
</LovableCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LovableTransactionCard;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. LovableAddTransactionButton 컴포넌트
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
|
|
||||||
interface LovableAddTransactionButtonProps {
|
|
||||||
onAddIncome?: () => void;
|
|
||||||
onAddExpense?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LovableAddTransactionButton: React.FC<LovableAddTransactionButtonProps> = ({
|
|
||||||
onAddIncome,
|
|
||||||
onAddExpense,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
|
|
||||||
const toggleOpen = () => {
|
|
||||||
setIsOpen(!isOpen);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddIncome = () => {
|
|
||||||
if (onAddIncome) {
|
|
||||||
onAddIncome();
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddExpense = () => {
|
|
||||||
if (onAddExpense) {
|
|
||||||
onAddExpense();
|
|
||||||
}
|
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={twMerge('fixed bottom-6 right-6 z-50', className)}>
|
|
||||||
{isOpen && (
|
|
||||||
<div className="flex flex-col items-end mb-4 space-y-3">
|
|
||||||
<button
|
|
||||||
onClick={handleAddIncome}
|
|
||||||
className="flex items-center bg-primary-50 text-green-600 px-4 py-2 rounded-xl shadow-neumo hover:shadow-neumo-pressed transition-all duration-200"
|
|
||||||
>
|
|
||||||
<span className="mr-2">💸</span>
|
|
||||||
<span>수입 추가</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleAddExpense}
|
|
||||||
className="flex items-center bg-primary-50 text-red-600 px-4 py-2 rounded-xl shadow-neumo hover:shadow-neumo-pressed transition-all duration-200"
|
|
||||||
>
|
|
||||||
<span className="mr-2">💳</span>
|
|
||||||
<span>지출 추가</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={toggleOpen}
|
|
||||||
className={`w-14 h-14 rounded-full bg-primary-100 text-primary-900 flex items-center justify-center shadow-neumo-elevated hover:shadow-neumo transition-all duration-300 ${isOpen ? 'rotate-45' : ''}`}
|
|
||||||
>
|
|
||||||
<span className="text-2xl font-bold">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LovableAddTransactionButton;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Tailwind CSS 구성
|
|
||||||
|
|
||||||
```ts
|
|
||||||
// tailwind.config.ts
|
|
||||||
import type { Config } from 'tailwindcss';
|
|
||||||
|
|
||||||
const config: Config = {
|
|
||||||
content: [
|
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#f5f7fa',
|
|
||||||
100: '#e4e7eb',
|
|
||||||
200: '#cbd2d9',
|
|
||||||
300: '#9aa5b1',
|
|
||||||
400: '#7b8794',
|
|
||||||
500: '#616e7c',
|
|
||||||
600: '#52606d',
|
|
||||||
700: '#3e4c59',
|
|
||||||
800: '#323f4b',
|
|
||||||
900: '#1f2933',
|
|
||||||
},
|
|
||||||
secondary: {
|
|
||||||
50: '#e3f8ff',
|
|
||||||
100: '#b3ecff',
|
|
||||||
200: '#81defd',
|
|
||||||
300: '#5ed0fa',
|
|
||||||
400: '#40c3f7',
|
|
||||||
500: '#2bb0ed',
|
|
||||||
600: '#1992d4',
|
|
||||||
700: '#127fbf',
|
|
||||||
800: '#0b69a3',
|
|
||||||
900: '#035388',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
'neumo': '5px 5px 10px #d1d9e6, -5px -5px 10px #ffffff',
|
|
||||||
'neumo-sm': '3px 3px 6px #d1d9e6, -3px -3px 6px #ffffff',
|
|
||||||
'neumo-pressed': 'inset 5px 5px 10px #d1d9e6, inset -5px -5px 10px #ffffff',
|
|
||||||
'neumo-elevated': '10px 10px 20px #d1d9e6, -10px -10px 20px #ffffff',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 결론
|
|
||||||
|
|
||||||
Lovable UI 컴포넌트는 Zellyy Finance 앱의 차별화된 사용자 경험을 제공하기 위한 핵심 요소입니다. Flutter에서 React와 Tailwind CSS를 사용한 웹 기반 앱으로 전환하면서, 뉴모피즘 스타일을 적용하여 입체적이고 부드러운 디자인을 구현하였습니다.
|
|
||||||
|
|
||||||
React의 컴포넌트 기반 아키텍처와 Tailwind CSS의 유틸리티 클래스를 활용하여 재사용 가능한 컴포넌트를 구축함으로써, 개발 효율성을 높이고 일관된 사용자 경험을 제공할 수 있습니다. 특히 커스텀 그림자 효과를 활용한 뉴모피즘 스타일은 사용자에게 매력적인 인터페이스를 제공합니다.
|
|
||||||
|
|
||||||
이러한 Lovable UI 컴포넌트 라이브러리는 앞으로도 지속적으로 개선되고 확장될 예정이며, 사용자 피드백을 반영하여 더욱 향상된 경험을 제공할 계획입니다. 이를 통해 Zellyy Finance 앱은 사용자에게 차별화된 가치를 제공하고, 재무 관리에 도움이 되는 유용한 도구로 자리매김할 것입니다.
|
|
||||||
533
package-lock.json
generated
533
package-lock.json
generated
@@ -45,7 +45,6 @@
|
|||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-query-devtools": "^5.83.0",
|
"@tanstack/react-query-devtools": "^5.83.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"appwrite": "^17.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
@@ -89,7 +88,6 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"lint-staged": "^16.1.2",
|
"lint-staged": "^16.1.2",
|
||||||
"lovable-tagger": "^1.1.7",
|
|
||||||
"playwright": "^1.54.1",
|
"playwright": "^1.54.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
@@ -161,42 +159,17 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
|
||||||
"version": "7.25.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz",
|
|
||||||
"integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/helper-validator-identifier": {
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/parser": {
|
|
||||||
"version": "7.25.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz",
|
|
||||||
"integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/types": "^7.25.9"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"parser": "bin/babel-parser.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@babel/runtime": {
|
"node_modules/@babel/runtime": {
|
||||||
"version": "7.27.0",
|
"version": "7.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||||
@@ -209,20 +182,6 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/types": {
|
|
||||||
"version": "7.25.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz",
|
|
||||||
"integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-string-parser": "^7.25.9",
|
|
||||||
"@babel/helper-validator-identifier": "^7.25.9"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@capacitor/cli": {
|
"node_modules/@capacitor/cli": {
|
||||||
"version": "7.1.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@capacitor/cli/-/cli-7.1.0.tgz",
|
||||||
@@ -737,23 +696,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -771,23 +713,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -4523,12 +4448,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/appwrite": {
|
|
||||||
"version": "17.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/appwrite/-/appwrite-17.0.2.tgz",
|
|
||||||
"integrity": "sha512-h8frLDRYzFDLS9xA2s8ZSlH/prPFq/ma5477fgQHHLcE/t9RDxNImpq9AleRUb9Oh1YJiP49HCObxgSTGW5AQA==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -7389,456 +7308,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lovable-tagger": {
|
|
||||||
"version": "1.1.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.7.tgz",
|
|
||||||
"integrity": "sha512-b1wwYbuxWGx+DuqviQGQXrgLAraK1RVbqTg6G8LYRID8FJTg4TuAeO0TJ7i6UXOF8gEzbgjhRbGZ+XAkWH2T8A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/parser": "^7.25.9",
|
|
||||||
"@babel/types": "^7.25.8",
|
|
||||||
"esbuild": "^0.25.0",
|
|
||||||
"estree-walker": "^3.0.3",
|
|
||||||
"magic-string": "^0.30.12",
|
|
||||||
"tailwindcss": "^3.4.17"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vite": "^5.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lovable-tagger/node_modules/esbuild": {
|
|
||||||
"version": "0.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
|
|
||||||
"integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.25.0",
|
|
||||||
"@esbuild/android-arm": "0.25.0",
|
|
||||||
"@esbuild/android-arm64": "0.25.0",
|
|
||||||
"@esbuild/android-x64": "0.25.0",
|
|
||||||
"@esbuild/darwin-arm64": "0.25.0",
|
|
||||||
"@esbuild/darwin-x64": "0.25.0",
|
|
||||||
"@esbuild/freebsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/freebsd-x64": "0.25.0",
|
|
||||||
"@esbuild/linux-arm": "0.25.0",
|
|
||||||
"@esbuild/linux-arm64": "0.25.0",
|
|
||||||
"@esbuild/linux-ia32": "0.25.0",
|
|
||||||
"@esbuild/linux-loong64": "0.25.0",
|
|
||||||
"@esbuild/linux-mips64el": "0.25.0",
|
|
||||||
"@esbuild/linux-ppc64": "0.25.0",
|
|
||||||
"@esbuild/linux-riscv64": "0.25.0",
|
|
||||||
"@esbuild/linux-s390x": "0.25.0",
|
|
||||||
"@esbuild/linux-x64": "0.25.0",
|
|
||||||
"@esbuild/netbsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/netbsd-x64": "0.25.0",
|
|
||||||
"@esbuild/openbsd-arm64": "0.25.0",
|
|
||||||
"@esbuild/openbsd-x64": "0.25.0",
|
|
||||||
"@esbuild/sunos-x64": "0.25.0",
|
|
||||||
"@esbuild/win32-arm64": "0.25.0",
|
|
||||||
"@esbuild/win32-ia32": "0.25.0",
|
|
||||||
"@esbuild/win32-x64": "0.25.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "11.1.0",
|
"version": "11.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz",
|
||||||
|
|||||||
@@ -79,7 +79,6 @@
|
|||||||
"@tanstack/react-query": "^5.83.0",
|
"@tanstack/react-query": "^5.83.0",
|
||||||
"@tanstack/react-query-devtools": "^5.83.0",
|
"@tanstack/react-query-devtools": "^5.83.0",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"appwrite": "^17.0.2",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
@@ -123,7 +122,6 @@
|
|||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"lint-staged": "^16.1.2",
|
"lint-staged": "^16.1.2",
|
||||||
"lovable-tagger": "^1.1.7",
|
|
||||||
"playwright": "^1.54.1",
|
"playwright": "^1.54.1",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ const PageTracker = () => {
|
|||||||
"/settings": "설정",
|
"/settings": "설정",
|
||||||
"/profile": "프로필",
|
"/profile": "프로필",
|
||||||
"/help": "도움말",
|
"/help": "도움말",
|
||||||
"/appwrite-settings": "백엔드 설정",
|
|
||||||
};
|
};
|
||||||
return pageMap[pathname] || pathname;
|
return pageMap[pathname] || pathname;
|
||||||
};
|
};
|
||||||
@@ -294,10 +293,6 @@ function App() {
|
|||||||
element={<NotificationSettings />}
|
element={<NotificationSettings />}
|
||||||
/>
|
/>
|
||||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||||
<Route
|
|
||||||
path="/appwrite-settings"
|
|
||||||
element={<div>Supabase Settings (구현 예정)</div>}
|
|
||||||
/>
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,301 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { appwriteLogger } from '@/utils/logger';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
|
||||||
import {
|
|
||||||
migrateTransactionsFromSupabase,
|
|
||||||
checkMigrationStatus
|
|
||||||
} from '@/lib/appwrite/migrateFromSupabase';
|
|
||||||
import { useAppwriteAuth } from '@/hooks/auth/useAppwriteAuth';
|
|
||||||
import { toast } from '@/hooks/useToast.wrapper';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supabase에서 Appwrite로 마이그레이션 컴포넌트
|
|
||||||
* 데이터 마이그레이션 상태 확인 및 마이그레이션 실행
|
|
||||||
*/
|
|
||||||
const SupabaseToAppwriteMigration: React.FC = () => {
|
|
||||||
// 인증 상태
|
|
||||||
const { user } = useAppwriteAuth();
|
|
||||||
|
|
||||||
// 마이그레이션 상태
|
|
||||||
const [migrationStatus, setMigrationStatus] = useState<{
|
|
||||||
supabaseCount: number;
|
|
||||||
appwriteCount: number;
|
|
||||||
isComplete: boolean;
|
|
||||||
error?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 마이그레이션 진행 상태
|
|
||||||
const [migrationProgress, setMigrationProgress] = useState<{
|
|
||||||
isRunning: boolean;
|
|
||||||
current: number;
|
|
||||||
total: number;
|
|
||||||
percentage: number;
|
|
||||||
}>({
|
|
||||||
isRunning: false,
|
|
||||||
current: 0,
|
|
||||||
total: 0,
|
|
||||||
percentage: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// 마이그레이션 결과
|
|
||||||
const [migrationResult, setMigrationResult] = useState<{
|
|
||||||
success: boolean;
|
|
||||||
migrated: number;
|
|
||||||
total: number;
|
|
||||||
error?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 상태 추적
|
|
||||||
const [isMounted, setIsMounted] = useState(true);
|
|
||||||
|
|
||||||
// 마이그레이션 상태 확인
|
|
||||||
const checkStatus = useCallback(async () => {
|
|
||||||
if (!user || !isMounted) {return;}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const status = await checkMigrationStatus(user);
|
|
||||||
if (isMounted) {
|
|
||||||
setMigrationStatus(status);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error('마이그레이션 상태 확인 오류:', error);
|
|
||||||
if (isMounted) {
|
|
||||||
toast({
|
|
||||||
title: '상태 확인 실패',
|
|
||||||
description: '마이그레이션 상태를 확인하는 중 오류가 발생했습니다.',
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [user, isMounted]);
|
|
||||||
|
|
||||||
// 마이그레이션 실행
|
|
||||||
const runMigration = useCallback(async () => {
|
|
||||||
if (!user || !isMounted) {return;}
|
|
||||||
|
|
||||||
// 진행 상태 초기화
|
|
||||||
setMigrationProgress({
|
|
||||||
isRunning: true,
|
|
||||||
current: 0,
|
|
||||||
total: migrationStatus?.supabaseCount || 0,
|
|
||||||
percentage: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// 결과 초기화
|
|
||||||
setMigrationResult(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 진행 상황 콜백
|
|
||||||
const progressCallback = (current: number, total: number) => {
|
|
||||||
if (!isMounted) {return;}
|
|
||||||
|
|
||||||
// requestAnimationFrame을 사용하여 UI 업데이트 최적화
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!isMounted) {return;}
|
|
||||||
|
|
||||||
setMigrationProgress({
|
|
||||||
isRunning: true,
|
|
||||||
current,
|
|
||||||
total,
|
|
||||||
percentage: Math.round((current / total) * 100)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 마이그레이션 실행
|
|
||||||
const result = await migrateTransactionsFromSupabase(user, progressCallback);
|
|
||||||
|
|
||||||
if (!isMounted) {return;}
|
|
||||||
|
|
||||||
// 결과 설정
|
|
||||||
setMigrationResult(result);
|
|
||||||
|
|
||||||
// 성공 메시지
|
|
||||||
if (result.success) {
|
|
||||||
toast({
|
|
||||||
title: '마이그레이션 완료',
|
|
||||||
description: `${result.migrated}개의 트랜잭션이 성공적으로 마이그레이션되었습니다.`,
|
|
||||||
variant: 'default'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: '마이그레이션 실패',
|
|
||||||
description: result.error || '알 수 없는 오류가 발생했습니다.',
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상태 다시 확인
|
|
||||||
checkStatus();
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error('마이그레이션 오류:', error);
|
|
||||||
|
|
||||||
if (!isMounted) {return;}
|
|
||||||
|
|
||||||
// 오류 메시지
|
|
||||||
toast({
|
|
||||||
title: '마이그레이션 실패',
|
|
||||||
description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
|
|
||||||
variant: 'destructive'
|
|
||||||
});
|
|
||||||
|
|
||||||
// 결과 설정
|
|
||||||
setMigrationResult({
|
|
||||||
success: false,
|
|
||||||
migrated: 0,
|
|
||||||
total: migrationStatus?.supabaseCount || 0,
|
|
||||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
// 진행 상태 종료
|
|
||||||
if (isMounted) {
|
|
||||||
setMigrationProgress(prev => ({
|
|
||||||
...prev,
|
|
||||||
isRunning: false
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [user, migrationStatus, checkStatus, isMounted]);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 상태 확인
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
checkStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
setIsMounted(false);
|
|
||||||
};
|
|
||||||
}, [user, checkStatus]);
|
|
||||||
|
|
||||||
// 사용자가 로그인하지 않은 경우
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="p-4 bg-yellow-50 rounded-md">
|
|
||||||
<p className="text-yellow-800">
|
|
||||||
마이그레이션을 시작하려면 로그인이 필요합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 p-4 border rounded-md">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Supabase에서 Appwrite로 데이터 마이그레이션</h3>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
|
||||||
Supabase의 트랜잭션 데이터를 Appwrite로 이전합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 마이그레이션 상태 */}
|
|
||||||
{migrationStatus && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">마이그레이션 상태</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={checkStatus}
|
|
||||||
disabled={migrationProgress.isRunning}
|
|
||||||
>
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="text-xs text-gray-500">Supabase 트랜잭션</div>
|
|
||||||
<div className="text-lg font-semibold">{migrationStatus.supabaseCount}</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-gray-50 rounded-md">
|
|
||||||
<div className="text-xs text-gray-500">Appwrite 트랜잭션</div>
|
|
||||||
<div className="text-lg font-semibold">{migrationStatus.appwriteCount}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center mt-2">
|
|
||||||
{migrationStatus.isComplete ? (
|
|
||||||
<div className="flex items-center text-green-600 text-sm">
|
|
||||||
<CheckCircle className="h-4 w-4 mr-1" />
|
|
||||||
마이그레이션 완료
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center text-amber-600 text-sm">
|
|
||||||
<AlertCircle className="h-4 w-4 mr-1" />
|
|
||||||
마이그레이션 필요
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{migrationStatus.error && (
|
|
||||||
<div className="text-sm text-red-600 mt-2">
|
|
||||||
오류: {migrationStatus.error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 마이그레이션 진행 상태 */}
|
|
||||||
{migrationProgress.isRunning && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">진행 상황</span>
|
|
||||||
<span className="text-xs text-gray-500">
|
|
||||||
{migrationProgress.current} / {migrationProgress.total} ({migrationProgress.percentage}%)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={migrationProgress.percentage} className="h-2" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 마이그레이션 결과 */}
|
|
||||||
{migrationResult && !migrationProgress.isRunning && (
|
|
||||||
<div className={`p-3 rounded-md ${migrationResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
|
|
||||||
<div className="flex items-start">
|
|
||||||
{migrationResult.success ? (
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className={`font-medium ${migrationResult.success ? 'text-green-800' : 'text-red-800'}`}>
|
|
||||||
{migrationResult.success ? '마이그레이션 성공' : '마이그레이션 실패'}
|
|
||||||
</p>
|
|
||||||
<p className={migrationResult.success ? 'text-green-700' : 'text-red-700'}>
|
|
||||||
{migrationResult.success
|
|
||||||
? `${migrationResult.migrated}개의 트랜잭션이 마이그레이션되었습니다.`
|
|
||||||
: migrationResult.error || '알 수 없는 오류가 발생했습니다.'
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 마이그레이션 버튼 */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={runMigration}
|
|
||||||
disabled={migrationProgress.isRunning || migrationStatus?.isComplete}
|
|
||||||
>
|
|
||||||
{migrationProgress.isRunning && (
|
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
)}
|
|
||||||
{migrationStatus?.isComplete
|
|
||||||
? '이미 마이그레이션 완료됨'
|
|
||||||
: migrationProgress.isRunning
|
|
||||||
? '마이그레이션 중...'
|
|
||||||
: '마이그레이션 시작'
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SupabaseToAppwriteMigration;
|
|
||||||
@@ -69,11 +69,11 @@ describe("Header", () => {
|
|||||||
global.Image = class {
|
global.Image = class {
|
||||||
onload: (() => void) | null = null;
|
onload: (() => void) | null = null;
|
||||||
onerror: (() => void) | null = null;
|
onerror: (() => void) | null = null;
|
||||||
src: string = "";
|
src = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.onload) this.onload();
|
if (this.onload) {this.onload();}
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
} as any;
|
} as any;
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { CheckCircle, XCircle } from "lucide-react";
|
|
||||||
|
|
||||||
interface AppwriteConnectionStatusProps {
|
|
||||||
testResults: {
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: string;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AppwriteConnectionStatus = ({
|
|
||||||
testResults,
|
|
||||||
}: AppwriteConnectionStatusProps) => {
|
|
||||||
if (!testResults) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? "bg-green-50" : "bg-red-50"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start">
|
|
||||||
{testResults.connected ? (
|
|
||||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className={`font-medium ${testResults.connected ? "text-green-800" : "text-red-800"}`}
|
|
||||||
>
|
|
||||||
{testResults.connected ? "연결됨" : "연결 실패"}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
testResults.connected ? "text-green-700" : "text-red-700"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{testResults.message}
|
|
||||||
</p>
|
|
||||||
{testResults.details && (
|
|
||||||
<p className="text-gray-500 mt-1 text-xs">{testResults.details}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppwriteConnectionStatus;
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import AppwriteConnectionStatus from "./AppwriteConnectionStatus";
|
|
||||||
import {
|
|
||||||
client,
|
|
||||||
account,
|
|
||||||
isValidAppwriteConfig,
|
|
||||||
getAppwriteEndpoint,
|
|
||||||
} from "@/lib/appwrite";
|
|
||||||
import { setupAppwriteDatabase } from "@/lib/appwrite/setup";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 연결 테스트 컴포넌트
|
|
||||||
* 서버 연결 상태를 확인하고 데이터베이스 설정을 진행합니다.
|
|
||||||
*/
|
|
||||||
const AppwriteConnectionTest = () => {
|
|
||||||
// 연결 테스트 결과 상태
|
|
||||||
const [testResults, setTestResults] = useState<{
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// 로딩 상태
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// 데이터베이스 설정 상태
|
|
||||||
const [dbSetupDone, setDbSetupDone] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// 연결 테스트 함수
|
|
||||||
const testConnection = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
setTestResults(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 설정 유효성 검사
|
|
||||||
if (!isValidAppwriteConfig()) {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: "Appwrite 설정이 완료되지 않았습니다.",
|
|
||||||
details:
|
|
||||||
"환경 변수 VITE_APPWRITE_ENDPOINT 및 VITE_APPWRITE_PROJECT_ID를 확인하세요.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서버 연결 테스트
|
|
||||||
try {
|
|
||||||
await account.get();
|
|
||||||
|
|
||||||
setTestResults({
|
|
||||||
connected: true,
|
|
||||||
message: "Appwrite 서버에 성공적으로 연결되었습니다.",
|
|
||||||
details: `서버: ${getAppwriteEndpoint()}`,
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// 인증 오류는 연결 성공으로 간주 (로그인 필요)
|
|
||||||
if (error.code === 401) {
|
|
||||||
setTestResults({
|
|
||||||
connected: true,
|
|
||||||
message: "Appwrite 서버에 연결되었지만 로그인이 필요합니다.",
|
|
||||||
details: `서버: ${getAppwriteEndpoint()}`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: "서버 연결에 실패했습니다.",
|
|
||||||
details: error.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: "연결 테스트 중 오류가 발생했습니다.",
|
|
||||||
details: error.message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 데이터베이스 설정 함수
|
|
||||||
const setupDatabase = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const success = await setupAppwriteDatabase();
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setDbSetupDone(true);
|
|
||||||
setTestResults({
|
|
||||||
connected: true,
|
|
||||||
message: "데이터베이스 설정이 완료되었습니다.",
|
|
||||||
details: "트랜잭션 컬렉션이 준비되었습니다.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: "데이터베이스 설정에 실패했습니다.",
|
|
||||||
details: "로그를 확인하세요.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: "데이터베이스 설정 중 오류가 발생했습니다.",
|
|
||||||
details: error.message,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 자동 테스트
|
|
||||||
useEffect(() => {
|
|
||||||
testConnection();
|
|
||||||
}, [testConnection]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={testConnection}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
연결 테스트
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{testResults?.connected && !dbSetupDone && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={setupDatabase}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
데이터베이스 설정
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppwriteConnectionStatus testResults={testResults} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppwriteConnectionTest;
|
|
||||||
@@ -20,7 +20,7 @@ export function AuthGuard({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function setupUserProfile() {
|
async function setupUserProfile() {
|
||||||
if (!user) return;
|
if (!user) {return;}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { supabase } = await getAuthenticatedSupabase();
|
const { supabase } = await getAuthenticatedSupabase();
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Mail, InfoIcon, RefreshCw } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
interface EmailConfirmationProps {
|
|
||||||
email: string;
|
|
||||||
onBackToForm: () => void;
|
|
||||||
onResendEmail?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EmailConfirmation: React.FC<EmailConfirmationProps> = ({
|
|
||||||
email,
|
|
||||||
onBackToForm,
|
|
||||||
onResendEmail,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [isResending, setIsResending] = useState(false);
|
|
||||||
|
|
||||||
// 이메일 재전송 핸들러
|
|
||||||
const handleResendEmail = async () => {
|
|
||||||
if (!onResendEmail) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsResending(true);
|
|
||||||
try {
|
|
||||||
await onResendEmail();
|
|
||||||
// 성공 메시지는 onResendEmail 내부에서 표시
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("이메일 재전송 오류:", error);
|
|
||||||
} finally {
|
|
||||||
setIsResending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="neuro-flat p-8 mb-6">
|
|
||||||
<div className="text-center space-y-6">
|
|
||||||
<Mail className="w-16 h-16 mx-auto text-neuro-income" />
|
|
||||||
<h2 className="text-2xl font-bold">이메일 인증이 필요합니다</h2>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
<strong>{email}</strong>로 인증 링크가 포함된 이메일을 보냈습니다.
|
|
||||||
이메일을 확인하고 링크를 클릭하여 계정 등록을 완료해주세요.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Alert className="bg-blue-50 border-blue-200 my-6">
|
|
||||||
<InfoIcon className="h-5 w-5 text-blue-600" />
|
|
||||||
<AlertTitle className="text-blue-700">
|
|
||||||
인증 이메일이 보이지 않나요?
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="text-blue-600">
|
|
||||||
스팸 폴더를 확인해보세요. 또한 이메일 서비스에 따라 도착하는데 몇
|
|
||||||
분이 걸릴 수 있습니다.
|
|
||||||
{onResendEmail && (
|
|
||||||
<div className="mt-2">
|
|
||||||
아직도 받지 못했다면 아래 '인증 메일 재전송' 버튼을 클릭하세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-4">
|
|
||||||
{onResendEmail && (
|
|
||||||
<Button
|
|
||||||
onClick={handleResendEmail}
|
|
||||||
variant="secondary"
|
|
||||||
className="w-full"
|
|
||||||
disabled={isResending}
|
|
||||||
>
|
|
||||||
{isResending ? (
|
|
||||||
<>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
인증 메일 전송 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>인증 메일 재전송</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => navigate("/login")}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
로그인 페이지로 이동
|
|
||||||
</Button>
|
|
||||||
<Button onClick={onBackToForm} variant="ghost" className="w-full">
|
|
||||||
회원가입 양식으로 돌아가기
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EmailConfirmation;
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
ArrowRight,
|
|
||||||
Mail,
|
|
||||||
KeyRound,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
AlertTriangle,
|
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
|
||||||
interface LoginFormProps {
|
|
||||||
email: string;
|
|
||||||
setEmail: (email: string) => void;
|
|
||||||
password: string;
|
|
||||||
setPassword: (password: string) => void;
|
|
||||||
showPassword: boolean;
|
|
||||||
setShowPassword: (show: boolean) => void;
|
|
||||||
isLoading: boolean;
|
|
||||||
isSettingUpTables?: boolean;
|
|
||||||
loginError: string | null;
|
|
||||||
handleLogin: (e: React.FormEvent) => Promise<void>;
|
|
||||||
}
|
|
||||||
const LoginForm: React.FC<LoginFormProps> = ({
|
|
||||||
email,
|
|
||||||
setEmail,
|
|
||||||
password,
|
|
||||||
setPassword,
|
|
||||||
showPassword,
|
|
||||||
setShowPassword,
|
|
||||||
isLoading,
|
|
||||||
isSettingUpTables = false,
|
|
||||||
loginError,
|
|
||||||
handleLogin,
|
|
||||||
}) => {
|
|
||||||
// CORS 또는 JSON 관련 오류인지 확인
|
|
||||||
const isCorsOrJsonError =
|
|
||||||
loginError &&
|
|
||||||
(loginError.includes("JSON") ||
|
|
||||||
loginError.includes("CORS") ||
|
|
||||||
loginError.includes("프록시") ||
|
|
||||||
loginError.includes("서버 응답") ||
|
|
||||||
loginError.includes("네트워크") ||
|
|
||||||
loginError.includes("404") ||
|
|
||||||
loginError.includes("Not Found"));
|
|
||||||
return (
|
|
||||||
<div className="neuro-flat p-8 mb-6">
|
|
||||||
<form data-testid="login-form" onSubmit={handleLogin}>
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email" className="text-base">
|
|
||||||
이메일
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password" className="text-base">
|
|
||||||
비밀번호
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loginError && (
|
|
||||||
<div
|
|
||||||
className={`p-3 ${isCorsOrJsonError ? "bg-amber-50 text-amber-800" : "bg-red-50 text-red-600"} rounded-md text-sm`}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 mt-0.5 text-amber-500" />
|
|
||||||
<div>
|
|
||||||
<p className="font-medium">{loginError}</p>
|
|
||||||
|
|
||||||
{isCorsOrJsonError && (
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-xs space-y-1 text-amber-700">
|
|
||||||
<li>
|
|
||||||
설정 페이지에서 다른 CORS 프록시 유형을 시도해 보세요.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
HTTPS URL을 사용하는 Supabase 인스턴스로 변경해 보세요.
|
|
||||||
</li>
|
|
||||||
<li>네트워크 연결 상태를 확인하세요.</li>
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<Link
|
|
||||||
to="/forgot-password"
|
|
||||||
className="text-sm text-neuro-income hover:underline"
|
|
||||||
>
|
|
||||||
비밀번호를 잊으셨나요?
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={isLoading || isSettingUpTables}
|
|
||||||
className="w-full hover:bg-neuro-income/80 text-white h-auto bg-neuro-income text-lg py-[10px]"
|
|
||||||
>
|
|
||||||
{isLoading
|
|
||||||
? "로그인 중..."
|
|
||||||
: isSettingUpTables
|
|
||||||
? "데이터베이스 설정 중..."
|
|
||||||
: "로그인"}
|
|
||||||
{!isLoading && !isSettingUpTables ? (
|
|
||||||
<ArrowRight className="ml-2 h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Loader2 className="ml-2 h-5 w-5 animate-spin" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default LoginForm;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
|
||||||
const LoginLink: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<p className="text-gray-500">
|
|
||||||
이미 계정이 있으신가요?{" "}
|
|
||||||
<Link
|
|
||||||
to="/login"
|
|
||||||
className="text-neuro-income font-medium hover:underline"
|
|
||||||
>
|
|
||||||
로그인
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LoginLink;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface RegisterErrorDisplayProps {
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterErrorDisplay: React.FC<RegisterErrorDisplayProps> = ({
|
|
||||||
error,
|
|
||||||
}) => {
|
|
||||||
if (!error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm">
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterErrorDisplay;
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import { useToast } from "@/hooks/useToast.wrapper";
|
|
||||||
import { verifyServerConnection } from "@/contexts/auth/auth.utils";
|
|
||||||
import { ServerConnectionStatus } from "./types";
|
|
||||||
import EmailConfirmation from "./EmailConfirmation";
|
|
||||||
import RegisterFormFields from "./RegisterFormFields";
|
|
||||||
import { supabase } from "@/archive/lib/supabase";
|
|
||||||
|
|
||||||
interface RegisterFormProps {
|
|
||||||
signUp: (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string
|
|
||||||
) => Promise<{
|
|
||||||
error: any;
|
|
||||||
user: any;
|
|
||||||
redirectToSettings?: boolean;
|
|
||||||
emailConfirmationRequired?: boolean;
|
|
||||||
}>;
|
|
||||||
serverStatus: ServerConnectionStatus;
|
|
||||||
setServerStatus: React.Dispatch<React.SetStateAction<ServerConnectionStatus>>;
|
|
||||||
setRegisterError: React.Dispatch<React.SetStateAction<string | null>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterForm: React.FC<RegisterFormProps> = ({
|
|
||||||
signUp,
|
|
||||||
serverStatus,
|
|
||||||
setServerStatus,
|
|
||||||
setRegisterError,
|
|
||||||
}) => {
|
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [emailConfirmationSent, setEmailConfirmationSent] = useState(false);
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
if (!username || !email || !password || !confirmPassword) {
|
|
||||||
toast({
|
|
||||||
title: "입력 오류",
|
|
||||||
description: "모든 필드를 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (password !== confirmPassword) {
|
|
||||||
toast({
|
|
||||||
title: "비밀번호 불일치",
|
|
||||||
description: "비밀번호와 비밀번호 확인이 일치하지 않습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 강도 검사
|
|
||||||
if (password.length < 8) {
|
|
||||||
toast({
|
|
||||||
title: "비밀번호 강도 부족",
|
|
||||||
description: "비밀번호는 최소 8자 이상이어야 합니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이메일 형식 검사
|
|
||||||
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
||||||
if (!emailPattern.test(email)) {
|
|
||||||
toast({
|
|
||||||
title: "이메일 형식 오류",
|
|
||||||
description: "유효한 이메일 주소를 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkServerConnectivity = async (): Promise<boolean> => {
|
|
||||||
// 서버 연결 상태 재확인
|
|
||||||
if (!serverStatus.connected) {
|
|
||||||
try {
|
|
||||||
const currentStatus = await verifyServerConnection();
|
|
||||||
setServerStatus({
|
|
||||||
checked: true,
|
|
||||||
connected: currentStatus.connected,
|
|
||||||
message: currentStatus.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentStatus.connected) {
|
|
||||||
toast({
|
|
||||||
title: "서버 연결 실패",
|
|
||||||
description:
|
|
||||||
"서버에 연결할 수 없습니다. 네트워크 또는 서버 상태를 확인하세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
toast({
|
|
||||||
title: "연결 확인 오류",
|
|
||||||
description:
|
|
||||||
error.message || "서버 연결 확인 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 인증 이메일 재전송 기능
|
|
||||||
const handleResendVerificationEmail = async () => {
|
|
||||||
try {
|
|
||||||
// 현재 브라우저 URL 가져오기
|
|
||||||
const currentUrl = window.location.origin;
|
|
||||||
const redirectUrl = `${currentUrl}/login?auth_callback=true`;
|
|
||||||
|
|
||||||
const { error } = await supabase.auth.resend({
|
|
||||||
type: "signup",
|
|
||||||
email,
|
|
||||||
options: {
|
|
||||||
emailRedirectTo: redirectUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
authLogger.error("인증 메일 재전송 실패:", error);
|
|
||||||
toast({
|
|
||||||
title: "인증 메일 재전송 실패",
|
|
||||||
description:
|
|
||||||
error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "인증 메일 재전송 완료",
|
|
||||||
description: `${email}로 인증 메일이 재전송되었습니다. 이메일과 스팸 폴더를 확인해주세요.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
authLogger.info("인증 메일 재전송 성공:", email);
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("인증 메일 재전송 중 예외 발생:", error);
|
|
||||||
toast({
|
|
||||||
title: "인증 메일 재전송 오류",
|
|
||||||
description:
|
|
||||||
error.message || "인증 메일을 재전송하는 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setRegisterError(null);
|
|
||||||
|
|
||||||
// 서버 연결 확인
|
|
||||||
const isServerConnected = await checkServerConnectivity();
|
|
||||||
if (!isServerConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 폼 유효성 검사
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 회원가입 시도
|
|
||||||
const { error, user, redirectToSettings, emailConfirmationRequired } =
|
|
||||||
await signUp(email, password, username);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
// 오류 메시지 출력
|
|
||||||
setRegisterError(error.message || "알 수 없는 오류가 발생했습니다.");
|
|
||||||
|
|
||||||
// 설정 페이지 리디렉션이 필요한 경우
|
|
||||||
if (redirectToSettings) {
|
|
||||||
toast({
|
|
||||||
title: "Supabase 설정 필요",
|
|
||||||
description: "Supabase 설정을 확인하고 올바른 값을 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2초 후 설정 페이지로 이동
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate("/supabase-settings");
|
|
||||||
}, 2000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 네트워크 관련 오류인 경우 자세한 안내
|
|
||||||
if (
|
|
||||||
error.message &&
|
|
||||||
(error.message.includes("fetch") ||
|
|
||||||
error.message.includes("네트워크") ||
|
|
||||||
error.message.includes("CORS"))
|
|
||||||
) {
|
|
||||||
toast({
|
|
||||||
title: "네트워크 오류",
|
|
||||||
description:
|
|
||||||
"서버에 연결할 수 없습니다. 설정에서 CORS 프록시가 활성화되어 있는지 확인하세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 서버 응답 관련 오류인 경우
|
|
||||||
else if (
|
|
||||||
error.message &&
|
|
||||||
(error.message.includes("400") ||
|
|
||||||
error.message.includes("401") ||
|
|
||||||
error.message.includes("403") ||
|
|
||||||
error.message.includes("500"))
|
|
||||||
) {
|
|
||||||
toast({
|
|
||||||
title: "서버 응답 오류",
|
|
||||||
description: error.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (user) {
|
|
||||||
// 이메일 확인이 필요한 경우
|
|
||||||
if (emailConfirmationRequired) {
|
|
||||||
setEmailConfirmationSent(true);
|
|
||||||
toast({
|
|
||||||
title: "이메일 인증 필요",
|
|
||||||
description: `${email}로 인증 메일이 발송되었습니다. 메일함을 확인하고 인증을 완료해주세요.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 이메일 확인이 필요하지 않은 경우 (자동 승인 등)
|
|
||||||
toast({
|
|
||||||
title: "회원가입 성공",
|
|
||||||
description:
|
|
||||||
"회원가입이 완료되었습니다. 로그인 페이지로 이동합니다.",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로그인 페이지로 이동
|
|
||||||
navigate("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("회원가입 처리 중 예외 발생:", error);
|
|
||||||
setRegisterError(error.message || "예상치 못한 오류가 발생했습니다.");
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "회원가입 오류",
|
|
||||||
description: error.message || "회원가입 처리 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 이메일 인증 안내 화면 (인증 메일이 발송된 경우)
|
|
||||||
if (emailConfirmationSent) {
|
|
||||||
return (
|
|
||||||
<EmailConfirmation
|
|
||||||
email={email}
|
|
||||||
onBackToForm={() => setEmailConfirmationSent(false)}
|
|
||||||
onResendEmail={handleResendVerificationEmail}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 회원가입 양식
|
|
||||||
return (
|
|
||||||
<div className="neuro-flat p-8 mb-6">
|
|
||||||
<form onSubmit={handleRegister}>
|
|
||||||
<RegisterFormFields
|
|
||||||
username={username}
|
|
||||||
setUsername={setUsername}
|
|
||||||
email={email}
|
|
||||||
setEmail={setEmail}
|
|
||||||
password={password}
|
|
||||||
setPassword={setPassword}
|
|
||||||
confirmPassword={confirmPassword}
|
|
||||||
setConfirmPassword={setConfirmPassword}
|
|
||||||
showPassword={showPassword}
|
|
||||||
setShowPassword={setShowPassword}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="w-full bg-neuro-income hover:bg-neuro-income/80 text-white py-6 h-auto mt-6"
|
|
||||||
disabled={
|
|
||||||
isLoading || (!serverStatus.connected && serverStatus.checked)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isLoading ? "가입 중..." : "회원가입"}{" "}
|
|
||||||
{!isLoading && <ArrowRight className="ml-2 h-5 w-5" />}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterForm;
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { User, Mail, KeyRound, Eye, EyeOff, InfoIcon } from "lucide-react";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
|
|
||||||
interface RegisterFormFieldsProps {
|
|
||||||
username: string;
|
|
||||||
setUsername: (value: string) => void;
|
|
||||||
email: string;
|
|
||||||
setEmail: (value: string) => void;
|
|
||||||
password: string;
|
|
||||||
setPassword: (value: string) => void;
|
|
||||||
confirmPassword: string;
|
|
||||||
setConfirmPassword: (value: string) => void;
|
|
||||||
showPassword: boolean;
|
|
||||||
setShowPassword: (value: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RegisterFormFields: React.FC<RegisterFormFieldsProps> = ({
|
|
||||||
username,
|
|
||||||
setUsername,
|
|
||||||
email,
|
|
||||||
setEmail,
|
|
||||||
password,
|
|
||||||
setPassword,
|
|
||||||
confirmPassword,
|
|
||||||
setConfirmPassword,
|
|
||||||
showPassword,
|
|
||||||
setShowPassword,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="username" className="text-base">
|
|
||||||
이름
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
placeholder="홍길동"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email" className="text-base">
|
|
||||||
이메일
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password" className="text-base">
|
|
||||||
비밀번호
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500"
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{password && password.length > 0 && password.length < 8 && (
|
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
비밀번호는 최소 8자 이상이어야 합니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="confirmPassword" className="text-base">
|
|
||||||
비밀번호 확인
|
|
||||||
</Label>
|
|
||||||
<div className="relative">
|
|
||||||
<KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500 h-5 w-5" />
|
|
||||||
<Input
|
|
||||||
id="confirmPassword"
|
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder="••••••••"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
className="pl-10 neuro-pressed"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{confirmPassword && password !== confirmPassword && (
|
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
비밀번호가 일치하지 않습니다.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Alert className="bg-amber-50 border-amber-200">
|
|
||||||
<InfoIcon className="h-5 w-5 text-amber-600" />
|
|
||||||
<AlertTitle className="text-amber-700">이메일 인증 필요</AlertTitle>
|
|
||||||
<AlertDescription className="text-amber-600">
|
|
||||||
회원가입 후 이메일로 인증 링크가 발송됩니다. 이메일 인증을 완료해야
|
|
||||||
로그인이 가능합니다.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterFormFields;
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
const RegisterHeader: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-neuro-income mb-2">
|
|
||||||
젤리의 적자탈출
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500">새 계정을 만들고 재정 관리를 시작하세요</p>
|
|
||||||
<p className="text-xs text-neuro-income mt-2">
|
|
||||||
온프레미스 Supabase 연결 최적화 완료
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RegisterHeader;
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
interface ServerStatusAlertProps {
|
|
||||||
serverStatus: {
|
|
||||||
checked: boolean;
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
checkServerConnection: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ServerStatusAlert = ({
|
|
||||||
serverStatus,
|
|
||||||
checkServerConnection,
|
|
||||||
}: ServerStatusAlertProps) => {
|
|
||||||
if (!serverStatus.checked || serverStatus.connected) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Alert variant="destructive" className="mb-6">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertTitle>서버 연결 문제</AlertTitle>
|
|
||||||
<AlertDescription className="flex flex-col">
|
|
||||||
<span>{serverStatus.message}</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="mt-2 self-start flex items-center gap-1"
|
|
||||||
onClick={checkServerConnection}
|
|
||||||
>
|
|
||||||
<RefreshCw className="h-3 w-3" />
|
|
||||||
<span>다시 시도</span>
|
|
||||||
</Button>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ServerStatusAlert;
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { verifySupabaseConnection } from "@/utils/auth/networkUtils";
|
|
||||||
import { toast } from "@/hooks/useToast.wrapper";
|
|
||||||
|
|
||||||
interface TestConnectionSectionProps {
|
|
||||||
setLoginError: (error: string | null) => void;
|
|
||||||
setTestResults: (results: any) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TestConnectionSection = ({
|
|
||||||
setLoginError,
|
|
||||||
setTestResults,
|
|
||||||
}: TestConnectionSectionProps) => {
|
|
||||||
const [isTesting, setIsTesting] = useState(false);
|
|
||||||
|
|
||||||
const testConnection = async () => {
|
|
||||||
setLoginError(null);
|
|
||||||
setIsTesting(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await verifySupabaseConnection();
|
|
||||||
setTestResults(results);
|
|
||||||
|
|
||||||
if (results.connected) {
|
|
||||||
toast({
|
|
||||||
title: "연결 성공",
|
|
||||||
description: "Supabase 서버에 성공적으로 연결되었습니다.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
title: "연결 실패",
|
|
||||||
description: results.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setTestResults({
|
|
||||||
connected: false,
|
|
||||||
message: error.message || "알 수 없는 오류",
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "테스트 오류",
|
|
||||||
description: error.message || "알 수 없는 오류",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsTesting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-8 border-t pt-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-700">연결 테스트</h3>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={testConnection}
|
|
||||||
disabled={isTesting}
|
|
||||||
>
|
|
||||||
{isTesting ? "테스트 중..." : "Supabase 연결 테스트"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TestConnectionSection;
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { ServerConnectionStatus } from "@/types/common";
|
|
||||||
import type { SignUpResponse } from "@/contexts/auth/types";
|
|
||||||
|
|
||||||
export interface RegisterFormProps {
|
|
||||||
signUp: (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string
|
|
||||||
) => Promise<SignUpResponse>;
|
|
||||||
serverStatus: ServerConnectionStatus;
|
|
||||||
setServerStatus: React.Dispatch<React.SetStateAction<ServerConnectionStatus>>;
|
|
||||||
setRegisterError: (error: string | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestConnectionResults {
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: string;
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { ClerkProvider as ClerkProviderComponent } from "@clerk/clerk-react";
|
import { ClerkProvider as ClerkProviderComponent } from "@clerk/clerk-react";
|
||||||
import { logger } from "@/utils/logger";
|
import { logger } from "@/utils/logger";
|
||||||
|
import { isClerkEnabled } from "@/lib/clerk/utils";
|
||||||
|
|
||||||
interface ClerkProviderProps {
|
interface ClerkProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -102,12 +103,6 @@ export const ClerkProvider: React.FC<ClerkProviderProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Clerk 사용 가능 여부 확인 유틸리티
|
|
||||||
*/
|
|
||||||
export const isClerkEnabled = (): boolean => {
|
|
||||||
return !!CLERK_PUBLISHABLE_KEY;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 개발환경용 Clerk 상태 확인 컴포넌트
|
* 개발환경용 Clerk 상태 확인 컴포넌트
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export const BackgroundSync = ({
|
|||||||
|
|
||||||
// 윈도우 포커스 이벤트 리스너
|
// 윈도우 포커스 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!syncOnFocus || !user?.id) return;
|
if (!syncOnFocus || !user?.id) {return;}
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
syncLogger.info("윈도우 포커스 감지 - 백그라운드 동기화 실행");
|
syncLogger.info("윈도우 포커스 감지 - 백그라운드 동기화 실행");
|
||||||
@@ -59,7 +59,7 @@ export const BackgroundSync = ({
|
|||||||
|
|
||||||
// 온라인 상태 복구 이벤트 리스너
|
// 온라인 상태 복구 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!syncOnOnline || !user?.id) return;
|
if (!syncOnOnline || !user?.id) {return;}
|
||||||
|
|
||||||
const handleOnline = () => {
|
const handleOnline = () => {
|
||||||
syncLogger.info("네트워크 연결 복구 - 백그라운드 동기화 실행");
|
syncLogger.info("네트워크 연결 복구 - 백그라운드 동기화 실행");
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { AuthProvider } from "./auth/AuthProvider";
|
|
||||||
|
|
||||||
export { AuthProvider } from "./auth/AuthProvider";
|
|
||||||
export { useAuth } from "./auth/useAuth";
|
|
||||||
|
|
||||||
export default function AuthContextWrapper({
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return <AuthProvider>{children}</AuthProvider>;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import React, { createContext } from "react";
|
|
||||||
import { AuthContextType } from "./types";
|
|
||||||
|
|
||||||
// AuthContext 생성
|
|
||||||
export const AuthContext = createContext<AuthContextType | undefined>(
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
@@ -1,271 +0,0 @@
|
|||||||
import React, { useEffect, useState, useCallback } from "react";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { toast } from "@/hooks/useToast.wrapper";
|
|
||||||
import { AuthContextType } from "./types";
|
|
||||||
import * as authActions from "./authActions";
|
|
||||||
import { clearAllToasts } from "@/hooks/toast/toastManager";
|
|
||||||
import { AuthContext } from "./AuthContext";
|
|
||||||
import {
|
|
||||||
account,
|
|
||||||
getInitializationStatus,
|
|
||||||
reinitializeAppwriteClient,
|
|
||||||
isValidConnection,
|
|
||||||
} from "@/lib/appwrite/client";
|
|
||||||
import { Models } from "appwrite";
|
|
||||||
import { getDefaultUserId } from "@/lib/appwrite/defaultUser";
|
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [session, setSession] = useState<Models.Session | null>(null);
|
|
||||||
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
const [appwriteInitialized, setAppwriteInitialized] =
|
|
||||||
useState<boolean>(false);
|
|
||||||
|
|
||||||
// 오류 발생 시 안전하게 처리하는 함수
|
|
||||||
const handleAuthError = useCallback((err: any) => {
|
|
||||||
authLogger.error("인증 처리 중 오류 발생:", err);
|
|
||||||
setError(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
// 오류가 발생해도 로딩 상태는 해제하여 UI가 차단되지 않도록 함
|
|
||||||
setLoading(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Appwrite 초기화 상태 확인
|
|
||||||
const checkAppwriteInitialization = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const status = getInitializationStatus();
|
|
||||||
authLogger.info(
|
|
||||||
"Appwrite 초기화 상태:",
|
|
||||||
status.isInitialized ? "성공" : "실패"
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!status.isInitialized) {
|
|
||||||
// 초기화 실패 시 재시도
|
|
||||||
authLogger.info("Appwrite 초기화 재시도 중...");
|
|
||||||
const retryStatus = reinitializeAppwriteClient();
|
|
||||||
setAppwriteInitialized(retryStatus.isInitialized);
|
|
||||||
|
|
||||||
if (!retryStatus.isInitialized && retryStatus.error) {
|
|
||||||
handleAuthError(retryStatus.error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setAppwriteInitialized(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연결 상태 확인
|
|
||||||
const connectionValid = await isValidConnection();
|
|
||||||
authLogger.info(
|
|
||||||
"Appwrite 연결 상태:",
|
|
||||||
connectionValid ? "정상" : "연결 문제"
|
|
||||||
);
|
|
||||||
|
|
||||||
return status.isInitialized;
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("Appwrite 초기화 상태 확인 오류:", error);
|
|
||||||
handleAuthError(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [handleAuthError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 현재 세션 체크 - 최적화된 버전
|
|
||||||
const getSession = async () => {
|
|
||||||
try {
|
|
||||||
authLogger.info("세션 로딩 시작");
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
// Appwrite 초기화 상태 확인
|
|
||||||
const isInitialized = await checkAppwriteInitialization();
|
|
||||||
if (!isInitialized) {
|
|
||||||
authLogger.warn(
|
|
||||||
"Appwrite 초기화 상태가 정상적이지 않습니다. 비로그인 상태로 처리합니다."
|
|
||||||
);
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setSession(null);
|
|
||||||
setUser(null);
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 정보 가져오기 시도 - 안전한 방식으로 처리
|
|
||||||
try {
|
|
||||||
// 사용자 정보 가져오기 시도
|
|
||||||
const currentUser = await account.get().catch((err) => {
|
|
||||||
// 401 오류는 비로그인 상태로 정상적인 경우
|
|
||||||
if (err && (err as any).code === 401) {
|
|
||||||
authLogger.info(
|
|
||||||
"사용자 정보 가져오기 실패, 비로그인 상태로 간주"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
authLogger.error("사용자 정보 가져오기 오류:", err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (currentUser) {
|
|
||||||
// 사용자 정보가 있으면 세션 정보 가져오기 시도
|
|
||||||
const currentSession = await account
|
|
||||||
.getSession("current")
|
|
||||||
.catch((err) => {
|
|
||||||
authLogger.info("세션 정보 가져오기 실패:", err);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 상태 업데이트를 마이크로태스크로 지연
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setUser(currentUser);
|
|
||||||
setSession(currentSession);
|
|
||||||
authLogger.info("세션 로딩 완료 - 사용자:", currentUser.$id);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 사용자 정보가 없으면 비로그인 상태로 처리
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setSession(null);
|
|
||||||
setUser(null);
|
|
||||||
authLogger.info("비로그인 상태로 처리");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 예상치 못한 오류 처리
|
|
||||||
authLogger.error("세션 처리 중 예상치 못한 오류:", error);
|
|
||||||
handleAuthError(error);
|
|
||||||
|
|
||||||
// 오류 발생 시 로그아웃 상태로 처리
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setSession(null);
|
|
||||||
setUser(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 최상위 예외 처리
|
|
||||||
authLogger.error("세션 확인 중 최상위 예외 발생:", error);
|
|
||||||
handleAuthError(error);
|
|
||||||
} finally {
|
|
||||||
// 로딩 상태 업데이트를 마이크로태스크로 지연
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setLoading(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기 세션 로딩 - 약간 지연시케 UI 렌더링 우선시
|
|
||||||
setTimeout(() => {
|
|
||||||
getSession();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Appwrite 인증 상태 변경 리스너 설정
|
|
||||||
// 참고: Appwrite는 직접적인 이벤트 리스너를 제공하지 않으므로 주기적으로 확인하는 방식 사용
|
|
||||||
const authCheckInterval = setInterval(async () => {
|
|
||||||
// 오류가 발생해도 애플리케이션이 중단되지 않도록 try-catch로 감싸기
|
|
||||||
try {
|
|
||||||
// Appwrite 초기화 상태 확인
|
|
||||||
if (!appwriteInitialized) {
|
|
||||||
const isInitialized = await checkAppwriteInitialization();
|
|
||||||
if (!isInitialized) {
|
|
||||||
authLogger.warn(
|
|
||||||
"Appwrite 초기화 상태가 여전히 정상적이지 않습니다. 다음 간격에서 재시도합니다."
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 정보 가져오기 시도 - 안전하게 처리
|
|
||||||
const currentUser = await account.get().catch((err) => {
|
|
||||||
// 401 오류는 비로그인 상태로 정상적인 경우
|
|
||||||
if (err && (err as any).code === 401) {
|
|
||||||
authLogger.info("사용자 정보 가져오기 실패, 비로그인 상태로 간주");
|
|
||||||
} else {
|
|
||||||
authLogger.error("사용자 정보 가져오기 오류:", err);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
// 사용자 정보가 있고, 이전과 다르면 세션 정보 가져오기
|
|
||||||
if (currentUser && (!user || currentUser.$id !== user.$id)) {
|
|
||||||
try {
|
|
||||||
// 세션 정보 가져오기 시도 - 안전하게 처리
|
|
||||||
const currentSession = await account
|
|
||||||
.getSession("current")
|
|
||||||
.catch((err) => {
|
|
||||||
authLogger.info("세션 정보 가져오기 실패:", err);
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 상태 업데이트를 마이크로태스크로 지연
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setUser(currentUser);
|
|
||||||
setSession(currentSession);
|
|
||||||
authLogger.info(
|
|
||||||
"Appwrite 인증 상태 변경: 로그인됨 - 사용자:",
|
|
||||||
currentUser.$id
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (sessionError) {
|
|
||||||
authLogger.error("세션 정보 가져오기 중 오류:", sessionError);
|
|
||||||
// 오류 발생해도 사용자 정보는 업데이트
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setUser(currentUser);
|
|
||||||
setSession(null);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (!currentUser && user) {
|
|
||||||
// 이전에는 사용자 정보가 있었지만 지금은 없는 경우 (로그아웃 상태)
|
|
||||||
queueMicrotask(() => {
|
|
||||||
setSession(null);
|
|
||||||
setUser(null);
|
|
||||||
|
|
||||||
// 로그아웃 시 열려있는 모든 토스트 제거
|
|
||||||
clearAllToasts();
|
|
||||||
|
|
||||||
// 로그아웃 이벤트 발생시켜 SyncSettings 등에서 감지하도록 함
|
|
||||||
window.dispatchEvent(new Event("auth-state-changed"));
|
|
||||||
authLogger.info("Appwrite 인증 상태 변경: 로그아웃됨");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// 예상치 못한 오류 발생 시에도 애플리케이션이 중단되지 않도록 처리
|
|
||||||
authLogger.error("Appwrite 인증 상태 검사 중 예상치 못한 오류:", error);
|
|
||||||
handleAuthError(error);
|
|
||||||
}
|
|
||||||
}, 5000); // 5초마다 확인
|
|
||||||
|
|
||||||
// 리스너 정리
|
|
||||||
return () => {
|
|
||||||
clearInterval(authCheckInterval);
|
|
||||||
};
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
// Appwrite 재초기화 함수
|
|
||||||
const reinitializeAppwrite = useCallback(() => {
|
|
||||||
authLogger.info("Appwrite 재초기화 요청됨");
|
|
||||||
return reinitializeAppwriteClient();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 인증 작업 메서드들
|
|
||||||
const value: AuthContextType = {
|
|
||||||
session,
|
|
||||||
user,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
appwriteInitialized,
|
|
||||||
reinitializeAppwrite,
|
|
||||||
signIn: authActions.signIn,
|
|
||||||
signUp: authActions.signUp,
|
|
||||||
signOut: authActions.signOut,
|
|
||||||
resetPassword: authActions.resetPassword,
|
|
||||||
};
|
|
||||||
|
|
||||||
// 로딩 중이 아니고 오류가 없을 때만 자식 컴포넌트 렌더링
|
|
||||||
// 오류가 있어도 애플리케이션이 중단되지 않도록 처리
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { verifyServerConnection } from "@/utils/auth/networkUtils";
|
|
||||||
|
|
||||||
export { verifyServerConnection };
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { signIn } from "./signIn";
|
|
||||||
export { signUp } from "./signUp";
|
|
||||||
export { signOut } from "./signOut";
|
|
||||||
export { resetPassword } from "./resetPassword";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { AuthProvider } from "./AuthProvider";
|
|
||||||
export { useAuth } from "./useAuth";
|
|
||||||
export type { AuthContextType } from "./types";
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { account } from "@/lib/appwrite/client";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
|
|
||||||
export const resetPassword = async (email: string) => {
|
|
||||||
try {
|
|
||||||
authLogger.info("비밀번호 재설정 시도 중:", email);
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appwrite로 비밀번호 재설정 이메일 발송
|
|
||||||
await account.createRecovery(
|
|
||||||
email,
|
|
||||||
window.location.origin + "/reset-password"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
showAuthToast(
|
|
||||||
"비밀번호 재설정 이메일 전송됨",
|
|
||||||
"이메일을 확인하여 비밀번호를 재설정해주세요."
|
|
||||||
);
|
|
||||||
return { error: null };
|
|
||||||
} catch (recoveryError: any) {
|
|
||||||
authLogger.error("비밀번호 재설정 이메일 전송 오류:", recoveryError);
|
|
||||||
|
|
||||||
// 오류 메시지 처리
|
|
||||||
let errorMessage =
|
|
||||||
recoveryError.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
// Appwrite 오류 코드에 따른 사용자 친화적 메시지
|
|
||||||
if (recoveryError.code === 404) {
|
|
||||||
errorMessage = "등록되지 않은 이메일입니다.";
|
|
||||||
} else if (recoveryError.code === 429) {
|
|
||||||
errorMessage =
|
|
||||||
"너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.";
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("비밀번호 재설정 실패", errorMessage, "destructive");
|
|
||||||
return { error: recoveryError };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("비밀번호 재설정 중 예외 발생:", error);
|
|
||||||
|
|
||||||
// 네트워크 오류 확인
|
|
||||||
const errorMessage =
|
|
||||||
error.message && error.message.includes("network")
|
|
||||||
? "서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요."
|
|
||||||
: "예상치 못한 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
showAuthToast("비밀번호 재설정 오류", errorMessage, "destructive");
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { account } from "@/lib/appwrite/client";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
import { getDefaultUserId } from "@/lib/appwrite/defaultUser";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 기능 - Appwrite 환경에 최적화
|
|
||||||
*/
|
|
||||||
export const signIn = async (email: string, password: string) => {
|
|
||||||
try {
|
|
||||||
authLogger.info("로그인 시도 중:", email);
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
// Appwrite 인증 방식 시도
|
|
||||||
try {
|
|
||||||
const session = await account.createSession(email, password);
|
|
||||||
const user = await account.get();
|
|
||||||
|
|
||||||
// 상태 업데이트를 마이크로태스크로 지연
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
showAuthToast("로그인 성공", "환영합니다!");
|
|
||||||
return { error: null, user };
|
|
||||||
} catch (authError: any) {
|
|
||||||
authLogger.error("로그인 오류:", authError);
|
|
||||||
|
|
||||||
let errorMessage = authError.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
let fallbackMode = false;
|
|
||||||
|
|
||||||
// Appwrite 오류 코드에 따른 사용자 친화적 메시지
|
|
||||||
if (authError.code === 401) {
|
|
||||||
errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다.";
|
|
||||||
} else if (authError.code === 429) {
|
|
||||||
errorMessage =
|
|
||||||
"너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.";
|
|
||||||
} else if (authError.code === 404 || authError.code === 503) {
|
|
||||||
// 서버 연결 문제인 경우 기본 사용자 ID를 활용한 대체 로직 시도
|
|
||||||
errorMessage = "서버 연결에 문제가 있어 일반 모드로 접속합니다.";
|
|
||||||
fallbackMode = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 기본 사용자 ID를 활용한 대체 로직
|
|
||||||
const defaultUserId = getDefaultUserId();
|
|
||||||
authLogger.info(
|
|
||||||
"기본 사용자 ID를 활용한 대체 로직 시도:",
|
|
||||||
defaultUserId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 일반 모드로 접속하는 경우 사용자에게 알림
|
|
||||||
showAuthToast(
|
|
||||||
"일반 모드 접속",
|
|
||||||
"일반 모드로 접속합니다. 일부 기능이 제한될 수 있습니다.",
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 기본 사용자 정보를 가진 가상의 사용자 객체 생성
|
|
||||||
const fallbackUser = {
|
|
||||||
$id: defaultUserId,
|
|
||||||
name: "일반 사용자",
|
|
||||||
email: email,
|
|
||||||
$createdAt: new Date().toISOString(),
|
|
||||||
$updatedAt: new Date().toISOString(),
|
|
||||||
status: true,
|
|
||||||
isFallbackUser: true, // 기본 사용자임을 표시하는 플래그
|
|
||||||
};
|
|
||||||
|
|
||||||
return { error: null, user: fallbackUser, isFallbackMode: true };
|
|
||||||
} catch (fallbackError) {
|
|
||||||
authLogger.error("기본 사용자 대체 로직 오류:", fallbackError);
|
|
||||||
// 대체 로직도 실패한 경우 원래 오류 반환
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fallbackMode) {
|
|
||||||
showAuthToast("로그인 실패", errorMessage, "destructive");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { error: authError, user: null };
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
authLogger.error("로그인 예외 발생:", error);
|
|
||||||
showAuthToast(
|
|
||||||
"로그인 오류",
|
|
||||||
"서버 연결 중 오류가 발생했습니다.",
|
|
||||||
"destructive"
|
|
||||||
);
|
|
||||||
return { error, user: null };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { supabase } from "@/archive/lib/supabase";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 기능 - Supabase Cloud 환경에 최적화
|
|
||||||
*/
|
|
||||||
export const signInWithDirectApi = async (email: string, password: string) => {
|
|
||||||
authLogger.info("Supabase Cloud 로그인 시도");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Supabase Cloud를 통한 로그인 요청
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 오류 응답 처리
|
|
||||||
if (error) {
|
|
||||||
authLogger.error("로그인 오류:", error);
|
|
||||||
|
|
||||||
// 오류 메시지 포맷팅
|
|
||||||
let errorMessage = error.message;
|
|
||||||
|
|
||||||
if (error.message.includes("Invalid login credentials")) {
|
|
||||||
errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다.";
|
|
||||||
} else if (error.message.includes("Email not confirmed")) {
|
|
||||||
errorMessage =
|
|
||||||
"이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.";
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("로그인 실패", errorMessage, "destructive");
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그인 성공 처리
|
|
||||||
if (data && data.user) {
|
|
||||||
authLogger.info("로그인 성공:", data.user);
|
|
||||||
showAuthToast("로그인 성공", "환영합니다!");
|
|
||||||
return { error: null, user: data.user };
|
|
||||||
} else {
|
|
||||||
// 사용자 정보가 없는 경우 (드문 경우)
|
|
||||||
authLogger.warn("로그인 성공했지만 사용자 정보가 없습니다");
|
|
||||||
showAuthToast(
|
|
||||||
"로그인 부분 성공",
|
|
||||||
"로그인은 성공했지만 사용자 정보를 가져오지 못했습니다.",
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
return { error: { message: "사용자 정보 조회 실패" }, user: null };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("로그인 요청 중 예외:", error);
|
|
||||||
const errorMessage = error.message || "로그인 중 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
showAuthToast("로그인 요청 실패", errorMessage, "destructive");
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { account } from "@/lib/appwrite/client";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
import { clearAllToasts } from "@/hooks/toast/toastManager";
|
|
||||||
|
|
||||||
export const signOut = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
authLogger.info("로그아웃 시도 중");
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 현재 세션 아이디 가져오기
|
|
||||||
const currentSession = await account.getSession("current");
|
|
||||||
|
|
||||||
// 현재 세션 삭제
|
|
||||||
await account.deleteSession(currentSession.$id);
|
|
||||||
|
|
||||||
// 로그아웃 시 열려있는 모든 토스트 제거
|
|
||||||
clearAllToasts();
|
|
||||||
|
|
||||||
// 로그아웃 이벤트 발생시켜 SyncSettings 등에서 감지하도록 함
|
|
||||||
window.dispatchEvent(new Event("auth-state-changed"));
|
|
||||||
|
|
||||||
showAuthToast("로그아웃 성공", "다음에 또 만나요!");
|
|
||||||
} catch (sessionError: any) {
|
|
||||||
authLogger.error("세션 삭제 중 오류:", sessionError);
|
|
||||||
|
|
||||||
// 오류 메시지 생성
|
|
||||||
let errorMessage =
|
|
||||||
sessionError.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
// Appwrite 오류 코드에 따른 사용자 친화적 메시지
|
|
||||||
if (sessionError.code === 401) {
|
|
||||||
errorMessage = "이미 로그아웃되었습니다.";
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("로그아웃 실패", errorMessage, "destructive");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("로그아웃 중 예외 발생:", error);
|
|
||||||
|
|
||||||
// 네트워크 오류 확인
|
|
||||||
const errorMessage =
|
|
||||||
error.message && error.message.includes("network")
|
|
||||||
? "서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요."
|
|
||||||
: "예상치 못한 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
showAuthToast("로그아웃 오류", errorMessage, "destructive");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { account, client } from "@/lib/appwrite/client";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { ID } from "appwrite";
|
|
||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
import { isValidConnection } from "@/lib/appwrite/client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입 기능 - Appwrite 환경에 최적화
|
|
||||||
*/
|
|
||||||
export const signUp = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
// 서버 연결 상태 확인
|
|
||||||
const connected = await isValidConnection();
|
|
||||||
if (!connected) {
|
|
||||||
authLogger.error("서버 연결 실패");
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 오류",
|
|
||||||
"서버 연결에 실패했습니다. 네트워크 연결을 확인해주세요.",
|
|
||||||
"destructive"
|
|
||||||
);
|
|
||||||
return { error: { message: "서버 연결 실패" }, user: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
authLogger.info("회원가입 시도:", email);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appwrite로 회원가입 요청
|
|
||||||
const user = await account.create(ID.unique(), email, password, username);
|
|
||||||
|
|
||||||
// 이메일 인증 메일 발송
|
|
||||||
await account.createVerification(window.location.origin + "/login");
|
|
||||||
|
|
||||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
|
||||||
await new Promise<void>((resolve) => queueMicrotask(() => resolve()));
|
|
||||||
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 성공",
|
|
||||||
"인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.",
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
authLogger.info("인증 메일 발송됨:", email);
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: null,
|
|
||||||
user,
|
|
||||||
message: "이메일 인증 필요",
|
|
||||||
emailConfirmationRequired: true,
|
|
||||||
};
|
|
||||||
} catch (authError: any) {
|
|
||||||
authLogger.error("회원가입 오류:", authError);
|
|
||||||
|
|
||||||
// 오류 메시지 처리
|
|
||||||
let errorMessage = authError.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
// Appwrite 오류 코드에 따른 사용자 친화적 메시지
|
|
||||||
if (authError.code === 409) {
|
|
||||||
errorMessage = "이미 등록된 이메일입니다.";
|
|
||||||
} else if (authError.code === 400) {
|
|
||||||
errorMessage = "유효하지 않은 이메일 또는 비밀번호입니다.";
|
|
||||||
} else if (authError.code === 429) {
|
|
||||||
errorMessage =
|
|
||||||
"너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.";
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("회원가입 실패", errorMessage, "destructive");
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("회원가입 전역 예외:", error);
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 오류",
|
|
||||||
error.message || "알 수 없는 오류",
|
|
||||||
"destructive"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
error: { message: error.message || "알 수 없는 오류" },
|
|
||||||
user: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { parseResponse, showAuthToast } from "@/utils/auth";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import {
|
|
||||||
getProxyType,
|
|
||||||
isCorsProxyEnabled,
|
|
||||||
getSupabaseUrl,
|
|
||||||
getOriginalSupabaseUrl,
|
|
||||||
} from "@/lib/supabase/config";
|
|
||||||
import { handleNetworkError } from "@/utils/auth/handleNetworkError";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 직접 API 호출을 통한 회원가입 요청 전송
|
|
||||||
*/
|
|
||||||
export const sendSignUpApiRequest = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string,
|
|
||||||
redirectUrl: string,
|
|
||||||
supabaseKey: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
// 프록시 적용된 URL과 원본 URL 모두 가져오기
|
|
||||||
const supabaseUrl = getOriginalSupabaseUrl(); // 원본 URL
|
|
||||||
const proxyUrl = getSupabaseUrl(); // 프록시 적용된 URL
|
|
||||||
|
|
||||||
// 프록시 정보 로깅
|
|
||||||
const usingProxy = isCorsProxyEnabled();
|
|
||||||
const proxyType = getProxyType();
|
|
||||||
authLogger.info(
|
|
||||||
`CORS 프록시 사용: ${usingProxy ? "예" : "아니오"}, 타입: ${proxyType}, 프록시 URL: ${proxyUrl}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 실제 요청에 사용할 URL 결정 (항상 프록시 URL 사용)
|
|
||||||
const useUrl = usingProxy ? proxyUrl : supabaseUrl;
|
|
||||||
|
|
||||||
// URL에 auth/v1이 이미 포함되어있는지 확인
|
|
||||||
const baseUrl = useUrl.includes("/auth/v1") ? useUrl : `${useUrl}/auth/v1`;
|
|
||||||
|
|
||||||
// 회원가입 API 엔드포인트 및 헤더 설정
|
|
||||||
const signUpUrl = `${baseUrl}/signup`;
|
|
||||||
const headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
apikey: supabaseKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
authLogger.info("회원가입 API 요청 URL:", signUpUrl);
|
|
||||||
|
|
||||||
// 회원가입 요청 전송
|
|
||||||
const response = await fetch(signUpUrl, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
data: { username }, // 사용자 메타데이터에 username 추가
|
|
||||||
redirect_to: redirectUrl, // 리디렉션 URL 추가
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(15000), // 타임아웃 시간 증가
|
|
||||||
});
|
|
||||||
|
|
||||||
authLogger.info("회원가입 응답 상태:", response.status);
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
throw error; // 상위 함수에서 처리하도록 오류 전파
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답 상태 코드 기반 에러 메시지 생성
|
|
||||||
*/
|
|
||||||
export const getStatusErrorMessage = (status: number): string => {
|
|
||||||
switch (status) {
|
|
||||||
case 400:
|
|
||||||
return "잘못된 요청 형식입니다. 입력 데이터를 확인하세요.";
|
|
||||||
case 401:
|
|
||||||
return "회원가입 권한이 없습니다. Supabase 설정 또는 권한을 확인하세요.";
|
|
||||||
case 404:
|
|
||||||
return "서버 경로를 찾을 수 없습니다. Supabase URL을 확인하세요.";
|
|
||||||
case 500:
|
|
||||||
return "서버 내부 오류가 발생했습니다. 나중에 다시 시도하세요.";
|
|
||||||
default:
|
|
||||||
return `회원가입 처리 중 오류가 발생했습니다 (${status}). 나중에 다시 시도하세요.`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { showAuthToast } from "@/utils/auth";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { getProxyType, isCorsProxyEnabled } from "@/lib/supabase/config";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입 API 호출 중 발생한 예외 처리
|
|
||||||
*/
|
|
||||||
export const handleSignUpApiError = (error: any) => {
|
|
||||||
authLogger.error("회원가입 API 호출 중 예외 발생:", error);
|
|
||||||
|
|
||||||
// 프록시 설정 확인
|
|
||||||
const usingProxy = isCorsProxyEnabled();
|
|
||||||
const proxyType = getProxyType();
|
|
||||||
|
|
||||||
// 오류 메시지 설정 및 프록시 추천
|
|
||||||
let errorMessage = error.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
|
|
||||||
// 타임아웃 오류 감지
|
|
||||||
if (errorMessage.includes("timed out") || errorMessage.includes("timeout")) {
|
|
||||||
errorMessage =
|
|
||||||
"서버 응답 시간이 초과되었습니다. 네트워크 상태를 확인하고 다시 시도하세요.";
|
|
||||||
}
|
|
||||||
// CORS 또는 네트워크 오류 감지
|
|
||||||
else if (
|
|
||||||
errorMessage.includes("Failed to fetch") ||
|
|
||||||
errorMessage.includes("NetworkError") ||
|
|
||||||
errorMessage.includes("CORS")
|
|
||||||
) {
|
|
||||||
if (!usingProxy) {
|
|
||||||
errorMessage += " (설정에서 Cloudflare CORS 프록시 활성화를 권장합니다)";
|
|
||||||
} else if (proxyType !== "cloudflare") {
|
|
||||||
errorMessage += " (설정에서 Cloudflare CORS 프록시로 변경을 권장합니다)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("회원가입 오류", errorMessage, "destructive");
|
|
||||||
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답 데이터에서 특정 에러 처리
|
|
||||||
*/
|
|
||||||
export const handleResponseError = (responseData: any) => {
|
|
||||||
if (responseData && responseData.error) {
|
|
||||||
const errorMessage =
|
|
||||||
responseData.error_description || responseData.error || "회원가입 실패";
|
|
||||||
|
|
||||||
if (responseData.error === "user_already_registered") {
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 실패",
|
|
||||||
"이미 등록된 이메일 주소입니다.",
|
|
||||||
"destructive"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
error: { message: "이미 등록된 이메일 주소입니다." },
|
|
||||||
user: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("회원가입 실패", errorMessage, "destructive");
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { supabase } from "@/archive/lib/supabase";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { parseResponse, showAuthToast } from "@/utils/auth";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입 기능 - Supabase Cloud 환경에 최적화
|
|
||||||
*/
|
|
||||||
export const signUpWithDirectApi = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string,
|
|
||||||
redirectUrl?: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
authLogger.info("Supabase Cloud 회원가입 시도 중");
|
|
||||||
|
|
||||||
// 리디렉션 URL 설정 (전달되지 않은 경우 기본값 사용)
|
|
||||||
const finalRedirectUrl =
|
|
||||||
redirectUrl || `${window.location.origin}/login?auth_callback=true`;
|
|
||||||
authLogger.info("이메일 인증 리디렉션 URL:", finalRedirectUrl);
|
|
||||||
|
|
||||||
// Supabase Cloud API를 통한 회원가입 요청
|
|
||||||
const { data, error } = await supabase.auth.signUp({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
options: {
|
|
||||||
data: {
|
|
||||||
username, // 사용자 이름을 메타데이터에 저장
|
|
||||||
},
|
|
||||||
emailRedirectTo: finalRedirectUrl,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 오류 처리
|
|
||||||
if (error) {
|
|
||||||
authLogger.error("회원가입 오류:", error);
|
|
||||||
|
|
||||||
let errorMessage = error.message;
|
|
||||||
if (error.message.includes("User already registered")) {
|
|
||||||
errorMessage = "이미 등록된 사용자입니다.";
|
|
||||||
} else if (error.message.includes("Signup not allowed")) {
|
|
||||||
errorMessage = "회원가입이 허용되지 않습니다.";
|
|
||||||
} else if (error.message.includes("Email link invalid")) {
|
|
||||||
errorMessage = "이메일 링크가 유효하지 않습니다.";
|
|
||||||
}
|
|
||||||
|
|
||||||
showAuthToast("회원가입 실패", errorMessage, "destructive");
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회원가입 성공
|
|
||||||
if (data && data.user) {
|
|
||||||
// 이메일 확인이 필요한지 확인
|
|
||||||
const isEmailConfirmationRequired =
|
|
||||||
data.user.identities &&
|
|
||||||
data.user.identities.length > 0 &&
|
|
||||||
!data.user.identities[0].identity_data?.email_verified;
|
|
||||||
|
|
||||||
if (isEmailConfirmationRequired) {
|
|
||||||
// 인증 메일 전송 성공 메시지와 이메일 확인 안내
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 성공",
|
|
||||||
"인증 메일이 발송되었습니다. 스팸 폴더도 확인해주세요.",
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
authLogger.info("인증 메일 발송됨:", email);
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: null,
|
|
||||||
user: data.user,
|
|
||||||
message: "이메일 인증 필요",
|
|
||||||
emailConfirmationRequired: true,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
showAuthToast("회원가입 성공", "환영합니다!", "default");
|
|
||||||
return { error: null, user: data.user };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 사용자 데이터가 없는 경우 (드물게 발생)
|
|
||||||
authLogger.warn("회원가입 응답은 성공했지만 사용자 데이터가 없습니다");
|
|
||||||
showAuthToast(
|
|
||||||
"회원가입 성공",
|
|
||||||
"계정이 생성되었습니다. 이메일 인증을 완료한 후 로그인해주세요.",
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: null,
|
|
||||||
user: { email },
|
|
||||||
message: "회원가입 완료",
|
|
||||||
emailConfirmationRequired: true,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
authLogger.error("회원가입 중 예외 발생:", error);
|
|
||||||
|
|
||||||
const errorMessage = error.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
showAuthToast("회원가입 오류", errorMessage, "destructive");
|
|
||||||
|
|
||||||
return { error: { message: errorMessage }, user: null };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Models } from "appwrite";
|
|
||||||
import type { ApiError } from "@/types/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 초기화 상태 반환 타입
|
|
||||||
*/
|
|
||||||
export interface AppwriteInitializationStatus {
|
|
||||||
isInitialized: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 응답 타입
|
|
||||||
*/
|
|
||||||
export interface AuthResponse {
|
|
||||||
error: ApiError | null;
|
|
||||||
user?: Models.User<Models.Preferences>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입 응답 타입
|
|
||||||
*/
|
|
||||||
export interface SignUpResponse {
|
|
||||||
error: ApiError | null;
|
|
||||||
user: Models.User<Models.Preferences> | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 재설정 응답 타입
|
|
||||||
*/
|
|
||||||
export interface ResetPasswordResponse {
|
|
||||||
error: ApiError | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 컨텍스트 타입
|
|
||||||
*/
|
|
||||||
export interface AuthContextType {
|
|
||||||
session: Models.Session | null;
|
|
||||||
user: Models.User<Models.Preferences> | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
appwriteInitialized: boolean;
|
|
||||||
reinitializeAppwrite: () => AppwriteInitializationStatus;
|
|
||||||
signIn: (email: string, password: string) => Promise<AuthResponse>;
|
|
||||||
signUp: (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string
|
|
||||||
) => Promise<SignUpResponse>;
|
|
||||||
signOut: () => Promise<void>;
|
|
||||||
resetPassword: (email: string) => Promise<ResetPasswordResponse>;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { AuthContext } from "./AuthContext";
|
|
||||||
import { AuthContextType } from "./types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 인증 컨텍스트에 접근하기 위한 커스텀 훅
|
|
||||||
* AuthProvider 내부에서만 사용해야 함
|
|
||||||
*/
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useAuth는 AuthProvider 내부에서 사용해야 합니다");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { account } from "@/lib/appwrite";
|
|
||||||
import { ID } from "appwrite";
|
|
||||||
|
|
||||||
// 인증 상태 인터페이스
|
|
||||||
interface AuthState {
|
|
||||||
user: any | null;
|
|
||||||
loading: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그인 입력값 인터페이스
|
|
||||||
interface LoginCredentials {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 회원가입 입력값 인터페이스
|
|
||||||
interface SignupCredentials extends LoginCredentials {
|
|
||||||
name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 인증 관련 훅
|
|
||||||
* 로그인, 로그아웃, 회원가입 및 사용자 상태 관리
|
|
||||||
*/
|
|
||||||
export const useAppwriteAuth = () => {
|
|
||||||
// 인증 상태 관리
|
|
||||||
const [authState, setAuthState] = useState<AuthState>({
|
|
||||||
user: null,
|
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 상태 추적
|
|
||||||
const [isMounted, setIsMounted] = useState(true);
|
|
||||||
|
|
||||||
// 사용자 정보 가져오기
|
|
||||||
const getCurrentUser = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const user = await account.get();
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState({
|
|
||||||
user,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState({
|
|
||||||
user: null,
|
|
||||||
loading: false,
|
|
||||||
error: error as Error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}, [isMounted]);
|
|
||||||
|
|
||||||
// 이메일/비밀번호로 로그인
|
|
||||||
const login = useCallback(
|
|
||||||
async ({ email, password }: LoginCredentials) => {
|
|
||||||
try {
|
|
||||||
setAuthState((prev) => ({ ...prev, loading: true, error: null }));
|
|
||||||
|
|
||||||
// 비동기 작업 시작 전 UI 스레드 차단 방지
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const session = await account.createEmailPasswordSession(
|
|
||||||
email,
|
|
||||||
password
|
|
||||||
);
|
|
||||||
const user = await account.get();
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState({
|
|
||||||
user,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { user, session };
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error as Error,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isMounted]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 회원가입
|
|
||||||
const signup = useCallback(
|
|
||||||
async ({ email, password, name }: SignupCredentials) => {
|
|
||||||
try {
|
|
||||||
setAuthState((prev) => ({ ...prev, loading: true, error: null }));
|
|
||||||
|
|
||||||
// 비동기 작업 시작 전 UI 스레드 차단 방지
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const user = await account.create(ID.unique(), email, password, name);
|
|
||||||
|
|
||||||
// 회원가입 후 자동 로그인
|
|
||||||
await account.createEmailPasswordSession(email, password);
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState({
|
|
||||||
user,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error as Error,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isMounted]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 로그아웃
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setAuthState((prev) => ({ ...prev, loading: true }));
|
|
||||||
|
|
||||||
// 현재 세션 삭제
|
|
||||||
await account.deleteSession("current");
|
|
||||||
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState({
|
|
||||||
user: null,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (isMounted) {
|
|
||||||
setAuthState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
loading: false,
|
|
||||||
error: error as Error,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [isMounted]);
|
|
||||||
|
|
||||||
// 초기 사용자 정보 로드
|
|
||||||
useEffect(() => {
|
|
||||||
setIsMounted(true);
|
|
||||||
getCurrentUser();
|
|
||||||
|
|
||||||
// 정리 함수
|
|
||||||
return () => {
|
|
||||||
setIsMounted(false);
|
|
||||||
};
|
|
||||||
}, [getCurrentUser]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: authState.user,
|
|
||||||
loading: authState.loading,
|
|
||||||
error: authState.error,
|
|
||||||
login,
|
|
||||||
signup,
|
|
||||||
logout,
|
|
||||||
getCurrentUser,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useAppwriteAuth;
|
|
||||||
@@ -4,27 +4,11 @@
|
|||||||
* 모든 React Query 훅들을 한 곳에서 관리하고 내보냅니다.
|
* 모든 React Query 훅들을 한 곳에서 관리하고 내보냅니다.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// 인증 관련 훅들
|
// 인증 관련 훅들 - Clerk로 이전됨
|
||||||
export {
|
// Clerk hooks는 @clerk/clerk-react에서 직접 사용
|
||||||
useUserQuery,
|
|
||||||
useSessionQuery,
|
|
||||||
useSignInMutation,
|
|
||||||
useSignUpMutation,
|
|
||||||
useSignOutMutation,
|
|
||||||
useResetPasswordMutation,
|
|
||||||
useAuth,
|
|
||||||
} from "./useAuthQueries";
|
|
||||||
|
|
||||||
// 트랜잭션 관련 훅들
|
// 트랜잭션 관련 훅들 - Supabase로 이전 예정 (Task 11.6)
|
||||||
export {
|
// Supabase CRUD 훅들은 향후 구현 예정
|
||||||
useTransactionsQuery,
|
|
||||||
useTransactionQuery,
|
|
||||||
useCreateTransactionMutation,
|
|
||||||
useUpdateTransactionMutation,
|
|
||||||
useDeleteTransactionMutation,
|
|
||||||
useTransactions,
|
|
||||||
useTransactionStatsQuery,
|
|
||||||
} from "./useTransactionQueries";
|
|
||||||
|
|
||||||
// 동기화 관련 훅들
|
// 동기화 관련 훅들
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
/**
|
|
||||||
* 인증 관련 React Query 훅들
|
|
||||||
*
|
|
||||||
* 기존 Zustand 스토어의 인증 로직을 React Query로 전환하여
|
|
||||||
* 서버 상태 관리를 최적화합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getCurrentUser,
|
|
||||||
createSession,
|
|
||||||
createAccount,
|
|
||||||
deleteCurrentSession,
|
|
||||||
sendPasswordRecoveryEmail,
|
|
||||||
} from "@/lib/appwrite/setup";
|
|
||||||
import {
|
|
||||||
queryKeys,
|
|
||||||
queryConfigs,
|
|
||||||
handleQueryError,
|
|
||||||
} from "@/lib/query/queryClient";
|
|
||||||
import { authLogger } from "@/utils/logger";
|
|
||||||
import { useAuthStore } from "@/stores";
|
|
||||||
import type {
|
|
||||||
AuthResponse,
|
|
||||||
SignUpResponse,
|
|
||||||
ResetPasswordResponse,
|
|
||||||
} from "@/contexts/auth/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 사용자 정보 조회 쿼리
|
|
||||||
*
|
|
||||||
* - 자동 캐싱 및 백그라운드 동기화
|
|
||||||
* - 윈도우 포커스 시 자동 refetch
|
|
||||||
* - 에러 발생 시 자동 재시도
|
|
||||||
*/
|
|
||||||
export const useUserQuery = () => {
|
|
||||||
const { session } = useAuthStore();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.auth.user(),
|
|
||||||
queryFn: async () => {
|
|
||||||
authLogger.info("사용자 정보 조회 시작");
|
|
||||||
const result = await getCurrentUser();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
authLogger.info("사용자 정보 조회 성공", { userId: result.user?.$id });
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
...queryConfigs.userInfo,
|
|
||||||
|
|
||||||
// 세션이 있을 때만 쿼리 활성화
|
|
||||||
enabled: !!session,
|
|
||||||
|
|
||||||
// 에러 시 로그아웃 상태로 전환
|
|
||||||
retry: (failureCount, error: any) => {
|
|
||||||
if (
|
|
||||||
error?.message?.includes("401") ||
|
|
||||||
error?.message?.includes("Unauthorized")
|
|
||||||
) {
|
|
||||||
// 인증 에러는 재시도하지 않음
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return failureCount < 2;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 Zustand 스토어 업데이트
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.user) {
|
|
||||||
useAuthStore.getState().setUser(data.user);
|
|
||||||
}
|
|
||||||
if (data.session) {
|
|
||||||
useAuthStore.getState().setSession(data.session);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 스토어 정리
|
|
||||||
onError: (error: any) => {
|
|
||||||
authLogger.error("사용자 정보 조회 실패:", error);
|
|
||||||
if (error?.message?.includes("401")) {
|
|
||||||
// 401 에러 시 로그아웃 처리
|
|
||||||
useAuthStore.getState().setUser(null);
|
|
||||||
useAuthStore.getState().setSession(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세션 상태 조회 쿼리 (가볍게 사용)
|
|
||||||
*
|
|
||||||
* 사용자 정보 없이 세션 상태만 확인할 때 사용
|
|
||||||
*/
|
|
||||||
export const useSessionQuery = () => {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.auth.session(),
|
|
||||||
queryFn: async () => {
|
|
||||||
const result = await getCurrentUser();
|
|
||||||
return result.session;
|
|
||||||
},
|
|
||||||
staleTime: 1 * 60 * 1000, // 1분
|
|
||||||
gcTime: 5 * 60 * 1000, // 5분
|
|
||||||
|
|
||||||
// 에러 무시 (세션 체크용)
|
|
||||||
retry: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그인 뮤테이션
|
|
||||||
*
|
|
||||||
* - 성공 시 사용자 정보 쿼리 무효화
|
|
||||||
* - Zustand 스토어와 동기화
|
|
||||||
* - 에러 핸들링 및 토스트 알림
|
|
||||||
*/
|
|
||||||
export const useSignInMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
}: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}): Promise<AuthResponse> => {
|
|
||||||
authLogger.info("로그인 뮤테이션 시작", { email });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sessionResult = await createSession(email, password);
|
|
||||||
|
|
||||||
if (sessionResult.error) {
|
|
||||||
return { error: sessionResult.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionResult.session) {
|
|
||||||
// 세션 생성 성공 시 사용자 정보 조회
|
|
||||||
const userResult = await getCurrentUser();
|
|
||||||
|
|
||||||
if (userResult.user && userResult.session) {
|
|
||||||
authLogger.info("로그인 성공", { userId: userResult.user.$id });
|
|
||||||
return { user: userResult.user, error: null };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
message: "세션 또는 사용자 정보를 가져올 수 없습니다",
|
|
||||||
code: "AUTH_ERROR",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "로그인 중 알 수 없는 오류가 발생했습니다";
|
|
||||||
authLogger.error("로그인 에러:", error);
|
|
||||||
return { error: { message: errorMessage, code: "UNKNOWN_ERROR" } };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 처리
|
|
||||||
onSuccess: (data) => {
|
|
||||||
if (data.user && !data.error) {
|
|
||||||
// Zustand 스토어 업데이트
|
|
||||||
useAuthStore.getState().setUser(data.user);
|
|
||||||
|
|
||||||
// 관련 쿼리 무효화하여 최신 데이터 로드
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth.user() });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session() });
|
|
||||||
|
|
||||||
authLogger.info("로그인 뮤테이션 성공 - 쿼리 무효화 완료");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 처리
|
|
||||||
onError: (error: any) => {
|
|
||||||
const friendlyMessage = handleQueryError(error, "로그인");
|
|
||||||
authLogger.error("로그인 뮤테이션 실패:", friendlyMessage);
|
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 회원가입 뮤테이션
|
|
||||||
*/
|
|
||||||
export const useSignUpMutation = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
username,
|
|
||||||
}: {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
username: string;
|
|
||||||
}): Promise<SignUpResponse> => {
|
|
||||||
authLogger.info("회원가입 뮤테이션 시작", { email, username });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await createAccount(email, password, username);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { error: result.error, user: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
authLogger.info("회원가입 성공", { userId: result.user?.$id });
|
|
||||||
return { error: null, user: result.user };
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "회원가입 중 알 수 없는 오류가 발생했습니다";
|
|
||||||
authLogger.error("회원가입 에러:", error);
|
|
||||||
return {
|
|
||||||
error: { message: errorMessage, code: "UNKNOWN_ERROR" },
|
|
||||||
user: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: (error: any) => {
|
|
||||||
const friendlyMessage = handleQueryError(error, "회원가입");
|
|
||||||
authLogger.error("회원가입 뮤테이션 실패:", friendlyMessage);
|
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그아웃 뮤테이션
|
|
||||||
*/
|
|
||||||
export const useSignOutMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (): Promise<void> => {
|
|
||||||
authLogger.info("로그아웃 뮤테이션 시작");
|
|
||||||
await deleteCurrentSession();
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 모든 인증 관련 데이터 정리
|
|
||||||
onSuccess: () => {
|
|
||||||
// Zustand 스토어 정리
|
|
||||||
useAuthStore.getState().setSession(null);
|
|
||||||
useAuthStore.getState().setUser(null);
|
|
||||||
useAuthStore.getState().setError(null);
|
|
||||||
|
|
||||||
// 모든 쿼리 캐시 정리 (민감한 데이터 제거)
|
|
||||||
queryClient.clear();
|
|
||||||
|
|
||||||
authLogger.info("로그아웃 성공 - 모든 캐시 정리 완료");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시에도 로컬 상태는 정리
|
|
||||||
onError: (error: any) => {
|
|
||||||
authLogger.error("로그아웃 에러:", error);
|
|
||||||
|
|
||||||
// 에러가 발생해도 로컬 상태는 정리
|
|
||||||
useAuthStore.getState().setSession(null);
|
|
||||||
useAuthStore.getState().setUser(null);
|
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, "로그아웃");
|
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 재설정 뮤테이션
|
|
||||||
*/
|
|
||||||
export const useResetPasswordMutation = () => {
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
email,
|
|
||||||
}: {
|
|
||||||
email: string;
|
|
||||||
}): Promise<ResetPasswordResponse> => {
|
|
||||||
authLogger.info("비밀번호 재설정 뮤테이션 시작", { email });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await sendPasswordRecoveryEmail(email);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
return { error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
authLogger.info("비밀번호 재설정 이메일 발송 성공");
|
|
||||||
return { error: null };
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "비밀번호 재설정 중 오류가 발생했습니다";
|
|
||||||
authLogger.error("비밀번호 재설정 에러:", error);
|
|
||||||
return { error: { message: errorMessage, code: "UNKNOWN_ERROR" } };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onError: (error: any) => {
|
|
||||||
const friendlyMessage = handleQueryError(error, "비밀번호 재설정");
|
|
||||||
authLogger.error("비밀번호 재설정 뮤테이션 실패:", friendlyMessage);
|
|
||||||
useAuthStore.getState().setError(new Error(friendlyMessage));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통합 인증 훅 (기존 useAuth와 호환성 유지)
|
|
||||||
*
|
|
||||||
* React Query와 Zustand를 조합하여
|
|
||||||
* 기존 컴포넌트들이 큰 변경 없이 사용할 수 있도록 함
|
|
||||||
*/
|
|
||||||
export const useAuth = () => {
|
|
||||||
const { user, session, loading, error } = useAuthStore();
|
|
||||||
const userQuery = useUserQuery();
|
|
||||||
const signInMutation = useSignInMutation();
|
|
||||||
const signUpMutation = useSignUpMutation();
|
|
||||||
const signOutMutation = useSignOutMutation();
|
|
||||||
const resetPasswordMutation = useResetPasswordMutation();
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 상태 (Zustand + React Query 조합)
|
|
||||||
user,
|
|
||||||
session,
|
|
||||||
loading: loading || userQuery.isLoading,
|
|
||||||
error: error || userQuery.error,
|
|
||||||
appwriteInitialized: useAuthStore((state) => state.appwriteInitialized),
|
|
||||||
|
|
||||||
// 액션 (React Query 뮤테이션)
|
|
||||||
signIn: signInMutation.mutate,
|
|
||||||
signUp: signUpMutation.mutate,
|
|
||||||
signOut: signOutMutation.mutate,
|
|
||||||
resetPassword: resetPasswordMutation.mutate,
|
|
||||||
reinitializeAppwrite: useAuthStore.getState().reinitializeAppwrite,
|
|
||||||
|
|
||||||
// React Query 상태 (필요시 접근)
|
|
||||||
queries: {
|
|
||||||
user: userQuery,
|
|
||||||
isSigningIn: signInMutation.isPending,
|
|
||||||
isSigningUp: signUpMutation.isPending,
|
|
||||||
isSigningOut: signOutMutation.isPending,
|
|
||||||
isResettingPassword: resetPasswordMutation.isPending,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -223,7 +223,7 @@ export const useBackgroundSyncMutation = () => {
|
|||||||
* - 설정된 간격에 따라 백그라운드 동기화 실행
|
* - 설정된 간격에 따라 백그라운드 동기화 실행
|
||||||
* - 네트워크 상태에 따른 동적 조정
|
* - 네트워크 상태에 따른 동적 조정
|
||||||
*/
|
*/
|
||||||
export const useAutoSyncQuery = (intervalMinutes: number = 5) => {
|
export const useAutoSyncQuery = (intervalMinutes = 5) => {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const backgroundSyncMutation = useBackgroundSyncMutation();
|
const backgroundSyncMutation = useBackgroundSyncMutation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,495 +0,0 @@
|
|||||||
/**
|
|
||||||
* 거래 관련 React Query 훅들
|
|
||||||
*
|
|
||||||
* 기존 Zustand 스토어의 트랜잭션 로직을 React Query로 전환하여
|
|
||||||
* 서버 상태 관리와 최적화된 캐싱을 제공합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
getAllTransactions,
|
|
||||||
saveTransaction,
|
|
||||||
updateExistingTransaction,
|
|
||||||
deleteTransactionById,
|
|
||||||
} from "@/lib/appwrite/setup";
|
|
||||||
import {
|
|
||||||
queryKeys,
|
|
||||||
queryConfigs,
|
|
||||||
handleQueryError,
|
|
||||||
invalidateQueries,
|
|
||||||
} from "@/lib/query/queryClient";
|
|
||||||
import { syncLogger } from "@/utils/logger";
|
|
||||||
import { useAuthStore, useBudgetStore } from "@/stores";
|
|
||||||
import type { Transaction } from "@/contexts/budget/types";
|
|
||||||
import { toast } from "@/hooks/useToast.wrapper";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 목록 조회 쿼리
|
|
||||||
*
|
|
||||||
* - 실시간 캐싱 및 백그라운드 동기화
|
|
||||||
* - 필터링 및 정렬 지원
|
|
||||||
* - 에러 발생 시 자동 재시도
|
|
||||||
*/
|
|
||||||
export const useTransactionsQuery = (filters?: Record<string, any>) => {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.transactions.list(filters),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 목록 조회 시작", { userId: user.id, filters });
|
|
||||||
const result = await getAllTransactions(user.id);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 목록 조회 성공", {
|
|
||||||
count: result.transactions?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.transactions || [];
|
|
||||||
},
|
|
||||||
...queryConfigs.transactions,
|
|
||||||
|
|
||||||
// 사용자가 로그인한 경우에만 쿼리 활성화
|
|
||||||
enabled: !!user?.id,
|
|
||||||
|
|
||||||
// 성공 시 Zustand 스토어 동기화
|
|
||||||
onSuccess: (transactions) => {
|
|
||||||
useBudgetStore.getState().setTransactions(transactions);
|
|
||||||
syncLogger.info("Zustand 스토어 트랜잭션 동기화 완료", {
|
|
||||||
count: transactions.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 처리
|
|
||||||
onError: (error: any) => {
|
|
||||||
syncLogger.error("트랜잭션 목록 조회 실패:", error);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 트랜잭션 조회 쿼리
|
|
||||||
*/
|
|
||||||
export const useTransactionQuery = (transactionId: string) => {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.transactions.detail(transactionId),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모든 트랜잭션을 가져와서 특정 ID 찾기
|
|
||||||
const result = await getAllTransactions(user.id);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transaction = result.transactions?.find(
|
|
||||||
(t) => t.id === transactionId
|
|
||||||
);
|
|
||||||
if (!transaction) {
|
|
||||||
throw new Error("트랜잭션을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return transaction;
|
|
||||||
},
|
|
||||||
...queryConfigs.transactions,
|
|
||||||
enabled: !!user?.id && !!transactionId,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 생성 뮤테이션
|
|
||||||
*
|
|
||||||
* - 낙관적 업데이트 지원
|
|
||||||
* - 성공 시 관련 쿼리 무효화
|
|
||||||
* - Zustand 스토어 동기화
|
|
||||||
*/
|
|
||||||
export const useCreateTransactionMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (
|
|
||||||
transactionData: Omit<Transaction, "id" | "localTimestamp">
|
|
||||||
): Promise<Transaction> => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 생성 뮤테이션 시작", {
|
|
||||||
amount: transactionData.amount,
|
|
||||||
category: transactionData.category,
|
|
||||||
type: transactionData.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await saveTransaction({
|
|
||||||
...transactionData,
|
|
||||||
userId: user.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.transaction) {
|
|
||||||
throw new Error("트랜잭션 생성에 실패했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 생성 성공", {
|
|
||||||
id: result.transaction.id,
|
|
||||||
amount: result.transaction.amount,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.transaction;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 낙관적 업데이트
|
|
||||||
onMutate: async (newTransaction) => {
|
|
||||||
// 진행 중인 쿼리 취소
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: queryKeys.transactions.all(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 이전 데이터 백업
|
|
||||||
const previousTransactions = queryClient.getQueryData(
|
|
||||||
queryKeys.transactions.list()
|
|
||||||
) as Transaction[] | undefined;
|
|
||||||
|
|
||||||
// 낙관적으로 새 트랜잭션 추가
|
|
||||||
if (previousTransactions) {
|
|
||||||
const optimisticTransaction: Transaction = {
|
|
||||||
...newTransaction,
|
|
||||||
id: `temp-${Date.now()}`,
|
|
||||||
localTimestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
queryClient.setQueryData(queryKeys.transactions.list(), [
|
|
||||||
...previousTransactions,
|
|
||||||
optimisticTransaction,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
|
||||||
useBudgetStore.getState().addTransaction(newTransaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previousTransactions };
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 처리
|
|
||||||
onSuccess: (newTransaction) => {
|
|
||||||
// 모든 트랜잭션 관련 쿼리 무효화
|
|
||||||
invalidateQueries.transactions();
|
|
||||||
|
|
||||||
// 토스트 알림
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 생성 완료",
|
|
||||||
description: `${newTransaction.type === "expense" ? "지출" : "수입"} ${newTransaction.amount.toLocaleString()}원이 추가되었습니다.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 생성 뮤테이션 성공 완료");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 롤백
|
|
||||||
onError: (error: any, newTransaction, context) => {
|
|
||||||
// 이전 데이터로 롤백
|
|
||||||
if (context?.previousTransactions) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
queryKeys.transactions.list(),
|
|
||||||
context.previousTransactions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, "트랜잭션 생성");
|
|
||||||
syncLogger.error("트랜잭션 생성 뮤테이션 실패:", friendlyMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 생성 실패",
|
|
||||||
description: friendlyMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 업데이트 뮤테이션
|
|
||||||
*/
|
|
||||||
export const useUpdateTransactionMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (
|
|
||||||
updatedTransaction: Transaction
|
|
||||||
): Promise<Transaction> => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 업데이트 뮤테이션 시작", {
|
|
||||||
id: updatedTransaction.id,
|
|
||||||
amount: updatedTransaction.amount,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await updateExistingTransaction(updatedTransaction);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.transaction) {
|
|
||||||
throw new Error("트랜잭션 업데이트에 실패했습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 업데이트 성공", {
|
|
||||||
id: result.transaction.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.transaction;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 낙관적 업데이트
|
|
||||||
onMutate: async (updatedTransaction) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: queryKeys.transactions.all(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousTransactions = queryClient.getQueryData(
|
|
||||||
queryKeys.transactions.list()
|
|
||||||
) as Transaction[] | undefined;
|
|
||||||
|
|
||||||
if (previousTransactions) {
|
|
||||||
const optimisticTransactions = previousTransactions.map((t) =>
|
|
||||||
t.id === updatedTransaction.id
|
|
||||||
? {
|
|
||||||
...updatedTransaction,
|
|
||||||
localTimestamp: new Date().toISOString(),
|
|
||||||
}
|
|
||||||
: t
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.setQueryData(
|
|
||||||
queryKeys.transactions.list(),
|
|
||||||
optimisticTransactions
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
|
||||||
useBudgetStore.getState().updateTransaction(updatedTransaction);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previousTransactions };
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 처리
|
|
||||||
onSuccess: (updatedTransaction) => {
|
|
||||||
// 관련 쿼리 무효화
|
|
||||||
invalidateQueries.transactions();
|
|
||||||
invalidateQueries.transaction(updatedTransaction.id);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 수정 완료",
|
|
||||||
description: "트랜잭션이 성공적으로 수정되었습니다.",
|
|
||||||
});
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 업데이트 뮤테이션 성공 완료");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 롤백
|
|
||||||
onError: (error: any, updatedTransaction, context) => {
|
|
||||||
if (context?.previousTransactions) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
queryKeys.transactions.list(),
|
|
||||||
context.previousTransactions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, "트랜잭션 수정");
|
|
||||||
syncLogger.error("트랜잭션 업데이트 뮤테이션 실패:", friendlyMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 수정 실패",
|
|
||||||
description: friendlyMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 삭제 뮤테이션
|
|
||||||
*/
|
|
||||||
export const useDeleteTransactionMutation = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (transactionId: string): Promise<void> => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 삭제 뮤테이션 시작", { id: transactionId });
|
|
||||||
|
|
||||||
const result = await deleteTransactionById(transactionId);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 삭제 성공", { id: transactionId });
|
|
||||||
},
|
|
||||||
|
|
||||||
// 낙관적 업데이트
|
|
||||||
onMutate: async (transactionId) => {
|
|
||||||
await queryClient.cancelQueries({
|
|
||||||
queryKey: queryKeys.transactions.all(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const previousTransactions = queryClient.getQueryData(
|
|
||||||
queryKeys.transactions.list()
|
|
||||||
) as Transaction[] | undefined;
|
|
||||||
|
|
||||||
if (previousTransactions) {
|
|
||||||
const optimisticTransactions = previousTransactions.filter(
|
|
||||||
(t) => t.id !== transactionId
|
|
||||||
);
|
|
||||||
queryClient.setQueryData(
|
|
||||||
queryKeys.transactions.list(),
|
|
||||||
optimisticTransactions
|
|
||||||
);
|
|
||||||
|
|
||||||
// Zustand 스토어에도 즉시 반영
|
|
||||||
useBudgetStore.getState().deleteTransaction(transactionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { previousTransactions };
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성공 시 처리
|
|
||||||
onSuccess: (_, transactionId) => {
|
|
||||||
// 관련 쿼리 무효화
|
|
||||||
invalidateQueries.transactions();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 삭제 완료",
|
|
||||||
description: "트랜잭션이 성공적으로 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
|
|
||||||
syncLogger.info("트랜잭션 삭제 뮤테이션 성공 완료");
|
|
||||||
},
|
|
||||||
|
|
||||||
// 에러 시 롤백
|
|
||||||
onError: (error: any, transactionId, context) => {
|
|
||||||
if (context?.previousTransactions) {
|
|
||||||
queryClient.setQueryData(
|
|
||||||
queryKeys.transactions.list(),
|
|
||||||
context.previousTransactions
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const friendlyMessage = handleQueryError(error, "트랜잭션 삭제");
|
|
||||||
syncLogger.error("트랜잭션 삭제 뮤테이션 실패:", friendlyMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "트랜잭션 삭제 실패",
|
|
||||||
description: friendlyMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통합 트랜잭션 훅 (기존 Zustand 훅과 호환성 유지)
|
|
||||||
*
|
|
||||||
* React Query와 Zustand를 조합하여
|
|
||||||
* 기존 컴포넌트들이 큰 변경 없이 사용할 수 있도록 함
|
|
||||||
*/
|
|
||||||
export const useTransactions = (filters?: Record<string, any>) => {
|
|
||||||
const transactionsQuery = useTransactionsQuery(filters);
|
|
||||||
const createMutation = useCreateTransactionMutation();
|
|
||||||
const updateMutation = useUpdateTransactionMutation();
|
|
||||||
const deleteMutation = useDeleteTransactionMutation();
|
|
||||||
|
|
||||||
// Zustand 스토어의 계산 함수들도 함께 제공
|
|
||||||
const { getCategorySpending, getPaymentMethodStats } = useBudgetStore();
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 데이터 상태 (React Query)
|
|
||||||
transactions: transactionsQuery.data || [],
|
|
||||||
loading: transactionsQuery.isLoading,
|
|
||||||
error: transactionsQuery.error,
|
|
||||||
|
|
||||||
// CRUD 액션 (React Query 뮤테이션)
|
|
||||||
addTransaction: createMutation.mutate,
|
|
||||||
updateTransaction: updateMutation.mutate,
|
|
||||||
deleteTransaction: deleteMutation.mutate,
|
|
||||||
|
|
||||||
// 뮤테이션 상태
|
|
||||||
isAdding: createMutation.isPending,
|
|
||||||
isUpdating: updateMutation.isPending,
|
|
||||||
isDeleting: deleteMutation.isPending,
|
|
||||||
|
|
||||||
// 분석 함수 (Zustand 스토어)
|
|
||||||
getCategorySpending,
|
|
||||||
getPaymentMethodStats,
|
|
||||||
|
|
||||||
// React Query 제어
|
|
||||||
refetch: transactionsQuery.refetch,
|
|
||||||
isFetching: transactionsQuery.isFetching,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 통계 쿼리 (파생 데이터)
|
|
||||||
*/
|
|
||||||
export const useTransactionStatsQuery = () => {
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
return useQuery({
|
|
||||||
queryKey: queryKeys.budget.stats(),
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!user?.id) {
|
|
||||||
throw new Error("사용자 인증이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getAllTransactions(user.id);
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const transactions = result.transactions || [];
|
|
||||||
|
|
||||||
// 통계 계산
|
|
||||||
const totalExpenses = transactions
|
|
||||||
.filter((t) => t.type === "expense")
|
|
||||||
.reduce((sum, t) => sum + t.amount, 0);
|
|
||||||
|
|
||||||
const totalIncome = transactions
|
|
||||||
.filter((t) => t.type === "income")
|
|
||||||
.reduce((sum, t) => sum + t.amount, 0);
|
|
||||||
|
|
||||||
const balance = totalIncome - totalExpenses;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalExpenses,
|
|
||||||
totalIncome,
|
|
||||||
balance,
|
|
||||||
transactionCount: transactions.length,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
...queryConfigs.statistics,
|
|
||||||
enabled: !!user?.id,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Models } from "appwrite";
|
import { User } from "@clerk/clerk-react";
|
||||||
import { useSync } from "@/hooks/query";
|
import { useSync } from "@/hooks/query";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 수동 동기화 기능을 위한 커스텀 훅 (React Query 기반)
|
* 수동 동기화 기능을 위한 커스텀 훅 (React Query 기반)
|
||||||
*
|
*
|
||||||
* 기존 인터페이스를 유지하면서 내부적으로 React Query를 사용합니다.
|
* 기존 인터페이스를 유지하면서 내부적으로 React Query를 사용합니다.
|
||||||
|
* Clerk User 타입으로 업데이트됨
|
||||||
*/
|
*/
|
||||||
export const useManualSync = (user: Models.User<Models.Preferences> | null) => {
|
export const useManualSync = (user: User | null) => {
|
||||||
const { syncing, handleManualSync } = useSync();
|
const { syncing, handleManualSync } = useSync();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
import { Transaction } from "@/components/TransactionCard";
|
|
||||||
import {
|
|
||||||
syncTransactionsWithAppwrite,
|
|
||||||
updateTransactionInAppwrite,
|
|
||||||
deleteTransactionFromAppwrite,
|
|
||||||
debouncedDeleteTransaction,
|
|
||||||
} from "@/utils/appwriteTransactionUtils";
|
|
||||||
import { toast } from "@/hooks/useToast.wrapper";
|
|
||||||
import { isSyncEnabled } from "@/utils/syncUtils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 트랜잭션 관리 훅
|
|
||||||
* 트랜잭션 동기화, 추가, 수정, 삭제 기능 제공
|
|
||||||
*/
|
|
||||||
export const useAppwriteTransactions = (
|
|
||||||
user: any,
|
|
||||||
localTransactions: Transaction[]
|
|
||||||
) => {
|
|
||||||
// 트랜잭션 상태 관리
|
|
||||||
const [transactions, setTransactions] =
|
|
||||||
useState<Transaction[]>(localTransactions);
|
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
|
||||||
const [error, setError] = useState<Error | null>(null);
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 상태 추적
|
|
||||||
const isMountedRef = useRef<boolean>(true);
|
|
||||||
|
|
||||||
// 진행 중인 작업 추적
|
|
||||||
const pendingOperations = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// 트랜잭션 동기화
|
|
||||||
const syncTransactions = useCallback(async () => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return localTransactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// UI 스레드 차단 방지
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
||||||
|
|
||||||
const syncedTransactions = await syncTransactionsWithAppwrite(
|
|
||||||
user,
|
|
||||||
localTransactions
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setTransactions(syncedTransactions);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return syncedTransactions;
|
|
||||||
} catch (err) {
|
|
||||||
appwriteLogger.error("트랜잭션 동기화 오류:", err);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
setError(err as Error);
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return localTransactions;
|
|
||||||
}
|
|
||||||
}, [user, localTransactions]);
|
|
||||||
|
|
||||||
// 트랜잭션 추가/수정
|
|
||||||
const saveTransaction = useCallback(
|
|
||||||
async (transaction: Transaction) => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 작업 추적 시작
|
|
||||||
pendingOperations.current.add(transaction.id);
|
|
||||||
|
|
||||||
// UI 스레드 차단 방지
|
|
||||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
||||||
|
|
||||||
await updateTransactionInAppwrite(user, transaction);
|
|
||||||
|
|
||||||
if (!isMountedRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로컬 상태 업데이트
|
|
||||||
setTransactions((prev) => {
|
|
||||||
const index = prev.findIndex((t) => t.id === transaction.id);
|
|
||||||
if (index >= 0) {
|
|
||||||
const updated = [...prev];
|
|
||||||
updated[index] = transaction;
|
|
||||||
return updated;
|
|
||||||
} else {
|
|
||||||
return [...prev, transaction];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
appwriteLogger.error("트랜잭션 저장 오류:", err);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
toast({
|
|
||||||
title: "저장 실패",
|
|
||||||
description: "트랜잭션을 저장하는 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// 작업 추적 종료
|
|
||||||
pendingOperations.current.delete(transaction.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[user]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 트랜잭션 삭제
|
|
||||||
const removeTransaction = useCallback(
|
|
||||||
async (transactionId: string) => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 작업 추적 시작
|
|
||||||
pendingOperations.current.add(transactionId);
|
|
||||||
|
|
||||||
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
|
|
||||||
setTransactions((prev) => prev.filter((t) => t.id !== transactionId));
|
|
||||||
|
|
||||||
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
|
|
||||||
await debouncedDeleteTransaction(user, transactionId);
|
|
||||||
} catch (err) {
|
|
||||||
appwriteLogger.error("트랜잭션 삭제 오류:", err);
|
|
||||||
|
|
||||||
if (isMountedRef.current) {
|
|
||||||
toast({
|
|
||||||
title: "삭제 실패",
|
|
||||||
description: "트랜잭션을 삭제하는 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
|
|
||||||
syncTransactions();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
// 작업 추적 종료
|
|
||||||
pendingOperations.current.delete(transactionId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[user, syncTransactions]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 초기 동기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (user && isSyncEnabled()) {
|
|
||||||
syncTransactions();
|
|
||||||
} else {
|
|
||||||
setTransactions(localTransactions);
|
|
||||||
}
|
|
||||||
}, [user, localTransactions, syncTransactions]);
|
|
||||||
|
|
||||||
// 컴포넌트 언마운트 시 정리
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
isMountedRef.current = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
transactions,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
syncTransactions,
|
|
||||||
saveTransaction,
|
|
||||||
removeTransaction,
|
|
||||||
hasPendingOperations: pendingOperations.current.size > 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useAppwriteTransactions;
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { logger } from "@/utils/logger";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useToast } from "@/hooks/useToast.wrapper";
|
|
||||||
import { useAuth } from "@/stores";
|
|
||||||
import { useTableSetup } from "@/hooks/useTableSetup";
|
|
||||||
import { setUser, trackEvent } from "@/lib/sentry";
|
|
||||||
|
|
||||||
export function useLogin() {
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [password, setPassword] = useState("");
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [loginError, setLoginError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { toast } = useToast();
|
|
||||||
const { signIn } = useAuth();
|
|
||||||
const { isSettingUpTables, setupTables } = useTableSetup();
|
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoginError(null);
|
|
||||||
|
|
||||||
if (!email || !password) {
|
|
||||||
toast({
|
|
||||||
title: "입력 오류",
|
|
||||||
description: "이메일과 비밀번호를 모두 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { error, user } = await signIn(email, password);
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
logger.error("로그인 실패:", error);
|
|
||||||
|
|
||||||
let errorMessage = "로그인에 실패했습니다.";
|
|
||||||
|
|
||||||
if (error.message) {
|
|
||||||
if (error.message.includes("Invalid login credentials")) {
|
|
||||||
errorMessage = "이메일 또는 비밀번호가 올바르지 않습니다.";
|
|
||||||
} else if (error.message.includes("Email not confirmed")) {
|
|
||||||
errorMessage =
|
|
||||||
"이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.";
|
|
||||||
} else {
|
|
||||||
errorMessage = `오류: ${error.message}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoginError(errorMessage);
|
|
||||||
|
|
||||||
// 로그인 실패 이벤트 추적
|
|
||||||
trackEvent("login_failed", {
|
|
||||||
error_type: error.message?.includes("Invalid login credentials")
|
|
||||||
? "invalid_credentials"
|
|
||||||
: "other",
|
|
||||||
email_domain: email.split("@")[1] || "unknown",
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "로그인 실패",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} else if (user) {
|
|
||||||
// 로그인 성공
|
|
||||||
toast({
|
|
||||||
title: "로그인 성공",
|
|
||||||
description: "환영합니다! 대시보드로 이동합니다.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sentry에 사용자 정보 설정
|
|
||||||
setUser({
|
|
||||||
id: user.id || "unknown",
|
|
||||||
email: user.email,
|
|
||||||
username: user.user_metadata?.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로그인 성공 이벤트 추적
|
|
||||||
trackEvent("login", {
|
|
||||||
user_id: user.id,
|
|
||||||
email_domain: user.email?.split("@")[1] || "unknown",
|
|
||||||
login_time: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await setupTables();
|
|
||||||
navigate("/");
|
|
||||||
} else {
|
|
||||||
// user가 없지만 error도 없는 경우 (드문 경우)
|
|
||||||
logger.warn("로그인 성공했지만 사용자 정보가 없습니다.");
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "로그인 상태 확인 중",
|
|
||||||
description:
|
|
||||||
"로그인은 성공했지만 사용자 정보를 확인하지 못했습니다. 페이지를 새로고침해보세요.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate("/");
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
logger.error("로그인 과정에서 예외 발생:", err);
|
|
||||||
|
|
||||||
const errorMessage = err.message || "알 수 없는 오류가 발생했습니다.";
|
|
||||||
setLoginError(errorMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "로그인 오류",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
email,
|
|
||||||
setEmail,
|
|
||||||
password,
|
|
||||||
setPassword,
|
|
||||||
showPassword,
|
|
||||||
setShowPassword,
|
|
||||||
isLoading,
|
|
||||||
isSettingUpTables,
|
|
||||||
loginError,
|
|
||||||
setLoginError,
|
|
||||||
handleLogin,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
/**
|
|
||||||
* Appwrite 클라이언트 설정
|
|
||||||
*
|
|
||||||
* 이 파일은 Appwrite 서비스와의 연결을 설정하고 필요한 서비스 인스턴스를 생성합니다.
|
|
||||||
* 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Client, Account, Databases, Storage, Avatars } from "appwrite";
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
import { config, validateConfig } from "./config";
|
|
||||||
|
|
||||||
// 서비스 타입 정의
|
|
||||||
export interface AppwriteServices {
|
|
||||||
client: Client;
|
|
||||||
account: Account;
|
|
||||||
databases: Databases;
|
|
||||||
storage: Storage;
|
|
||||||
avatars: Avatars;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 클라이언트 초기화 상태 추적
|
|
||||||
let isInitialized = false;
|
|
||||||
let initializationError: Error | null = null;
|
|
||||||
|
|
||||||
// Appwrite 클라이언트 초기화
|
|
||||||
let appwriteClient: Client;
|
|
||||||
let accountService: Account;
|
|
||||||
let databasesService: Databases;
|
|
||||||
let storageService: Storage;
|
|
||||||
let avatarsService: Avatars;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 클라이언트 초기화 함수
|
|
||||||
* UI 스레드를 차단하지 않도록 비동기적으로 초기화합니다.
|
|
||||||
*/
|
|
||||||
const initializeAppwriteClient = () => {
|
|
||||||
try {
|
|
||||||
// 설정 유효성 검증
|
|
||||||
validateConfig();
|
|
||||||
|
|
||||||
appwriteLogger.info(`Appwrite 클라이언트 생성 중: ${config.endpoint}`);
|
|
||||||
appwriteLogger.info(`프로젝트 ID: ${config.projectId}`);
|
|
||||||
|
|
||||||
// Appwrite 클라이언트 생성
|
|
||||||
appwriteClient = new Client();
|
|
||||||
|
|
||||||
appwriteClient.setEndpoint(config.endpoint).setProject(config.projectId);
|
|
||||||
|
|
||||||
// API 키가 있는 경우 설정
|
|
||||||
if (config.apiKey) {
|
|
||||||
appwriteLogger.info("API 키 설정 중...");
|
|
||||||
// 최신 Appwrite SDK에서는 JWT 토큰을 사용하거나 세션 기반 인증을 사용합니다.
|
|
||||||
// 서버에서는 API 키를 사용하지만 클라이언트에서는 사용하지 않습니다.
|
|
||||||
// 클라이언트에서 API 키를 사용하는 것은 보안 위험이 있어 권장되지 않습니다.
|
|
||||||
appwriteLogger.info(
|
|
||||||
"API 키가 설정되었지만 클라이언트에서는 사용하지 않습니다."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
appwriteLogger.warn(
|
|
||||||
"API 키가 설정되지 않았습니다. 일부 기능이 제한될 수 있습니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서비스 초기화
|
|
||||||
accountService = new Account(appwriteClient);
|
|
||||||
databasesService = new Databases(appwriteClient);
|
|
||||||
storageService = new Storage(appwriteClient);
|
|
||||||
avatarsService = new Avatars(appwriteClient);
|
|
||||||
|
|
||||||
isInitialized = true;
|
|
||||||
appwriteLogger.info("Appwrite 클라이언트가 성공적으로 생성되었습니다.");
|
|
||||||
|
|
||||||
// 세션 확인 (선택적)
|
|
||||||
queueMicrotask(async () => {
|
|
||||||
try {
|
|
||||||
await accountService.get();
|
|
||||||
appwriteLogger.info("Appwrite 세션 확인 성공");
|
|
||||||
} catch (sessionError) {
|
|
||||||
// 로그인되지 않은 상태는 정상적인 경우이므로 오류로 처리하지 않음
|
|
||||||
appwriteLogger.info("Appwrite 세션 없음 (정상)");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 클라이언트 생성 오류:", error);
|
|
||||||
initializationError = error as Error;
|
|
||||||
|
|
||||||
// 더미 클라이언트 생성 (앱이 완전히 실패하지 않도록)
|
|
||||||
appwriteClient = new Client();
|
|
||||||
accountService = new Account(appwriteClient);
|
|
||||||
databasesService = new Databases(appwriteClient);
|
|
||||||
storageService = new Storage(appwriteClient);
|
|
||||||
avatarsService = new Avatars(appwriteClient);
|
|
||||||
|
|
||||||
// 사용자에게 오류 알림 (개발 모드에서만)
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
queueMicrotask(() => {
|
|
||||||
appwriteLogger.warn(
|
|
||||||
"Appwrite 서버 연결에 실패했습니다. 환경 설정을 확인해주세요."
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 클라이언트 초기화 실행
|
|
||||||
initializeAppwriteClient();
|
|
||||||
|
|
||||||
// 서비스 내보내기
|
|
||||||
export const client = appwriteClient;
|
|
||||||
export const account = accountService;
|
|
||||||
export const databases = databasesService;
|
|
||||||
export const storage = storageService;
|
|
||||||
export const avatars = avatarsService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기화 상태 확인
|
|
||||||
* @returns 초기화 상태
|
|
||||||
*/
|
|
||||||
export const getInitializationStatus = () => {
|
|
||||||
return {
|
|
||||||
isInitialized,
|
|
||||||
error: initializationError,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 클라이언트 재초기화 시도
|
|
||||||
* 오류 발생 시 재시도하기 위한 함수
|
|
||||||
*/
|
|
||||||
export const reinitializeAppwriteClient = () => {
|
|
||||||
appwriteLogger.info("Appwrite 클라이언트 재초기화 시도");
|
|
||||||
isInitialized = false;
|
|
||||||
initializationError = null;
|
|
||||||
initializeAppwriteClient();
|
|
||||||
return getInitializationStatus();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 연결 상태 확인
|
|
||||||
export const isValidConnection = async (): Promise<boolean> => {
|
|
||||||
if (!isInitialized) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 계정 서비스를 통해 현재 세션 상태 확인 (간단한 API 호출)
|
|
||||||
await account.get();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
// 401 오류는 로그인되지 않은 상태로 정상적인 경우
|
|
||||||
if (error && (error as any).code === 401) {
|
|
||||||
return true; // 서버 연결은 정상이지만 로그인되지 않은 상태
|
|
||||||
}
|
|
||||||
|
|
||||||
appwriteLogger.error("Appwrite 연결 확인 오류:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
/**
|
|
||||||
* Appwrite 설정
|
|
||||||
*
|
|
||||||
* 이 파일은 Appwrite 서비스에 필요한 모든 설정 값을 정의합니다.
|
|
||||||
* 환경 변수에서 값을 가져오며, 기본값을 제공합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Appwrite 설정 타입 정의
|
|
||||||
export interface AppwriteConfig {
|
|
||||||
endpoint: string;
|
|
||||||
projectId: string;
|
|
||||||
databaseId: string;
|
|
||||||
transactionsCollectionId: string;
|
|
||||||
apiKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 환경 변수에서 설정 값 가져오기
|
|
||||||
const endpoint =
|
|
||||||
import.meta.env.VITE_APPWRITE_ENDPOINT || "https://a11.ism.kr/v1";
|
|
||||||
const projectId =
|
|
||||||
import.meta.env.VITE_APPWRITE_PROJECT_ID || "68182a300039f6d700a6";
|
|
||||||
const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || "default";
|
|
||||||
const transactionsCollectionId =
|
|
||||||
import.meta.env.VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID || "transactions";
|
|
||||||
// API 키는 보안상 클라이언트에서 제거됨
|
|
||||||
// 서버 사이드 함수나 백엔드에서만 사용해야 함
|
|
||||||
const apiKey = "";
|
|
||||||
|
|
||||||
// 개발 모드에서 설정 값 로깅 (임시 주석처리 - Appwrite 제거 예정)
|
|
||||||
// appwriteLogger.info("현재 Appwrite 설정:", {
|
|
||||||
// endpoint,
|
|
||||||
// projectId,
|
|
||||||
// databaseId,
|
|
||||||
// transactionsCollectionId,
|
|
||||||
// apiKey: apiKey ? "설정됨" : "설정되지 않음", // API 키는 안전을 위해 완전한 값을 로깅하지 않음
|
|
||||||
// });
|
|
||||||
|
|
||||||
// 설정 객체 생성
|
|
||||||
export const config: AppwriteConfig = {
|
|
||||||
endpoint,
|
|
||||||
projectId,
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
apiKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Getter functions for config values
|
|
||||||
export const getAppwriteEndpoint = (): string => endpoint;
|
|
||||||
export const getAppwriteProjectId = (): string => projectId;
|
|
||||||
export const getAppwriteDatabaseId = (): string => databaseId;
|
|
||||||
export const getAppwriteTransactionsCollectionId = (): string =>
|
|
||||||
transactionsCollectionId;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 서버 연결 유효성 검사
|
|
||||||
* @returns 유효한 설정인지 여부
|
|
||||||
*/
|
|
||||||
export const isValidAppwriteConfig = (): boolean => {
|
|
||||||
return Boolean(endpoint && projectId);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 설정 값 검증 및 오류 발생
|
|
||||||
* @throws 필수 설정이 없는 경우 오류 발생
|
|
||||||
*/
|
|
||||||
export const validateConfig = (): void => {
|
|
||||||
if (!endpoint) {
|
|
||||||
throw new Error("VITE_APPWRITE_ENDPOINT is not set");
|
|
||||||
}
|
|
||||||
if (!projectId) {
|
|
||||||
throw new Error("VITE_APPWRITE_PROJECT_ID is not set");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
/**
|
|
||||||
* Appwrite 기본 사용자 정보
|
|
||||||
*
|
|
||||||
* 이 파일은 Appwrite 서비스에 연결할 때 사용할 기본 사용자 정보를 제공합니다.
|
|
||||||
* 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 기본 사용자 ID
|
|
||||||
export const DEFAULT_USER_ID = "68183aa4002a6f19542b";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 사용자 정보를 가져오는 함수
|
|
||||||
*
|
|
||||||
* @returns 기본 사용자 ID
|
|
||||||
*/
|
|
||||||
export const getDefaultUserId = (): string => {
|
|
||||||
return DEFAULT_USER_ID;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 ID가 기본 사용자인지 확인하는 함수
|
|
||||||
*
|
|
||||||
* @param userId 확인할 사용자 ID
|
|
||||||
* @returns 기본 사용자 여부
|
|
||||||
*/
|
|
||||||
export const isDefaultUser = (userId: string): boolean => {
|
|
||||||
return userId === DEFAULT_USER_ID;
|
|
||||||
};
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import {
|
|
||||||
client,
|
|
||||||
account,
|
|
||||||
databases,
|
|
||||||
storage,
|
|
||||||
avatars,
|
|
||||||
isValidConnection,
|
|
||||||
} from "./client";
|
|
||||||
import {
|
|
||||||
getAppwriteEndpoint,
|
|
||||||
getAppwriteProjectId,
|
|
||||||
getAppwriteDatabaseId,
|
|
||||||
getAppwriteTransactionsCollectionId,
|
|
||||||
isValidAppwriteConfig,
|
|
||||||
} from "./config";
|
|
||||||
import { setupAppwriteDatabase } from "./setup";
|
|
||||||
|
|
||||||
export {
|
|
||||||
// 클라이언트 및 서비스
|
|
||||||
client,
|
|
||||||
account,
|
|
||||||
databases,
|
|
||||||
storage,
|
|
||||||
avatars,
|
|
||||||
|
|
||||||
// 설정 및 유틸리티
|
|
||||||
getAppwriteEndpoint,
|
|
||||||
getAppwriteProjectId,
|
|
||||||
getAppwriteDatabaseId,
|
|
||||||
getAppwriteTransactionsCollectionId,
|
|
||||||
isValidAppwriteConfig,
|
|
||||||
isValidConnection,
|
|
||||||
|
|
||||||
// 데이터베이스 설정
|
|
||||||
setupAppwriteDatabase,
|
|
||||||
};
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import { ID, Query, Permission, Role, Models } from "appwrite";
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
import {
|
|
||||||
databases,
|
|
||||||
account,
|
|
||||||
getInitializationStatus,
|
|
||||||
reinitializeAppwriteClient,
|
|
||||||
} from "./client";
|
|
||||||
import { config } from "./config";
|
|
||||||
import type { ApiError } from "@/types/common";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 데이터베이스 및 컬렉션 설정
|
|
||||||
* 필요한 데이터베이스와 컬렉션이 없으면 생성합니다.
|
|
||||||
*/
|
|
||||||
export const setupAppwriteDatabase = async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const databaseId = config.databaseId;
|
|
||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
|
||||||
|
|
||||||
// 현재 사용자 정보 가져오기
|
|
||||||
const user = await account.get();
|
|
||||||
|
|
||||||
// 1. 데이터베이스 존재 확인 또는 생성
|
|
||||||
let database: Models.Database;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 기존 데이터베이스 가져오기 시도
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
database = await databases.getDatabase(databaseId);
|
|
||||||
appwriteLogger.info("기존 데이터베이스를 찾았습니다:", database.name);
|
|
||||||
} catch (error) {
|
|
||||||
// 데이터베이스가 없으면 생성
|
|
||||||
appwriteLogger.info("데이터베이스를 생성합니다...");
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
database = await databases.createDatabase(databaseId, "Zellyy Finance");
|
|
||||||
appwriteLogger.info("데이터베이스가 생성되었습니다:", database.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 트랜잭션 컬렉션 존재 확인 또는 생성
|
|
||||||
let collection: Models.Collection;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 기존 컬렉션 가져오기 시도
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
collection = await databases.getCollection(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId
|
|
||||||
);
|
|
||||||
appwriteLogger.info(
|
|
||||||
"기존 트랜잭션 컬렉션을 찾았습니다:",
|
|
||||||
collection.name
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
// 컬렉션이 없으면 생성
|
|
||||||
appwriteLogger.info("트랜잭션 컬렉션을 생성합니다...");
|
|
||||||
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
collection = await databases.createCollection(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
{
|
|
||||||
name: "거래 내역",
|
|
||||||
permissions: [
|
|
||||||
// 사용자만 자신의 데이터에 접근 가능하도록 설정
|
|
||||||
Permission.read(Role.user(user.$id)),
|
|
||||||
Permission.update(Role.user(user.$id)),
|
|
||||||
Permission.delete(Role.user(user.$id)),
|
|
||||||
Permission.create(Role.user(user.$id)),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 컬렉션이 생성되었습니다:", collection.name);
|
|
||||||
|
|
||||||
// 3. 필요한 속성(필드) 생성
|
|
||||||
await Promise.all([
|
|
||||||
// 사용자 ID 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"user_id",
|
|
||||||
{
|
|
||||||
size: 255,
|
|
||||||
required: true,
|
|
||||||
default: user.$id,
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 트랜잭션 ID 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"transaction_id",
|
|
||||||
{
|
|
||||||
size: 255,
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 제목 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"title",
|
|
||||||
{
|
|
||||||
size: 255,
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 금액 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createFloatAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"amount",
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
default: 0,
|
|
||||||
min: null,
|
|
||||||
max: null,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 날짜 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"date",
|
|
||||||
{
|
|
||||||
size: 255,
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 카테고리 필드
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"category",
|
|
||||||
{
|
|
||||||
size: 255,
|
|
||||||
required: false,
|
|
||||||
default: null,
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
|
|
||||||
// 유형 필드 (수입/지출)
|
|
||||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
|
||||||
databases.createStringAttribute(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
"type",
|
|
||||||
{
|
|
||||||
size: 50,
|
|
||||||
required: true,
|
|
||||||
default: "expense",
|
|
||||||
array: false,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 컬렉션 속성이 생성되었습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 데이터베이스 설정 오류:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 초기화 함수
|
|
||||||
*/
|
|
||||||
export const initializeAppwrite = () => {
|
|
||||||
return getInitializationStatus();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 세션 생성 (로그인)
|
|
||||||
*/
|
|
||||||
export const createSession = async (email: string, password: string) => {
|
|
||||||
try {
|
|
||||||
const session = await account.createEmailPasswordSession(email, password);
|
|
||||||
return { session, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("세션 생성 실패:", error);
|
|
||||||
return {
|
|
||||||
session: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "로그인에 실패했습니다.",
|
|
||||||
code: error.code || "AUTH_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 계정 생성 (회원가입)
|
|
||||||
*/
|
|
||||||
export const createAccount = async (
|
|
||||||
email: string,
|
|
||||||
password: string,
|
|
||||||
username: string
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const user = await account.create(ID.unique(), email, password, username);
|
|
||||||
return { user, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("계정 생성 실패:", error);
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "회원가입에 실패했습니다.",
|
|
||||||
code: error.code || "SIGNUP_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 세션 삭제 (로그아웃)
|
|
||||||
*/
|
|
||||||
export const deleteCurrentSession = async () => {
|
|
||||||
try {
|
|
||||||
await account.deleteSession("current");
|
|
||||||
appwriteLogger.info("로그아웃 완료");
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("로그아웃 실패:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 사용자 정보 가져오기
|
|
||||||
*/
|
|
||||||
export const getCurrentUser = async () => {
|
|
||||||
try {
|
|
||||||
const user = await account.get();
|
|
||||||
const session = await account.getSession("current");
|
|
||||||
return { user, session, error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.debug("사용자 정보 가져오기 실패:", error);
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
session: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "사용자 정보를 가져올 수 없습니다.",
|
|
||||||
code: error.code || "USER_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비밀번호 재설정 이메일 발송
|
|
||||||
*/
|
|
||||||
export const sendPasswordRecoveryEmail = async (email: string) => {
|
|
||||||
try {
|
|
||||||
await account.createRecovery(
|
|
||||||
email,
|
|
||||||
window.location.origin + "/reset-password"
|
|
||||||
);
|
|
||||||
return { error: null };
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("비밀번호 재설정 이메일 발송 실패:", error);
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
message: error.message || "비밀번호 재설정 이메일 발송에 실패했습니다.",
|
|
||||||
code: error.code || "RECOVERY_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자의 모든 트랜잭션 조회
|
|
||||||
*/
|
|
||||||
export const getAllTransactions = async (userId: string) => {
|
|
||||||
try {
|
|
||||||
const databaseId = config.databaseId;
|
|
||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 목록 조회 시작", { userId });
|
|
||||||
|
|
||||||
const response = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
[
|
|
||||||
Query.equal("user_id", userId),
|
|
||||||
Query.orderDesc("$createdAt"),
|
|
||||||
Query.limit(1000), // 최대 1000개
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Appwrite 문서를 Transaction 타입으로 변환
|
|
||||||
const transactions = response.documents.map((doc: any) => ({
|
|
||||||
id: doc.transaction_id || doc.$id,
|
|
||||||
title: doc.title || "",
|
|
||||||
amount: Number(doc.amount) || 0,
|
|
||||||
category: doc.category || "",
|
|
||||||
type: doc.type || "expense",
|
|
||||||
paymentMethod: doc.payment_method || "신용카드",
|
|
||||||
date: doc.date || doc.$createdAt,
|
|
||||||
localTimestamp: doc.local_timestamp || doc.$updatedAt,
|
|
||||||
userId: doc.user_id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 목록 조회 성공", {
|
|
||||||
count: transactions.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
transactions,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("트랜잭션 목록 조회 실패:", error);
|
|
||||||
return {
|
|
||||||
transactions: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "트랜잭션 목록을 불러올 수 없습니다.",
|
|
||||||
code: error.code || "FETCH_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 새 트랜잭션 저장
|
|
||||||
*/
|
|
||||||
export const saveTransaction = async (transactionData: any) => {
|
|
||||||
try {
|
|
||||||
const databaseId = config.databaseId;
|
|
||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 저장 시작", {
|
|
||||||
amount: transactionData.amount,
|
|
||||||
type: transactionData.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
const documentData = {
|
|
||||||
user_id: transactionData.userId,
|
|
||||||
transaction_id: transactionData.id || ID.unique(),
|
|
||||||
title: transactionData.title || "",
|
|
||||||
amount: transactionData.amount,
|
|
||||||
category: transactionData.category,
|
|
||||||
type: transactionData.type,
|
|
||||||
payment_method: transactionData.paymentMethod,
|
|
||||||
date: transactionData.date,
|
|
||||||
local_timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await databases.createDocument(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
ID.unique(),
|
|
||||||
documentData
|
|
||||||
);
|
|
||||||
|
|
||||||
// 생성된 트랜잭션을 Transaction 타입으로 변환
|
|
||||||
const transaction = {
|
|
||||||
id: response.transaction_id,
|
|
||||||
title: response.title,
|
|
||||||
amount: Number(response.amount),
|
|
||||||
category: response.category,
|
|
||||||
type: response.type,
|
|
||||||
paymentMethod: response.payment_method,
|
|
||||||
date: response.date,
|
|
||||||
localTimestamp: response.local_timestamp,
|
|
||||||
userId: response.user_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 저장 성공", {
|
|
||||||
id: transaction.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
transaction,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("트랜잭션 저장 실패:", error);
|
|
||||||
return {
|
|
||||||
transaction: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "트랜잭션 저장에 실패했습니다.",
|
|
||||||
code: error.code || "SAVE_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기존 트랜잭션 업데이트
|
|
||||||
*/
|
|
||||||
export const updateExistingTransaction = async (transactionData: any) => {
|
|
||||||
try {
|
|
||||||
const databaseId = config.databaseId;
|
|
||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 업데이트 시작", {
|
|
||||||
id: transactionData.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 먼저 해당 트랜잭션 문서 찾기
|
|
||||||
const existingResponse = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
[Query.equal("transaction_id", transactionData.id), Query.limit(1)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingResponse.documents.length === 0) {
|
|
||||||
throw new Error("업데이트할 트랜잭션을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentId = existingResponse.documents[0].$id;
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
title: transactionData.title || "",
|
|
||||||
amount: transactionData.amount,
|
|
||||||
category: transactionData.category,
|
|
||||||
type: transactionData.type,
|
|
||||||
payment_method: transactionData.paymentMethod,
|
|
||||||
date: transactionData.date,
|
|
||||||
local_timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await databases.updateDocument(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
documentId,
|
|
||||||
updateData
|
|
||||||
);
|
|
||||||
|
|
||||||
// 업데이트된 트랜잭션을 Transaction 타입으로 변환
|
|
||||||
const transaction = {
|
|
||||||
id: response.transaction_id,
|
|
||||||
title: response.title,
|
|
||||||
amount: Number(response.amount),
|
|
||||||
category: response.category,
|
|
||||||
type: response.type,
|
|
||||||
paymentMethod: response.payment_method,
|
|
||||||
date: response.date,
|
|
||||||
localTimestamp: response.local_timestamp,
|
|
||||||
userId: response.user_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 업데이트 성공", {
|
|
||||||
id: transaction.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
transaction,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("트랜잭션 업데이트 실패:", error);
|
|
||||||
return {
|
|
||||||
transaction: null,
|
|
||||||
error: {
|
|
||||||
message: error.message || "트랜잭션 업데이트에 실패했습니다.",
|
|
||||||
code: error.code || "UPDATE_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랜잭션 삭제
|
|
||||||
*/
|
|
||||||
export const deleteTransactionById = async (transactionId: string) => {
|
|
||||||
try {
|
|
||||||
const databaseId = config.databaseId;
|
|
||||||
const transactionsCollectionId = config.transactionsCollectionId;
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 삭제 시작", { id: transactionId });
|
|
||||||
|
|
||||||
// 먼저 해당 트랜잭션 문서 찾기
|
|
||||||
const existingResponse = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
[Query.equal("transaction_id", transactionId), Query.limit(1)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingResponse.documents.length === 0) {
|
|
||||||
throw new Error("삭제할 트랜잭션을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentId = existingResponse.documents[0].$id;
|
|
||||||
|
|
||||||
await databases.deleteDocument(
|
|
||||||
databaseId,
|
|
||||||
transactionsCollectionId,
|
|
||||||
documentId
|
|
||||||
);
|
|
||||||
|
|
||||||
appwriteLogger.info("트랜잭션 삭제 성공", { id: transactionId });
|
|
||||||
|
|
||||||
return {
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
appwriteLogger.error("트랜잭션 삭제 실패:", error);
|
|
||||||
return {
|
|
||||||
error: {
|
|
||||||
message: error.message || "트랜잭션 삭제에 실패했습니다.",
|
|
||||||
code: error.code || "DELETE_ERROR",
|
|
||||||
} as ApiError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
20
src/lib/clerk/utils.ts
Normal file
20
src/lib/clerk/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Clerk 유틸리티 함수들
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Clerk 설정 키 확인
|
||||||
|
const CLERK_PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clerk 사용 가능 여부 확인 유틸리티
|
||||||
|
*/
|
||||||
|
export const isClerkEnabled = (): boolean => {
|
||||||
|
return !!CLERK_PUBLISHABLE_KEY;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clerk Publishable Key 가져오기
|
||||||
|
*/
|
||||||
|
export const getClerkPublishableKey = (): string | undefined => {
|
||||||
|
return CLERK_PUBLISHABLE_KEY;
|
||||||
|
};
|
||||||
@@ -265,7 +265,7 @@ export const offlineStrategies = {
|
|||||||
restoreFromOfflineCache: () => {
|
restoreFromOfflineCache: () => {
|
||||||
try {
|
try {
|
||||||
const offlineData = localStorage.getItem("offline-cache");
|
const offlineData = localStorage.getItem("offline-cache");
|
||||||
if (!offlineData) return;
|
if (!offlineData) {return;}
|
||||||
|
|
||||||
const parsedData = JSON.parse(offlineData);
|
const parsedData = JSON.parse(offlineData);
|
||||||
let restoredCount = 0;
|
let restoredCount = 0;
|
||||||
@@ -301,7 +301,7 @@ export const autoCacheManagement = {
|
|||||||
/**
|
/**
|
||||||
* 주기적 캐시 정리 시작
|
* 주기적 캐시 정리 시작
|
||||||
*/
|
*/
|
||||||
startPeriodicCleanup: (intervalMinutes: number = 30) => {
|
startPeriodicCleanup: (intervalMinutes = 30) => {
|
||||||
const interval = setInterval(
|
const interval = setInterval(
|
||||||
() => {
|
() => {
|
||||||
cacheOptimization.cleanStaleCache();
|
cacheOptimization.cleanStaleCache();
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ export const clearUser = () => {
|
|||||||
|
|
||||||
// Core Web Vitals 측정 및 전송
|
// Core Web Vitals 측정 및 전송
|
||||||
export const initWebVitals = () => {
|
export const initWebVitals = () => {
|
||||||
if (!SENTRY_DSN) return;
|
if (!SENTRY_DSN) {return;}
|
||||||
|
|
||||||
// Core Web Vitals 측정
|
// Core Web Vitals 측정
|
||||||
onCLS((metric) => {
|
onCLS((metric) => {
|
||||||
@@ -252,7 +252,7 @@ export const trackEvent = (
|
|||||||
eventName: string,
|
eventName: string,
|
||||||
properties?: Record<string, any>
|
properties?: Record<string, any>
|
||||||
) => {
|
) => {
|
||||||
if (!SENTRY_DSN) return;
|
if (!SENTRY_DSN) {return;}
|
||||||
|
|
||||||
Sentry.addBreadcrumb({
|
Sentry.addBreadcrumb({
|
||||||
category: "user-action",
|
category: "user-action",
|
||||||
@@ -279,7 +279,7 @@ export const trackEvent = (
|
|||||||
|
|
||||||
// 페이지 전환 추적
|
// 페이지 전환 추적
|
||||||
export const trackPageView = (pageName: string, url: string) => {
|
export const trackPageView = (pageName: string, url: string) => {
|
||||||
if (!SENTRY_DSN) return;
|
if (!SENTRY_DSN) {return;}
|
||||||
|
|
||||||
Sentry.addBreadcrumb({
|
Sentry.addBreadcrumb({
|
||||||
category: "navigation",
|
category: "navigation",
|
||||||
@@ -295,7 +295,7 @@ export const trackPageView = (pageName: string, url: string) => {
|
|||||||
|
|
||||||
// 성능 메트릭 커스텀 측정
|
// 성능 메트릭 커스텀 측정
|
||||||
export const measurePerformance = (name: string, startTime: number) => {
|
export const measurePerformance = (name: string, startTime: number) => {
|
||||||
if (!SENTRY_DSN) return;
|
if (!SENTRY_DSN) {return;}
|
||||||
|
|
||||||
const duration = performance.now() - startTime;
|
const duration = performance.now() - startTime;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useAuth } from "@clerk/clerk-react";
|
import { useAuth } from "@clerk/clerk-react";
|
||||||
import { getSupabaseClient } from "./client";
|
import { getSupabaseClient } from "./client";
|
||||||
import { Database } from "./types";
|
|
||||||
|
|
||||||
// Clerk와 Supabase 연동을 위한 훅
|
// Clerk와 Supabase 연동을 위한 훅
|
||||||
export function useSupabaseWithClerk() {
|
export function useSupabaseWithClerk() {
|
||||||
@@ -67,7 +66,7 @@ export async function ensureUserProfile(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (createError) throw createError;
|
if (createError) {throw createError;}
|
||||||
return newProfile;
|
return newProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ export async function ensureUserProfile(
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (updateError) throw updateError;
|
if (updateError) {throw updateError;}
|
||||||
return updatedProfile;
|
return updatedProfile;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error ensuring user profile:", error);
|
console.error("Error ensuring user profile:", error);
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export function createRealtimeSubscription<
|
|||||||
>(table: T, callback: (payload: any) => void, filter?: string) {
|
>(table: T, callback: (payload: any) => void, filter?: string) {
|
||||||
const client = getSupabaseClient();
|
const client = getSupabaseClient();
|
||||||
|
|
||||||
let subscription = client.channel(`${table}_changes`).on(
|
const subscription = client.channel(`${table}_changes`).on(
|
||||||
"postgres_changes",
|
"postgres_changes",
|
||||||
{
|
{
|
||||||
event: "*",
|
event: "*",
|
||||||
|
|||||||
@@ -1,279 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import AppwriteConnectionTest from "@/components/auth/AppwriteConnectionTest";
|
|
||||||
// import SupabaseToAppwriteMigration from '@/components/migration/SupabaseToAppwriteMigration';
|
|
||||||
import { useAppwriteAuth } from "@/hooks/auth/useAppwriteAuth";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { toast } from "@/hooks/useToast.wrapper";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appwrite 설정 페이지
|
|
||||||
* - 연결 설정 및 테스트
|
|
||||||
* - 데이터베이스 설정
|
|
||||||
* - Supabase에서 데이터 마이그레이션
|
|
||||||
*/
|
|
||||||
const AppwriteSettingsPage: React.FC = () => {
|
|
||||||
// 인증 상태
|
|
||||||
const { user, login, signup, logout, loading, error } = useAppwriteAuth();
|
|
||||||
|
|
||||||
// 로그인 폼 상태
|
|
||||||
const [loginForm, setLoginForm] = useState({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 회원가입 폼 상태
|
|
||||||
const [signupForm, setSignupForm] = useState({
|
|
||||||
email: "",
|
|
||||||
password: "",
|
|
||||||
name: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로그인 처리
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await login(loginForm);
|
|
||||||
toast({
|
|
||||||
title: "로그인 성공",
|
|
||||||
description: "성공적으로 로그인되었습니다.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("로그인 오류:", error);
|
|
||||||
toast({
|
|
||||||
title: "로그인 실패",
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "알 수 없는 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 회원가입 처리
|
|
||||||
const handleSignup = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
await signup(signupForm);
|
|
||||||
toast({
|
|
||||||
title: "회원가입 성공",
|
|
||||||
description: "성공적으로 가입되었습니다.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("회원가입 오류:", error);
|
|
||||||
toast({
|
|
||||||
title: "회원가입 실패",
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "알 수 없는 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 로그아웃 처리
|
|
||||||
const handleLogout = async () => {
|
|
||||||
try {
|
|
||||||
await logout();
|
|
||||||
toast({
|
|
||||||
title: "로그아웃",
|
|
||||||
description: "성공적으로 로그아웃되었습니다.",
|
|
||||||
variant: "default",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("로그아웃 오류:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-6 space-y-8">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold">Appwrite 설정</h1>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
Appwrite 서버 연결 설정 및 데이터 마이그레이션
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 서버 연결 상태 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>서버 연결 상태</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Appwrite 서버 연결 상태를 확인하고 테스트합니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<AppwriteConnectionTest />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 인증 관리 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>인증 관리</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Appwrite 계정에 로그인하거나 새 계정을 생성합니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{user ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="p-4 bg-gray-50 rounded-md">
|
|
||||||
<h3 className="text-sm font-medium">로그인 정보</h3>
|
|
||||||
<p className="text-sm mt-1">
|
|
||||||
사용자 ID: <span className="font-mono">{user.$id}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">이메일: {user.email}</p>
|
|
||||||
{user.name && <p className="text-sm">이름: {user.name}</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleLogout} variant="outline">
|
|
||||||
로그아웃
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Tabs defaultValue="login">
|
|
||||||
<TabsList className="grid w-full grid-cols-2">
|
|
||||||
<TabsTrigger value="login">로그인</TabsTrigger>
|
|
||||||
<TabsTrigger value="signup">회원가입</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="login" className="space-y-4 mt-4">
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="email">이메일</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
placeholder="이메일 주소"
|
|
||||||
value={loginForm.email}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLoginForm({ ...loginForm, email: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="password">비밀번호</Label>
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
placeholder="비밀번호"
|
|
||||||
value={loginForm.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLoginForm({ ...loginForm, password: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
|
||||||
로그인
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="signup" className="space-y-4 mt-4">
|
|
||||||
<form onSubmit={handleSignup} className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="signup-email">이메일</Label>
|
|
||||||
<Input
|
|
||||||
id="signup-email"
|
|
||||||
type="email"
|
|
||||||
placeholder="이메일 주소"
|
|
||||||
value={signupForm.email}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSignupForm({ ...signupForm, email: e.target.value })
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="signup-password">비밀번호</Label>
|
|
||||||
<Input
|
|
||||||
id="signup-password"
|
|
||||||
type="password"
|
|
||||||
placeholder="비밀번호"
|
|
||||||
value={signupForm.password}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSignupForm({
|
|
||||||
...signupForm,
|
|
||||||
password: e.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">이름 (선택사항)</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="이름"
|
|
||||||
value={signupForm.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSignupForm({ ...signupForm, name: e.target.value })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
|
||||||
회원가입
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mt-4 p-3 bg-red-50 rounded-md text-sm text-red-600">
|
|
||||||
{error.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 데이터 마이그레이션 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>데이터 마이그레이션</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Supabase에서 Appwrite로 데이터를 마이그레이션합니다.
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{/* <SupabaseToAppwriteMigration /> */}
|
|
||||||
<p className="text-sm text-gray-500">
|
|
||||||
마이그레이션 컴포넌트를 사용할 수 없습니다.
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AppwriteSettingsPage;
|
|
||||||
@@ -25,10 +25,8 @@ const Index = memo(() => {
|
|||||||
const {
|
const {
|
||||||
loading: authLoading,
|
loading: authLoading,
|
||||||
error: authError,
|
error: authError,
|
||||||
clerkLoaded,
|
|
||||||
} = useAuthStore();
|
} = useAuthStore();
|
||||||
const { isLoaded, isSignedIn } = useAuth();
|
const { isLoaded } = useAuth();
|
||||||
const { user } = useUser();
|
|
||||||
|
|
||||||
// 애플리케이션 상태 관리
|
// 애플리케이션 상태 관리
|
||||||
const [appState, setAppState] = useState<"loading" | "error" | "ready">(
|
const [appState, setAppState] = useState<"loading" | "error" | "ready">(
|
||||||
|
|||||||
@@ -1,68 +1,26 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/stores";
|
import { useAuth } from "@clerk/clerk-react";
|
||||||
import LoginForm from "@/components/auth/LoginForm";
|
import { SignIn } from "@/components/auth/SignIn";
|
||||||
import { useLogin } from "@/hooks/useLogin";
|
|
||||||
|
/**
|
||||||
|
* 로그인 페이지
|
||||||
|
*
|
||||||
|
* Clerk 기반 로그인으로 전환됨
|
||||||
|
*/
|
||||||
const Login = () => {
|
const Login = () => {
|
||||||
const {
|
|
||||||
email,
|
|
||||||
setEmail,
|
|
||||||
password,
|
|
||||||
setPassword,
|
|
||||||
showPassword,
|
|
||||||
setShowPassword,
|
|
||||||
isLoading,
|
|
||||||
isSettingUpTables,
|
|
||||||
loginError,
|
|
||||||
setLoginError: _setLoginError,
|
|
||||||
handleLogin,
|
|
||||||
} = useLogin();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
|
||||||
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (isSignedIn) {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [isSignedIn, navigate]);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-6 bg-neuro-background">
|
<div className="min-h-screen flex flex-col items-center justify-center p-6 bg-neuro-background">
|
||||||
<div className="w-full max-w-md">
|
<SignIn />
|
||||||
<div className="text-center mb-8">
|
|
||||||
<h1 className="text-3xl font-bold text-neuro-income mb-2">
|
|
||||||
젤리의 적자탈출
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
계정에 로그인하여 예산 관리를 시작하세요
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<LoginForm
|
|
||||||
email={email}
|
|
||||||
setEmail={setEmail}
|
|
||||||
password={password}
|
|
||||||
setPassword={setPassword}
|
|
||||||
showPassword={showPassword}
|
|
||||||
setShowPassword={setShowPassword}
|
|
||||||
isLoading={isLoading}
|
|
||||||
isSettingUpTables={isSettingUpTables}
|
|
||||||
loginError={loginError}
|
|
||||||
handleLogin={handleLogin}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="text-center mb-4">
|
|
||||||
<p className="text-gray-500">
|
|
||||||
계정이 없으신가요?{" "}
|
|
||||||
<Link
|
|
||||||
to="/register"
|
|
||||||
className="text-neuro-income font-medium hover:underline"
|
|
||||||
>
|
|
||||||
회원가입
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,131 +1,27 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "@/stores";
|
import { useAuth } from "@clerk/clerk-react";
|
||||||
import { useToast } from "@/hooks/useToast.wrapper";
|
import { SignUp } from "@/components/auth/SignUp";
|
||||||
import {
|
|
||||||
verifyServerConnection,
|
|
||||||
verifySupabaseConnection,
|
|
||||||
} from "@/utils/auth/networkUtils";
|
|
||||||
|
|
||||||
// 분리된 컴포넌트들 임포트
|
|
||||||
import RegisterHeader from "@/components/auth/RegisterHeader";
|
|
||||||
import RegisterForm from "@/components/auth/RegisterForm";
|
|
||||||
import LoginLink from "@/components/auth/LoginLink";
|
|
||||||
import ServerStatusAlert from "@/components/auth/ServerStatusAlert";
|
|
||||||
import TestConnectionSection from "@/components/auth/TestConnectionSection";
|
|
||||||
import SupabaseConnectionStatus from "@/archive/components/SupabaseConnectionStatus";
|
|
||||||
import RegisterErrorDisplay from "@/components/auth/RegisterErrorDisplay";
|
|
||||||
import { ServerConnectionStatus } from "@/components/auth/types";
|
|
||||||
|
|
||||||
interface TestResults {
|
|
||||||
connected: boolean;
|
|
||||||
message: string;
|
|
||||||
details?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회원가입 페이지
|
||||||
|
*
|
||||||
|
* Clerk 기반 회원가입으로 전환됨
|
||||||
|
*/
|
||||||
const Register = () => {
|
const Register = () => {
|
||||||
const [registerError, setRegisterError] = useState<string | null>(null);
|
|
||||||
const [testResults, setTestResults] = useState<TestResults | null>(null);
|
|
||||||
const [serverStatus, setServerStatus] = useState<ServerConnectionStatus>({
|
|
||||||
checked: false,
|
|
||||||
connected: false,
|
|
||||||
message: "서버 연결 상태를 확인 중입니다...",
|
|
||||||
});
|
|
||||||
|
|
||||||
const { toast } = useToast();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signUp, user } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
|
|
||||||
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
// 이미 로그인된 경우 메인 페이지로 리다이렉트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (isSignedIn) {
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}
|
}
|
||||||
}, [user, navigate]);
|
}, [isSignedIn, navigate]);
|
||||||
|
|
||||||
// 서버 연결 상태 확인 함수
|
|
||||||
const checkServerConnection = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
// 먼저 기본 연결 확인
|
|
||||||
const basicStatus = await verifyServerConnection();
|
|
||||||
|
|
||||||
// 기본 확인에 실패하면 강화된 확인 시도
|
|
||||||
if (!basicStatus.connected) {
|
|
||||||
const enhancedStatus = await verifySupabaseConnection();
|
|
||||||
|
|
||||||
setServerStatus({
|
|
||||||
checked: true,
|
|
||||||
connected: enhancedStatus.connected,
|
|
||||||
message:
|
|
||||||
enhancedStatus.message +
|
|
||||||
(enhancedStatus.details ? ` (${enhancedStatus.details})` : ""),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!enhancedStatus.connected) {
|
|
||||||
toast({
|
|
||||||
title: "서버 연결 문제",
|
|
||||||
description: enhancedStatus.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 기본 확인에 성공한 경우
|
|
||||||
setServerStatus({
|
|
||||||
checked: true,
|
|
||||||
connected: true,
|
|
||||||
message: basicStatus.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "알 수 없는 오류";
|
|
||||||
setServerStatus({
|
|
||||||
checked: true,
|
|
||||||
connected: false,
|
|
||||||
message: `연결 확인 중 오류: ${errorMessage}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "연결 확인 오류",
|
|
||||||
description: errorMessage,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [toast]);
|
|
||||||
|
|
||||||
// 서버 연결 상태 확인
|
|
||||||
useEffect(() => {
|
|
||||||
checkServerConnection();
|
|
||||||
}, [checkServerConnection]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col items-center justify-center p-6 bg-neuro-background">
|
<div className="min-h-screen flex flex-col items-center justify-center p-6 bg-neuro-background">
|
||||||
<div className="w-full max-w-md">
|
<SignUp />
|
||||||
<RegisterHeader />
|
|
||||||
|
|
||||||
<ServerStatusAlert
|
|
||||||
serverStatus={serverStatus}
|
|
||||||
checkServerConnection={checkServerConnection}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<RegisterErrorDisplay error={registerError} />
|
|
||||||
|
|
||||||
<RegisterForm
|
|
||||||
signUp={signUp}
|
|
||||||
serverStatus={serverStatus}
|
|
||||||
setServerStatus={setServerStatus}
|
|
||||||
setRegisterError={setRegisterError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<LoginLink />
|
|
||||||
|
|
||||||
<TestConnectionSection
|
|
||||||
setLoginError={setRegisterError}
|
|
||||||
setTestResults={setTestResults}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SupabaseConnectionStatus testResults={testResults} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -146,12 +146,6 @@ const Settings = () => {
|
|||||||
description="보안 및 데이터 설정"
|
description="보안 및 데이터 설정"
|
||||||
onClick={() => navigate("/security-privacy")}
|
onClick={() => navigate("/security-privacy")}
|
||||||
/>
|
/>
|
||||||
<SettingsOption
|
|
||||||
icon={Database}
|
|
||||||
label="Appwrite 설정"
|
|
||||||
description="Appwrite 연결 및 데이터 마이그레이션"
|
|
||||||
onClick={() => navigate("/appwrite-settings")}
|
|
||||||
/>
|
|
||||||
<SettingsOption
|
<SettingsOption
|
||||||
icon={HelpCircle}
|
icon={HelpCircle}
|
||||||
label="도움말 및 지원"
|
label="도움말 및 지원"
|
||||||
|
|||||||
@@ -79,86 +79,42 @@ global.fetch = vi.fn().mockResolvedValue({
|
|||||||
clone: vi.fn(),
|
clone: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
// Appwrite SDK 모킹
|
// Clerk SDK 모킹
|
||||||
vi.mock("appwrite", () => ({
|
vi.mock("@clerk/clerk-react", () => ({
|
||||||
Client: vi.fn().mockImplementation(() => ({
|
useAuth: vi.fn(() => ({
|
||||||
setEndpoint: vi.fn().mockReturnThis(),
|
isLoaded: true,
|
||||||
setProject: vi.fn().mockReturnThis(),
|
isSignedIn: true,
|
||||||
|
getToken: vi.fn().mockResolvedValue("test-token"),
|
||||||
|
userId: "test-user-id",
|
||||||
})),
|
})),
|
||||||
Account: vi.fn().mockImplementation(() => ({
|
useUser: vi.fn(() => ({
|
||||||
get: vi.fn().mockResolvedValue({
|
user: {
|
||||||
$id: "test-user-id",
|
id: "test-user-id",
|
||||||
email: "test@example.com",
|
emailAddresses: [{ emailAddress: "test@example.com" }],
|
||||||
name: "Test User",
|
username: "testuser",
|
||||||
}),
|
firstName: "Test",
|
||||||
createEmailPasswordSession: vi.fn().mockResolvedValue({
|
lastName: "User",
|
||||||
$id: "test-session-id",
|
imageUrl: "https://example.com/avatar.jpg",
|
||||||
userId: "test-user-id",
|
},
|
||||||
}),
|
|
||||||
deleteSession: vi.fn().mockResolvedValue({}),
|
|
||||||
createAccount: vi.fn().mockResolvedValue({
|
|
||||||
$id: "test-user-id",
|
|
||||||
email: "test@example.com",
|
|
||||||
}),
|
|
||||||
createRecovery: vi.fn().mockResolvedValue({}),
|
|
||||||
})),
|
})),
|
||||||
Databases: vi.fn().mockImplementation(() => ({
|
ClerkProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||||
listDocuments: vi.fn().mockResolvedValue({
|
}));
|
||||||
documents: [],
|
|
||||||
total: 0,
|
// Supabase SDK 모킹
|
||||||
}),
|
vi.mock("@supabase/supabase-js", () => ({
|
||||||
createDocument: vi.fn().mockResolvedValue({
|
createClient: vi.fn(() => ({
|
||||||
$id: "test-document-id",
|
from: vi.fn(() => ({
|
||||||
$createdAt: new Date().toISOString(),
|
select: vi.fn().mockReturnThis(),
|
||||||
$updatedAt: new Date().toISOString(),
|
insert: vi.fn().mockReturnThis(),
|
||||||
}),
|
update: vi.fn().mockReturnThis(),
|
||||||
updateDocument: vi.fn().mockResolvedValue({
|
delete: vi.fn().mockReturnThis(),
|
||||||
$id: "test-document-id",
|
eq: vi.fn().mockReturnThis(),
|
||||||
$updatedAt: new Date().toISOString(),
|
single: vi.fn().mockResolvedValue({ data: {}, error: null }),
|
||||||
}),
|
})),
|
||||||
deleteDocument: vi.fn().mockResolvedValue({}),
|
auth: {
|
||||||
getDatabase: vi.fn().mockResolvedValue({
|
getUser: vi.fn().mockResolvedValue({ data: { user: null }, error: null }),
|
||||||
$id: "test-database-id",
|
},
|
||||||
name: "Test Database",
|
|
||||||
}),
|
|
||||||
createDatabase: vi.fn().mockResolvedValue({
|
|
||||||
$id: "test-database-id",
|
|
||||||
name: "Test Database",
|
|
||||||
}),
|
|
||||||
getCollection: vi.fn().mockResolvedValue({
|
|
||||||
$id: "test-collection-id",
|
|
||||||
name: "Test Collection",
|
|
||||||
}),
|
|
||||||
createCollection: vi.fn().mockResolvedValue({
|
|
||||||
$id: "test-collection-id",
|
|
||||||
name: "Test Collection",
|
|
||||||
}),
|
|
||||||
createStringAttribute: vi.fn().mockResolvedValue({}),
|
|
||||||
createFloatAttribute: vi.fn().mockResolvedValue({}),
|
|
||||||
createBooleanAttribute: vi.fn().mockResolvedValue({}),
|
|
||||||
createDatetimeAttribute: vi.fn().mockResolvedValue({}),
|
|
||||||
})),
|
})),
|
||||||
Query: {
|
|
||||||
equal: vi.fn((attribute, value) => `equal("${attribute}", "${value}")`),
|
|
||||||
orderDesc: vi.fn((attribute) => `orderDesc("${attribute}")`),
|
|
||||||
orderAsc: vi.fn((attribute) => `orderAsc("${attribute}")`),
|
|
||||||
limit: vi.fn((limit) => `limit(${limit})`),
|
|
||||||
offset: vi.fn((offset) => `offset(${offset})`),
|
|
||||||
},
|
|
||||||
ID: {
|
|
||||||
unique: vi.fn(() => `test-id-${Date.now()}`),
|
|
||||||
},
|
|
||||||
Permission: {
|
|
||||||
read: vi.fn((role) => `read("${role}")`),
|
|
||||||
write: vi.fn((role) => `write("${role}")`),
|
|
||||||
create: vi.fn((role) => `create("${role}")`),
|
|
||||||
update: vi.fn((role) => `update("${role}")`),
|
|
||||||
delete: vi.fn((role) => `delete("${role}")`),
|
|
||||||
},
|
|
||||||
Role: {
|
|
||||||
user: vi.fn((userId) => `user:${userId}`),
|
|
||||||
any: vi.fn(() => "any"),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// React Router 모킹
|
// React Router 모킹
|
||||||
@@ -198,7 +154,7 @@ vi.mock("@/utils/logger", () => ({
|
|||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
},
|
},
|
||||||
appwriteLogger: {
|
supabaseLogger: {
|
||||||
info: vi.fn(),
|
info: vi.fn(),
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export const useSyncStatus = () => {
|
|||||||
let onlineStatusListener: (() => void) | null = null;
|
let onlineStatusListener: (() => void) | null = null;
|
||||||
|
|
||||||
export const setupOnlineStatusListener = () => {
|
export const setupOnlineStatusListener = () => {
|
||||||
if (onlineStatusListener) return;
|
if (onlineStatusListener) {return;}
|
||||||
|
|
||||||
const updateOnlineStatus = () => {
|
const updateOnlineStatus = () => {
|
||||||
useAppStore.getState().setOnlineStatus(navigator.onLine);
|
useAppStore.getState().setOnlineStatus(navigator.onLine);
|
||||||
|
|||||||
@@ -46,9 +46,4 @@ export type {
|
|||||||
PaymentMethodStats,
|
PaymentMethodStats,
|
||||||
} from "@/contexts/budget/types";
|
} from "@/contexts/budget/types";
|
||||||
|
|
||||||
export type {
|
// Clerk types are now used directly from @clerk/clerk-react
|
||||||
AuthResponse,
|
|
||||||
SignUpResponse,
|
|
||||||
ResetPasswordResponse,
|
|
||||||
AppwriteInitializationStatus,
|
|
||||||
} from "@/contexts/auth/types";
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Appwrite 사용자 연결 테스트 스크립트
|
|
||||||
*
|
|
||||||
* 이 파일은 Appwrite 서비스와의 사용자 연결을 테스트하기 위한 스크립트입니다.
|
|
||||||
* 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Client, Account, ID } from "appwrite";
|
|
||||||
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
// 설정 값 직접 지정
|
|
||||||
const endpoint = "https://a11.ism.kr/v1";
|
|
||||||
const projectId = "68182a300039f6d700a6"; // 프로젝트 ID
|
|
||||||
const userId = "68183aa4002a6f19542b"; // 사용자 ID
|
|
||||||
|
|
||||||
// 테스트 함수
|
|
||||||
async function testAppwriteUserConnection() {
|
|
||||||
appwriteLogger.info("Appwrite 사용자 연결 테스트 시작...");
|
|
||||||
appwriteLogger.info("설정 정보:", {
|
|
||||||
endpoint,
|
|
||||||
projectId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appwrite 클라이언트 생성
|
|
||||||
const client = new Client();
|
|
||||||
|
|
||||||
client.setEndpoint(endpoint).setProject(projectId);
|
|
||||||
|
|
||||||
// 계정 서비스 초기화
|
|
||||||
const account = new Account(client);
|
|
||||||
|
|
||||||
// 이메일/비밀번호 로그인 테스트
|
|
||||||
try {
|
|
||||||
appwriteLogger.info("이메일/비밀번호 로그인 테스트...");
|
|
||||||
// 참고: 실제 로그인 정보는 보안상의 이유로 하드코딩하지 않습니다.
|
|
||||||
// 이 부분은 실제 애플리케이션에서 사용자 입력을 통해 처리해야 합니다.
|
|
||||||
appwriteLogger.info("로그인은 실제 애플리케이션에서 수행해야 합니다.");
|
|
||||||
|
|
||||||
// JWT 세션 테스트 (선택적)
|
|
||||||
try {
|
|
||||||
appwriteLogger.info("JWT 세션 테스트...");
|
|
||||||
// JWT 세션 생성은 서버 측에서 수행해야 하는 작업입니다.
|
|
||||||
appwriteLogger.info("JWT 세션 생성은 서버 측에서 수행해야 합니다.");
|
|
||||||
} catch (jwtError) {
|
|
||||||
appwriteLogger.error("JWT 세션 테스트 실패:", jwtError);
|
|
||||||
}
|
|
||||||
} catch (loginError) {
|
|
||||||
appwriteLogger.error("로그인 테스트 실패:", loginError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 익명 세션 테스트
|
|
||||||
try {
|
|
||||||
appwriteLogger.info("익명 세션 테스트...");
|
|
||||||
const anonymousSession = await account.createAnonymousSession();
|
|
||||||
appwriteLogger.info("익명 세션 생성 성공:", anonymousSession.$id);
|
|
||||||
|
|
||||||
// 세션 삭제
|
|
||||||
try {
|
|
||||||
await account.deleteSession(anonymousSession.$id);
|
|
||||||
appwriteLogger.info("익명 세션 삭제 성공");
|
|
||||||
} catch (deleteError) {
|
|
||||||
appwriteLogger.error("익명 세션 삭제 실패:", deleteError);
|
|
||||||
}
|
|
||||||
} catch (anonymousError) {
|
|
||||||
appwriteLogger.error("익명 세션 테스트 실패:", anonymousError);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 클라이언트 생성 오류:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
appwriteLogger.info("Appwrite 사용자 연결 테스트 완료");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 테스트 실행
|
|
||||||
testAppwriteUserConnection()
|
|
||||||
.then(() => {
|
|
||||||
appwriteLogger.info("테스트가 완료되었습니다.");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
appwriteLogger.error("테스트 중 예외 발생:", error);
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/**
|
|
||||||
* Appwrite 연결 테스트 스크립트
|
|
||||||
*
|
|
||||||
* 이 파일은 Appwrite 서비스와의 연결을 테스트하기 위한 스크립트입니다.
|
|
||||||
* 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Client, Account } from "appwrite";
|
|
||||||
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
// 설정 값 직접 지정
|
|
||||||
const endpoint = "https://a11.ism.kr/v1";
|
|
||||||
const projectId = "68182a300039f6d700a6"; // 올바른 프로젝트 ID
|
|
||||||
const apiKey =
|
|
||||||
"standard_9672cc2d052d4fc56d9d28e75c6476ff1029d932b7d375dbb4bb0f705d741d8e6d9ae154929009e01c7168810884b6ee80e6bb564d3fe6439b8b142ed4a8d287546bb0bed2531c20188a7ecc36e6f9983abb1ab0022c1656cf2219d4c2799655c7baef00ae4861fe74186dbb421141d9e2332f2fad812975ae7b4b7f57527cea";
|
|
||||||
|
|
||||||
// 테스트 함수
|
|
||||||
async function testAppwriteConnection() {
|
|
||||||
appwriteLogger.info("Appwrite 연결 테스트 시작...");
|
|
||||||
appwriteLogger.info("설정 정보:", {
|
|
||||||
endpoint,
|
|
||||||
projectId,
|
|
||||||
apiKey: apiKey ? "설정됨" : "설정되지 않음",
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Appwrite 클라이언트 생성
|
|
||||||
const client = new Client();
|
|
||||||
|
|
||||||
client.setEndpoint(endpoint).setProject(projectId);
|
|
||||||
|
|
||||||
// 계정 서비스 초기화
|
|
||||||
const account = new Account(client);
|
|
||||||
|
|
||||||
// 연결 테스트 (익명 세션 생성 시도)
|
|
||||||
try {
|
|
||||||
appwriteLogger.info("익명 세션 생성 시도...");
|
|
||||||
const session = await account.createAnonymousSession();
|
|
||||||
appwriteLogger.info("익명 세션 생성 성공:", session.$id);
|
|
||||||
|
|
||||||
// 세션 정보 확인
|
|
||||||
try {
|
|
||||||
const user = await account.get();
|
|
||||||
appwriteLogger.info("사용자 정보 확인 성공:", user.$id);
|
|
||||||
} catch (userError) {
|
|
||||||
appwriteLogger.error("사용자 정보 확인 실패:", userError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 세션 삭제
|
|
||||||
try {
|
|
||||||
await account.deleteSession(session.$id);
|
|
||||||
appwriteLogger.info("세션 삭제 성공");
|
|
||||||
} catch (deleteError) {
|
|
||||||
appwriteLogger.error("세션 삭제 실패:", deleteError);
|
|
||||||
}
|
|
||||||
} catch (sessionError) {
|
|
||||||
appwriteLogger.error("익명 세션 생성 실패:", sessionError);
|
|
||||||
|
|
||||||
// 프로젝트 정보 확인 시도
|
|
||||||
try {
|
|
||||||
appwriteLogger.info("프로젝트 정보 확인 시도...");
|
|
||||||
// 프로젝트 정보는 API 키가 있어야 확인 가능
|
|
||||||
if (!apiKey) {
|
|
||||||
appwriteLogger.error(
|
|
||||||
"API 키가 없어 프로젝트 정보를 확인할 수 없습니다."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
appwriteLogger.info(
|
|
||||||
"API 키가 있지만 클라이언트에서는 사용할 수 없습니다."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (projectError) {
|
|
||||||
appwriteLogger.error("프로젝트 정보 확인 실패:", projectError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 클라이언트 생성 오류:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
appwriteLogger.info("Appwrite 연결 테스트 완료");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 테스트 실행
|
|
||||||
testAppwriteConnection()
|
|
||||||
.then(() => {
|
|
||||||
appwriteLogger.info("테스트가 완료되었습니다.");
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
appwriteLogger.error("테스트 중 예외 발생:", error);
|
|
||||||
});
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
import { ID, Query, Models } from "appwrite";
|
|
||||||
import { appwriteLogger } from "@/utils/logger";
|
|
||||||
import { databases, account } from "@/lib/appwrite";
|
|
||||||
import { Transaction } from "@/components/TransactionCard";
|
|
||||||
import { isSyncEnabled } from "@/utils/syncUtils";
|
|
||||||
import { formatISO } from "date-fns";
|
|
||||||
import {
|
|
||||||
getAppwriteDatabaseId,
|
|
||||||
getAppwriteTransactionsCollectionId,
|
|
||||||
} from "@/lib/appwrite/config";
|
|
||||||
import { isValidTransaction, isString, isNumber } from "@/types/guards";
|
|
||||||
|
|
||||||
// ISO 형식으로 날짜 변환 (Appwrite 저장용)
|
|
||||||
const convertDateToISO = (dateStr: string): string => {
|
|
||||||
try {
|
|
||||||
// 이미 ISO 형식인 경우 그대로 반환
|
|
||||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "오늘, 시간" 형식 처리
|
|
||||||
if (dateStr.includes("오늘")) {
|
|
||||||
const today = new Date();
|
|
||||||
|
|
||||||
// 시간 추출 시도
|
|
||||||
const timeMatch = dateStr.match(/(\d{1,2}):(\d{2})/);
|
|
||||||
if (timeMatch) {
|
|
||||||
const hours = parseInt(timeMatch[1], 10);
|
|
||||||
const minutes = parseInt(timeMatch[2], 10);
|
|
||||||
today.setHours(hours, minutes, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return formatISO(today);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 일반 날짜 문자열은 그대로 Date 객체로 변환 시도
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
return formatISO(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 변환 실패 시 현재 시간 반환
|
|
||||||
appwriteLogger.warn(
|
|
||||||
`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`
|
|
||||||
);
|
|
||||||
return formatISO(new Date());
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error(`날짜 변환 오류: "${dateStr}"`, error);
|
|
||||||
return formatISO(new Date());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Appwrite와 트랜잭션 동기화
|
|
||||||
export const syncTransactionsWithAppwrite = async (
|
|
||||||
user: Models.User<Models.Preferences>,
|
|
||||||
transactions: Transaction[]
|
|
||||||
): Promise<Transaction[]> => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return transactions;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const databaseId = getAppwriteDatabaseId();
|
|
||||||
const collectionId = getAppwriteTransactionsCollectionId();
|
|
||||||
|
|
||||||
const { documents } = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
collectionId,
|
|
||||||
[Query.equal("user_id", user.$id)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (documents && documents.length > 0) {
|
|
||||||
// Appwrite 데이터 로컬 형식으로 변환 (타입 검증 포함)
|
|
||||||
const appwriteTransactions = documents
|
|
||||||
.map((doc) => ({
|
|
||||||
id: doc.transaction_id,
|
|
||||||
title: doc.title,
|
|
||||||
amount: doc.amount,
|
|
||||||
date: doc.date,
|
|
||||||
category: doc.category,
|
|
||||||
type: doc.type,
|
|
||||||
}))
|
|
||||||
.filter((tx) => {
|
|
||||||
// 타입 가드를 사용한 런타임 검증
|
|
||||||
const isValid = isValidTransaction(tx);
|
|
||||||
if (!isValid) {
|
|
||||||
appwriteLogger.warn("Invalid transaction data from Appwrite:", tx);
|
|
||||||
}
|
|
||||||
return isValid;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로컬 데이터와 병합 (중복 ID 제거)
|
|
||||||
const mergedTransactions = [...transactions];
|
|
||||||
|
|
||||||
appwriteTransactions.forEach((newTx) => {
|
|
||||||
const existingIndex = mergedTransactions.findIndex(
|
|
||||||
(t) => t.id === newTx.id
|
|
||||||
);
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
mergedTransactions[existingIndex] = newTx;
|
|
||||||
} else {
|
|
||||||
mergedTransactions.push(newTx);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return mergedTransactions;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
appwriteLogger.error("Appwrite 동기화 오류:", err);
|
|
||||||
}
|
|
||||||
|
|
||||||
return transactions;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Appwrite에 트랜잭션 업데이트
|
|
||||||
export const updateTransactionInAppwrite = async (
|
|
||||||
user: Models.User<Models.Preferences>,
|
|
||||||
transaction: Transaction
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const databaseId = getAppwriteDatabaseId();
|
|
||||||
const collectionId = getAppwriteTransactionsCollectionId();
|
|
||||||
|
|
||||||
// 날짜를 ISO 형식으로 변환
|
|
||||||
const isoDate = convertDateToISO(transaction.date);
|
|
||||||
|
|
||||||
// 기존 문서 찾기
|
|
||||||
const { documents } = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
collectionId,
|
|
||||||
[Query.equal("transaction_id", transaction.id)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (documents && documents.length > 0) {
|
|
||||||
// 기존 문서 업데이트
|
|
||||||
await databases.updateDocument(
|
|
||||||
databaseId,
|
|
||||||
collectionId,
|
|
||||||
documents[0].$id,
|
|
||||||
{
|
|
||||||
title: transaction.title,
|
|
||||||
amount: transaction.amount,
|
|
||||||
date: isoDate,
|
|
||||||
category: transaction.category,
|
|
||||||
type: transaction.type,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
appwriteLogger.info("Appwrite 트랜잭션 업데이트 성공:", transaction.id);
|
|
||||||
} else {
|
|
||||||
// 새 문서 생성
|
|
||||||
await databases.createDocument(databaseId, collectionId, ID.unique(), {
|
|
||||||
user_id: user.$id,
|
|
||||||
transaction_id: transaction.id,
|
|
||||||
title: transaction.title,
|
|
||||||
amount: transaction.amount,
|
|
||||||
date: isoDate,
|
|
||||||
category: transaction.category,
|
|
||||||
type: transaction.type,
|
|
||||||
});
|
|
||||||
appwriteLogger.info("Appwrite 트랜잭션 생성 성공:", transaction.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 업데이트 오류:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Appwrite에서 트랜잭션 삭제 - UI 스레드 차단 방지를 위한 비동기 처리
|
|
||||||
export const deleteTransactionFromAppwrite = async (
|
|
||||||
user: Models.User<Models.Preferences>,
|
|
||||||
transactionId: string
|
|
||||||
): Promise<void> => {
|
|
||||||
if (!user || !isSyncEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 컴포넌트 마운트 상태 추적을 위한 변수
|
|
||||||
const isMounted = true;
|
|
||||||
|
|
||||||
// 비동기 작업 래퍼 함수
|
|
||||||
const performDelete = async () => {
|
|
||||||
try {
|
|
||||||
const databaseId = getAppwriteDatabaseId();
|
|
||||||
const collectionId = getAppwriteTransactionsCollectionId();
|
|
||||||
|
|
||||||
// 기존 문서 찾기
|
|
||||||
const { documents } = await databases.listDocuments(
|
|
||||||
databaseId,
|
|
||||||
collectionId,
|
|
||||||
[Query.equal("transaction_id", transactionId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
} // 컴포넌트가 언마운트되었으면 중단
|
|
||||||
|
|
||||||
if (documents && documents.length > 0) {
|
|
||||||
// requestAnimationFrame을 사용하여 UI 업데이트 최적화
|
|
||||||
requestAnimationFrame(async () => {
|
|
||||||
try {
|
|
||||||
await databases.deleteDocument(
|
|
||||||
databaseId,
|
|
||||||
collectionId,
|
|
||||||
documents[0].$id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isMounted) {
|
|
||||||
return;
|
|
||||||
} // 컴포넌트가 언마운트되었으면 중단
|
|
||||||
|
|
||||||
appwriteLogger.info("Appwrite 트랜잭션 삭제 성공:", transactionId);
|
|
||||||
} catch (innerError) {
|
|
||||||
appwriteLogger.error("Appwrite 삭제 내부 오류:", innerError);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("Appwrite 삭제 오류:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비동기 작업 시작
|
|
||||||
performDelete();
|
|
||||||
|
|
||||||
// 정리 함수 반환은 해제 (이 함수는 void를 반환해야 함)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컴포넌트에서 사용할 수 있는 삭제 함수 (정리 함수 반환)
|
|
||||||
export const deleteTransactionWithCleanup = (
|
|
||||||
user: Models.User<Models.Preferences>,
|
|
||||||
transactionId: string
|
|
||||||
): (() => void) => {
|
|
||||||
let isMounted = true;
|
|
||||||
|
|
||||||
// 삭제 작업 시작
|
|
||||||
deleteTransactionFromAppwrite(user, transactionId);
|
|
||||||
|
|
||||||
// 정리 함수 반환
|
|
||||||
return () => {
|
|
||||||
isMounted = false;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 트랜잭션 삭제 작업을 디바운스하기 위한 유틸리티
|
|
||||||
const deleteTimeouts: Record<string, NodeJS.Timeout> = {};
|
|
||||||
|
|
||||||
// 디바운스된 트랜잭션 삭제 함수
|
|
||||||
export const debouncedDeleteTransaction = (
|
|
||||||
user: Models.User<Models.Preferences>,
|
|
||||||
transactionId: string,
|
|
||||||
delay = 300
|
|
||||||
): Promise<void> => {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
// 이전 타임아웃이 있으면 취소
|
|
||||||
if (deleteTimeouts[transactionId]) {
|
|
||||||
clearTimeout(deleteTimeouts[transactionId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 새 타임아웃 설정
|
|
||||||
deleteTimeouts[transactionId] = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
await deleteTransactionFromAppwrite(user, transactionId);
|
|
||||||
resolve();
|
|
||||||
} catch (error) {
|
|
||||||
appwriteLogger.error("디바운스된 삭제 작업 오류:", error);
|
|
||||||
resolve();
|
|
||||||
} finally {
|
|
||||||
delete deleteTimeouts[transactionId];
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -95,6 +95,6 @@ export const syncLogger = createDomainLogger("SYNC");
|
|||||||
export const authLogger = createDomainLogger("AUTH");
|
export const authLogger = createDomainLogger("AUTH");
|
||||||
export const networkLogger = createDomainLogger("NETWORK");
|
export const networkLogger = createDomainLogger("NETWORK");
|
||||||
export const storageLogger = createDomainLogger("STORAGE");
|
export const storageLogger = createDomainLogger("STORAGE");
|
||||||
export const appwriteLogger = createDomainLogger("APPWRITE");
|
export const supabaseLogger = createDomainLogger("SUPABASE");
|
||||||
|
|
||||||
export default logger;
|
export default logger;
|
||||||
|
|||||||
18
vercel.json
18
vercel.json
@@ -43,23 +43,17 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"env": {
|
"env": {
|
||||||
"VITE_APPWRITE_ENDPOINT": "@vite_appwrite_endpoint",
|
"VITE_SUPABASE_URL": "@vite_supabase_url",
|
||||||
"VITE_APPWRITE_PROJECT_ID": "@vite_appwrite_project_id",
|
"VITE_SUPABASE_ANON_KEY": "@vite_supabase_anon_key",
|
||||||
"VITE_APPWRITE_DATABASE_ID": "@vite_appwrite_database_id",
|
"VITE_CLERK_PUBLISHABLE_KEY": "@vite_clerk_publishable_key",
|
||||||
"VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID": "@vite_appwrite_transactions_collection_id",
|
|
||||||
"VITE_APPWRITE_API_KEY": "@vite_appwrite_api_key",
|
|
||||||
"VITE_DISABLE_LOVABLE_BANNER": "@vite_disable_lovable_banner",
|
|
||||||
"VITE_SENTRY_DSN": "@vite_sentry_dsn",
|
"VITE_SENTRY_DSN": "@vite_sentry_dsn",
|
||||||
"VITE_SENTRY_ENVIRONMENT": "@vite_sentry_environment"
|
"VITE_SENTRY_ENVIRONMENT": "@vite_sentry_environment"
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"env": {
|
"env": {
|
||||||
"VITE_APPWRITE_ENDPOINT": "@vite_appwrite_endpoint",
|
"VITE_SUPABASE_URL": "@vite_supabase_url",
|
||||||
"VITE_APPWRITE_PROJECT_ID": "@vite_appwrite_project_id",
|
"VITE_SUPABASE_ANON_KEY": "@vite_supabase_anon_key",
|
||||||
"VITE_APPWRITE_DATABASE_ID": "@vite_appwrite_database_id",
|
"VITE_CLERK_PUBLISHABLE_KEY": "@vite_clerk_publishable_key",
|
||||||
"VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID": "@vite_appwrite_transactions_collection_id",
|
|
||||||
"VITE_APPWRITE_API_KEY": "@vite_appwrite_api_key",
|
|
||||||
"VITE_DISABLE_LOVABLE_BANNER": "@vite_disable_lovable_banner",
|
|
||||||
"VITE_SENTRY_DSN": "@vite_sentry_dsn",
|
"VITE_SENTRY_DSN": "@vite_sentry_dsn",
|
||||||
"VITE_SENTRY_ENVIRONMENT": "@vite_sentry_environment"
|
"VITE_SENTRY_ENVIRONMENT": "@vite_sentry_environment"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react-swc";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { componentTagger } from "lovable-tagger";
|
|
||||||
import { visualizer } from "rollup-plugin-visualizer";
|
import { visualizer } from "rollup-plugin-visualizer";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
@@ -18,7 +17,6 @@ export default defineConfig(({ mode }) => ({
|
|||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
mode === "development" && componentTagger(),
|
|
||||||
visualizer({
|
visualizer({
|
||||||
filename: "dist/stats.html",
|
filename: "dist/stats.html",
|
||||||
open: false,
|
open: false,
|
||||||
@@ -59,7 +57,8 @@ export default defineConfig(({ mode }) => ({
|
|||||||
],
|
],
|
||||||
charts: ["recharts"],
|
charts: ["recharts"],
|
||||||
query: ["@tanstack/react-query", "@tanstack/react-query-devtools"],
|
query: ["@tanstack/react-query", "@tanstack/react-query-devtools"],
|
||||||
appwrite: ["appwrite"],
|
clerk: ["@clerk/clerk-react"],
|
||||||
|
supabase: ["@supabase/supabase-js"],
|
||||||
sentry: ["@sentry/react", "@sentry/tracing"],
|
sentry: ["@sentry/react", "@sentry/tracing"],
|
||||||
date: ["date-fns"],
|
date: ["date-fns"],
|
||||||
utils: ["clsx", "class-variance-authority", "tailwind-merge"],
|
utils: ["clsx", "class-variance-authority", "tailwind-merge"],
|
||||||
|
|||||||
Reference in New Issue
Block a user