🎯 프로젝트 리브랜딩: Kumamoto → Travel Planner v2.0
✨ 주요 변경사항: - 프로젝트 이름: kumamoto-travel-planner → travel-planner - 버전 업그레이드: v1.0.0 → v2.0.0 - 멀티유저 시스템 구현 (JWT 인증) - PostgreSQL 마이그레이션 시스템 추가 - Docker 컨테이너 이름 변경 - UI 브랜딩 업데이트 (Travel Planner) - API 서버 및 인증 시스템 추가 - 여행 공유 기능 구현 - 템플릿 시스템 추가 🔧 기술 스택: - Frontend: React + TypeScript + Vite - Backend: Node.js + Express + JWT - Database: PostgreSQL + 마이그레이션 - Infrastructure: Docker + Docker Compose 🌟 새로운 기능: - 사용자 인증 및 권한 관리 - 다중 여행 계획 관리 - 여행 템플릿 시스템 - 공유 링크 및 댓글 시스템 - 관리자 대시보드
This commit is contained in:
213
DATABASE_SETUP.md
Normal file
213
DATABASE_SETUP.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 🗄️ 데이터베이스 설정 가이드
|
||||
|
||||
## 📋 개요
|
||||
Travel Planner v2.0은 PostgreSQL을 사용하여 멀티 사용자 여행 계획을 관리합니다.
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. PostgreSQL 설치 확인
|
||||
```bash
|
||||
# PostgreSQL 버전 확인
|
||||
psql --version
|
||||
|
||||
# PostgreSQL 서비스 상태 확인 (macOS)
|
||||
brew services list | grep postgresql
|
||||
|
||||
# PostgreSQL 시작 (macOS)
|
||||
brew services start postgresql
|
||||
```
|
||||
|
||||
### 2. 데이터베이스 생성
|
||||
```bash
|
||||
# PostgreSQL 접속
|
||||
psql postgres
|
||||
|
||||
# 데이터베이스 생성
|
||||
CREATE DATABASE kumamoto_travel;
|
||||
|
||||
# 사용자 생성 (선택사항)
|
||||
CREATE USER kumamoto_user WITH PASSWORD 'your_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE kumamoto_travel TO kumamoto_user;
|
||||
|
||||
# 종료
|
||||
\q
|
||||
```
|
||||
|
||||
### 3. 환경 변수 설정
|
||||
```bash
|
||||
# 서버 디렉토리로 이동
|
||||
cd server
|
||||
|
||||
# 환경 변수 파일 생성
|
||||
cp env.example .env
|
||||
|
||||
# .env 파일 편집
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 4. .env 파일 예시
|
||||
```env
|
||||
# 기본 설정 (로컬 개발용)
|
||||
DATABASE_URL=postgresql://localhost:5432/kumamoto_travel
|
||||
|
||||
# 사용자 계정을 만든 경우
|
||||
DATABASE_URL=postgresql://kumamoto_user:your_password@localhost:5432/kumamoto_travel
|
||||
|
||||
# JWT 시크릿 (랜덤한 문자열로 변경하세요)
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production-123456789
|
||||
|
||||
# 선택사항
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## 🔧 서버 시작
|
||||
|
||||
### 1. 의존성 설치
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 서버 실행
|
||||
```bash
|
||||
# 개발 모드
|
||||
npm run dev
|
||||
|
||||
# 또는 일반 모드
|
||||
npm start
|
||||
```
|
||||
|
||||
### 3. 서버 확인
|
||||
```bash
|
||||
# 헬스 체크
|
||||
curl http://localhost:3000/health
|
||||
|
||||
# 데이터베이스 연결 테스트
|
||||
curl http://localhost:3000/api/setup/test-db
|
||||
|
||||
# 설정 상태 확인
|
||||
curl http://localhost:3000/api/setup/status
|
||||
```
|
||||
|
||||
## 📊 데이터베이스 스키마
|
||||
|
||||
### 주요 테이블
|
||||
- **users**: 사용자 계정 (관리자/일반 사용자)
|
||||
- **travel_plans**: 여행 계획 (멀티 목적지 지원)
|
||||
- **day_schedules**: 날짜별 일정
|
||||
- **activities**: 개별 활동
|
||||
- **share_links**: 공유 링크 관리
|
||||
- **trip_comments**: 여행 계획 댓글
|
||||
|
||||
### 스키마 업데이트
|
||||
```bash
|
||||
# 기존 데이터가 있는 경우 백업
|
||||
pg_dump kumamoto_travel > backup.sql
|
||||
|
||||
# 새 스키마 적용
|
||||
psql kumamoto_travel < server/schema_v2.sql
|
||||
```
|
||||
|
||||
## 🔐 보안 설정
|
||||
|
||||
### JWT 시크릿 생성
|
||||
```bash
|
||||
# 랜덤 시크릿 생성 (Node.js)
|
||||
node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||
|
||||
# 또는 OpenSSL 사용
|
||||
openssl rand -hex 64
|
||||
```
|
||||
|
||||
### 데이터베이스 보안
|
||||
```sql
|
||||
-- 사용자별 권한 설정
|
||||
REVOKE ALL ON SCHEMA public FROM PUBLIC;
|
||||
GRANT USAGE ON SCHEMA public TO kumamoto_user;
|
||||
GRANT ALL ON ALL TABLES IN SCHEMA public TO kumamoto_user;
|
||||
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO kumamoto_user;
|
||||
```
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### 연결 오류
|
||||
```bash
|
||||
# PostgreSQL 실행 확인
|
||||
ps aux | grep postgres
|
||||
|
||||
# 포트 확인
|
||||
lsof -i :5432
|
||||
|
||||
# 로그 확인
|
||||
tail -f /usr/local/var/log/postgres.log
|
||||
```
|
||||
|
||||
### 권한 오류
|
||||
```sql
|
||||
-- 데이터베이스 소유자 변경
|
||||
ALTER DATABASE kumamoto_travel OWNER TO kumamoto_user;
|
||||
|
||||
-- 테이블 권한 부여
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO kumamoto_user;
|
||||
```
|
||||
|
||||
### 스키마 초기화
|
||||
```bash
|
||||
# 모든 테이블 삭제 후 재생성
|
||||
psql kumamoto_travel -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
psql kumamoto_travel < server/schema_v2.sql
|
||||
```
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### 인덱스 확인
|
||||
```sql
|
||||
-- 인덱스 사용 현황
|
||||
SELECT schemaname, tablename, indexname, idx_tup_read, idx_tup_fetch
|
||||
FROM pg_stat_user_indexes;
|
||||
|
||||
-- 느린 쿼리 확인
|
||||
SELECT query, mean_time, calls FROM pg_stat_statements ORDER BY mean_time DESC;
|
||||
```
|
||||
|
||||
### 연결 풀 설정
|
||||
```javascript
|
||||
// server/db.js에서 연결 풀 조정
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
max: 20, // 최대 연결 수
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
```
|
||||
|
||||
## 🚀 프로덕션 배포
|
||||
|
||||
### 환경 변수 (프로덕션)
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@host:port/database?sslmode=require
|
||||
JWT_SECRET=production-secret-key-very-long-and-random
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### SSL 설정
|
||||
```javascript
|
||||
// SSL 연결 (프로덕션)
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
```
|
||||
|
||||
## 📞 지원
|
||||
|
||||
문제가 발생하면 다음을 확인하세요:
|
||||
1. PostgreSQL 서비스 실행 상태
|
||||
2. .env 파일의 DATABASE_URL 정확성
|
||||
3. 데이터베이스 사용자 권한
|
||||
4. 방화벽 설정 (포트 5432, 3000)
|
||||
|
||||
성공적으로 설정되면 브라우저에서 `http://localhost:5173`에 접속하여 초기 설정을 완료할 수 있습니다.
|
||||
125
IMPROVEMENT_PLAN.md
Normal file
125
IMPROVEMENT_PLAN.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# 구마모토 여행 앱 UI 개선 계획
|
||||
|
||||
## 📋 개선 목표
|
||||
|
||||
### 데스크톱 모드 (계획 수립 중심)
|
||||
**목표**: 효율적인 여행 계획 수립을 위한 종합 대시보드
|
||||
|
||||
### 모바일 모드 (여행 중 사용)
|
||||
**목표**: 여행 중 필요한 정보에 빠르게 접근할 수 있는 실용적 인터페이스
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 데스크톱 모드 개선 계획
|
||||
|
||||
### 1. 레이아웃 최적화
|
||||
- **3컬럼 레이아웃**: 지도(50%) | 일정(30%) | 정보(20%)
|
||||
- **상단 대시보드**: 여행 개요, 진행률, 예산 요약
|
||||
- **탭 기반 네비게이션**: 일정 | 관광지 | 예산 | 체크리스트
|
||||
|
||||
### 2. 일정 편집 기능 강화
|
||||
- **드래그앤드롭**: 일정 순서 변경
|
||||
- **시간 슬라이더**: 직관적 시간 조정
|
||||
- **관광지 연동**: 관광지 목록에서 일정으로 드래그
|
||||
- **자동 시간 계산**: 이동 시간 자동 계산
|
||||
|
||||
### 3. 정보 통합 및 시각화
|
||||
- **진행률 표시**: 계획 완성도 시각화
|
||||
- **예산 차트**: 카테고리별 예산 분배
|
||||
- **날씨 정보**: 여행 날짜 날씨 예보
|
||||
- **교통 정보**: 경로 및 소요 시간
|
||||
|
||||
### 4. 협업 기능
|
||||
- **공유 링크**: 가족과 계획 공유
|
||||
- **댓글 시스템**: 일정별 메모/의견
|
||||
- **버전 관리**: 계획 변경 이력
|
||||
|
||||
---
|
||||
|
||||
## 📱 모바일 모드 개선 계획
|
||||
|
||||
### 1. 네비게이션 최적화
|
||||
- **하단 탭바**: 지도 | 오늘일정 | 전체일정 | 더보기
|
||||
- **플로팅 액션 버튼**: 현재위치, 다음장소, 긴급연락
|
||||
- **스와이프 제스처**: 날짜 간 빠른 전환
|
||||
|
||||
### 2. 실시간 여행 지원
|
||||
- **현재 진행 상황**: 완료된 일정 체크
|
||||
- **다음 목적지**: 현재 위치에서 다음 장소까지 길찾기
|
||||
- **실시간 알림**: 일정 시간 알림, 교통 정보
|
||||
- **오프라인 모드**: 필수 정보 캐싱
|
||||
|
||||
### 3. 빠른 액세스 기능
|
||||
- **원터치 액션**: 전화걸기, 길찾기, 메모
|
||||
- **음성 메모**: 여행 중 간편 기록
|
||||
- **사진 연동**: 장소별 사진 자동 분류
|
||||
- **체크인 기능**: 방문 완료 체크
|
||||
|
||||
### 4. 여행 중 편의 기능
|
||||
- **언어 지원**: 일본어 기본 문구
|
||||
- **환율 계산기**: 실시간 환율 적용
|
||||
- **긴급 정보**: 병원, 경찰서, 대사관
|
||||
- **교통카드 잔액**: IC카드 사용 기록
|
||||
|
||||
---
|
||||
|
||||
## 🔄 공통 개선 사항
|
||||
|
||||
### 1. 성능 최적화
|
||||
- **로딩 시간 단축**: 이미지 최적화, 코드 스플리팅
|
||||
- **오프라인 지원**: PWA 적용, 캐싱 전략
|
||||
- **배터리 최적화**: 위치 서비스 효율화
|
||||
|
||||
### 2. 접근성 개선
|
||||
- **다국어 지원**: 한국어, 일본어, 영어
|
||||
- **다크모드**: 야간 사용 편의성
|
||||
- **폰트 크기 조절**: 사용자 맞춤 설정
|
||||
|
||||
### 3. 데이터 동기화
|
||||
- **실시간 동기화**: 데스크톱-모바일 간 즉시 반영
|
||||
- **오프라인 동기화**: 연결 복구 시 자동 동기화
|
||||
- **백업/복원**: 클라우드 백업 기능
|
||||
|
||||
---
|
||||
|
||||
## 📅 구현 우선순위
|
||||
|
||||
### Phase 1: 핵심 기능 개선 (1주)
|
||||
1. 데스크톱 3컬럼 레이아웃 구현
|
||||
2. 모바일 하단 탭바 네비게이션
|
||||
3. 일정 드래그앤드롭 기능
|
||||
4. 진행 상황 표시
|
||||
|
||||
### Phase 2: 사용성 향상 (1주)
|
||||
1. 관광지-일정 연동 기능
|
||||
2. 실시간 위치 기반 기능
|
||||
3. 오프라인 모드 기본 구현
|
||||
4. 성능 최적화
|
||||
|
||||
### Phase 3: 고급 기능 (1주)
|
||||
1. 협업 및 공유 기능
|
||||
2. 음성/사진 연동
|
||||
3. 다국어 지원
|
||||
4. PWA 완성
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 시스템
|
||||
|
||||
### 색상 팔레트
|
||||
- **Primary**: 구마모토 레드 (#DC2626)
|
||||
- **Secondary**: 구마모토 그린 (#059669)
|
||||
- **Accent**: 구마모토 블루 (#2563EB)
|
||||
- **Neutral**: 회색 계열 (#F3F4F6 ~ #1F2937)
|
||||
|
||||
### 타이포그래피
|
||||
- **제목**: Inter/Noto Sans KR Bold
|
||||
- **본문**: Inter/Noto Sans KR Regular
|
||||
- **캡션**: Inter/Noto Sans KR Medium
|
||||
|
||||
### 컴포넌트 일관성
|
||||
- **버튼**: 둥근 모서리, 그림자 효과
|
||||
- **카드**: 미니멀 디자인, 호버 효과
|
||||
- **입력**: 명확한 라벨, 에러 상태
|
||||
- **아이콘**: 일관된 스타일, 의미 명확
|
||||
|
||||
199
README.md
199
README.md
@@ -1,54 +1,195 @@
|
||||
# 구마모토 여행 계획 사이트
|
||||
# ✈️ Travel Planner
|
||||
|
||||
2025년 2월 17일 ~ 2월 20일 구마모토 여행을 위한 가족 공유 여행 계획 사이트입니다.
|
||||
**스마트한 여행 계획 관리 시스템**
|
||||
|
||||
## 기능
|
||||
다중 사용자를 지원하는 현대적인 여행 계획 웹 애플리케이션입니다. 여행 일정 관리, 지도 통합, 공유 기능을 제공합니다.
|
||||
|
||||
- 📅 **여행 일정 관리**: 날짜별 일정을 추가하고 관리할 수 있습니다
|
||||
- 🗾 **관광지 정보**: 구마모토 주요 관광지 정보를 확인할 수 있습니다
|
||||
- 💰 **예산 관리**: 항목별 예산을 설정하고 환율을 적용해 원화로 확인할 수 있습니다
|
||||
- ✅ **체크리스트**: 준비물, 쇼핑 목록, 방문할 곳 등을 체크리스트로 관리합니다
|
||||
## 🌟 주요 기능
|
||||
|
||||
## 시작하기
|
||||
### 👥 멀티 사용자 시스템
|
||||
- **사용자 인증**: JWT 기반 로그인/회원가입
|
||||
- **관리자 시스템**: 사용자 및 시스템 관리
|
||||
- **권한 관리**: 개인/공개 여행 계획 설정
|
||||
|
||||
### 설치
|
||||
### 🗺️ 여행 계획 관리
|
||||
- **다중 여행 관리**: 여러 여행 계획 동시 관리
|
||||
- **템플릿 시스템**: 도시별 여행 템플릿 제공
|
||||
- **일정 관리**: 날짜별 상세 일정 작성
|
||||
- **장소 검색**: Google Places API 통합
|
||||
|
||||
### 🔗 공유 및 협업
|
||||
- **여행 공유**: 링크를 통한 여행 계획 공유
|
||||
- **권한 설정**: 보기/편집/댓글 권한 제어
|
||||
- **댓글 시스템**: 공유된 여행에 댓글 작성
|
||||
|
||||
### 🗺️ 지도 통합
|
||||
- **Google Maps**: 장소 검색 및 경로 최적화
|
||||
- **Leaflet**: 오프라인 지도 지원
|
||||
- **경로 계획**: 여행지 간 최적 경로 제안
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### Docker로 실행 (권장)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 저장소 클론
|
||||
git clone <repository-url>
|
||||
cd travel-planner
|
||||
|
||||
# Docker 환경 시작
|
||||
./docker-start.sh
|
||||
|
||||
# 또는 수동 실행
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### 개발 서버 실행
|
||||
### 로컬 개발 환경
|
||||
|
||||
```bash
|
||||
# 의존성 설치
|
||||
npm install
|
||||
|
||||
# 개발 서버 시작
|
||||
npm run dev
|
||||
|
||||
# API 서버 시작 (별도 터미널)
|
||||
cd server
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
브라우저에서 `http://localhost:5173`을 열어 확인하세요.
|
||||
## 🔧 환경 설정
|
||||
|
||||
### 빌드
|
||||
### 필수 환경 변수
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```env
|
||||
# 프론트엔드 (.env)
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id
|
||||
|
||||
# 백엔드 (server/.env)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/travel_planner
|
||||
JWT_SECRET=your-jwt-secret-key
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
||||
```
|
||||
|
||||
## 기술 스택
|
||||
### 데이터베이스 설정
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- date-fns
|
||||
```bash
|
||||
# PostgreSQL 설치 및 시작
|
||||
brew install postgresql
|
||||
brew services start postgresql
|
||||
|
||||
## 사용 방법
|
||||
# 데이터베이스 생성
|
||||
createdb travel_planner
|
||||
|
||||
1. **일정 추가**: 각 날짜 옆의 "+ 일정 추가" 버튼을 클릭하여 일정을 추가합니다
|
||||
2. **예산 설정**: 예산 관리 섹션에서 각 항목을 클릭하여 예산을 입력합니다
|
||||
3. **체크리스트**: 체크리스트에 항목을 추가하고 완료 시 체크박스를 클릭합니다
|
||||
# 마이그레이션 실행 (자동)
|
||||
npm start # 서버 시작 시 자동 실행
|
||||
```
|
||||
|
||||
## 공유하기
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
이 사이트는 로컬 스토리지를 사용하여 데이터를 저장합니다. 가족과 공유하려면:
|
||||
```
|
||||
travel-planner/
|
||||
├── src/ # 프론트엔드 소스
|
||||
│ ├── components/ # React 컴포넌트
|
||||
│ ├── services/ # API 서비스
|
||||
│ ├── types/ # TypeScript 타입
|
||||
│ └── utils/ # 유틸리티 함수
|
||||
├── server/ # 백엔드 API
|
||||
│ ├── routes/ # API 라우트
|
||||
│ ├── migrations/ # DB 마이그레이션
|
||||
│ └── uploads/ # 파일 업로드
|
||||
├── docker/ # Docker 설정
|
||||
└── docs/ # 문서
|
||||
```
|
||||
|
||||
1. 개발 서버를 실행한 후 네트워크 IP로 접근하거나
|
||||
2. 빌드 후 정적 호스팅 서비스(Vercel, Netlify 등)에 배포하세요
|
||||
## 🛠️ 기술 스택
|
||||
|
||||
### 프론트엔드
|
||||
- **React 18** + **TypeScript**
|
||||
- **Vite** (빌드 도구)
|
||||
- **Tailwind CSS** (스타일링)
|
||||
- **React Router** (라우팅)
|
||||
- **Leaflet** + **Google Maps** (지도)
|
||||
|
||||
### 백엔드
|
||||
- **Node.js** + **Express**
|
||||
- **PostgreSQL** (데이터베이스)
|
||||
- **JWT** (인증)
|
||||
- **Multer** (파일 업로드)
|
||||
|
||||
### 인프라
|
||||
- **Docker** + **Docker Compose**
|
||||
- **Nginx** (프로덕션)
|
||||
|
||||
## 📊 API 문서
|
||||
|
||||
### 인증 API
|
||||
```
|
||||
POST /api/auth/register # 회원가입
|
||||
POST /api/auth/login # 로그인
|
||||
GET /api/auth/verify # 토큰 검증
|
||||
```
|
||||
|
||||
### 여행 계획 API
|
||||
```
|
||||
GET /api/travel-plans # 여행 목록
|
||||
POST /api/travel-plans # 여행 생성
|
||||
GET /api/travel-plans/:id # 여행 조회
|
||||
PUT /api/travel-plans/:id # 여행 수정
|
||||
DELETE /api/travel-plans/:id # 여행 삭제
|
||||
```
|
||||
|
||||
### 공유 API
|
||||
```
|
||||
POST /api/share/create # 공유 링크 생성
|
||||
GET /api/share/:code # 공유된 여행 조회
|
||||
```
|
||||
|
||||
## 🔒 보안
|
||||
|
||||
- **JWT 토큰**: 안전한 사용자 인증
|
||||
- **비밀번호 해시**: bcrypt 암호화
|
||||
- **SQL 인젝션 방지**: 매개변수화된 쿼리
|
||||
- **CORS 설정**: 허용된 도메인만 접근
|
||||
|
||||
## 🌍 배포
|
||||
|
||||
### Docker 배포
|
||||
```bash
|
||||
# 프로덕션 빌드
|
||||
docker-compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
### 수동 배포
|
||||
```bash
|
||||
# 프론트엔드 빌드
|
||||
npm run build
|
||||
|
||||
# 서버 시작
|
||||
cd server
|
||||
npm start
|
||||
```
|
||||
|
||||
## 🤝 기여하기
|
||||
|
||||
1. Fork the repository
|
||||
2. Create feature branch (`git checkout -b feature/amazing-feature`)
|
||||
3. Commit changes (`git commit -m 'Add amazing feature'`)
|
||||
4. Push to branch (`git push origin feature/amazing-feature`)
|
||||
5. Open Pull Request
|
||||
|
||||
## 📝 라이선스
|
||||
|
||||
MIT License - 자세한 내용은 [LICENSE](LICENSE) 파일을 참조하세요.
|
||||
|
||||
## 📞 지원
|
||||
|
||||
- **이슈 리포트**: GitHub Issues
|
||||
- **문서**: [Wiki](../../wiki)
|
||||
- **FAQ**: [자주 묻는 질문](docs/FAQ.md)
|
||||
|
||||
---
|
||||
|
||||
**Travel Planner** - 당신의 완벽한 여행을 계획하세요! ✈️🗺️
|
||||
@@ -1,6 +1,40 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: kumamoto-db
|
||||
environment:
|
||||
POSTGRES_USER: kumamoto
|
||||
POSTGRES_PASSWORD: kumamoto123
|
||||
POSTGRES_DB: kumamoto_travel
|
||||
TZ: Asia/Seoul
|
||||
PGTZ: Asia/Seoul
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
# Backend API Server
|
||||
api:
|
||||
image: node:20-alpine
|
||||
container_name: kumamoto-api
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- ./server:/app
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://kumamoto:kumamoto123@db:5432/kumamoto_travel
|
||||
NODE_ENV: development
|
||||
TZ: Asia/Seoul
|
||||
command: sh -c "npm install && npm run dev"
|
||||
depends_on:
|
||||
- db
|
||||
restart: unless-stopped
|
||||
|
||||
# Frontend (Vite)
|
||||
web:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
@@ -9,31 +43,15 @@ services:
|
||||
- /app/node_modules
|
||||
ports:
|
||||
- "5173:5173"
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:3000
|
||||
TZ: Asia/Seoul
|
||||
command: sh -c "npm install && npm run dev -- --host"
|
||||
container_name: kumamoto-travel-planner-dev
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- map-server
|
||||
environment:
|
||||
- VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
|
||||
map-server:
|
||||
build:
|
||||
context: ./docker/map-server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
container_name: kumamoto-map-server-dev
|
||||
- api
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- map_data_dev:/var/lib/postgresql/data
|
||||
- map_tiles_dev:/var/www/html/tiles
|
||||
environment:
|
||||
- POSTGRES_DB=kumamoto_map
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=mapserver123
|
||||
|
||||
volumes:
|
||||
map_data_dev:
|
||||
map_tiles_dev:
|
||||
postgres_data:
|
||||
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 프론트엔드
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:80"
|
||||
container_name: kumamoto-travel-planner
|
||||
container_name: travel-planner-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api-server
|
||||
- map-server
|
||||
environment:
|
||||
- VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
- VITE_API_URL=http://localhost:3001
|
||||
|
||||
# API 서버
|
||||
api-server:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3001:3000"
|
||||
container_name: travel-planner-api
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- map-server
|
||||
environment:
|
||||
- VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
- DATABASE_URL=postgresql://postgres:mapserver123@map-server:5432/kumamoto_map
|
||||
- JWT_SECRET=travel-planner-jwt-secret-key-2024-docker
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
volumes:
|
||||
- ./server/uploads:/app/uploads
|
||||
- ./server/migrations:/app/migrations
|
||||
|
||||
# 지도 서버 (기존)
|
||||
map-server:
|
||||
build:
|
||||
context: ./docker/map-server
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:80"
|
||||
container_name: kumamoto-map-server
|
||||
container_name: travel-planner-map-server
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- map_data:/var/lib/postgresql/data
|
||||
@@ -31,6 +55,6 @@ services:
|
||||
- POSTGRES_PASSWORD=mapserver123
|
||||
|
||||
volumes:
|
||||
map_data:
|
||||
map_tiles:
|
||||
map_data: # 지도 데이터 (기존 DB 포함)
|
||||
map_tiles: # 지도 타일
|
||||
|
||||
|
||||
66
docker-start.sh
Normal file
66
docker-start.sh
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Travel Planner Docker 시작 스크립트
|
||||
|
||||
echo "🐳 Travel Planner Docker 환경 시작 중..."
|
||||
|
||||
# 기존 컨테이너 정리 (선택사항)
|
||||
read -p "기존 컨테이너를 정리하시겠습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "🧹 기존 컨테이너 정리 중..."
|
||||
docker-compose down -v
|
||||
docker system prune -f
|
||||
fi
|
||||
|
||||
# 환경 변수 파일 확인
|
||||
if [ ! -f .env ]; then
|
||||
echo "📋 환경 변수 파일 생성 중..."
|
||||
cp env.docker .env
|
||||
echo "⚠️ .env 파일을 확인하고 필요한 설정을 수정하세요."
|
||||
fi
|
||||
|
||||
# 서버 환경 변수 확인
|
||||
if [ ! -f server/.env ]; then
|
||||
echo "📋 서버 환경 변수 파일 생성 중..."
|
||||
cp server/env.example server/.env
|
||||
echo "✅ 서버 환경 변수가 Docker Compose에서 자동 설정됩니다."
|
||||
fi
|
||||
|
||||
# Docker 이미지 빌드 및 시작
|
||||
echo "🔨 Docker 이미지 빌드 중..."
|
||||
docker-compose build
|
||||
|
||||
echo "🚀 서비스 시작 중..."
|
||||
docker-compose up -d
|
||||
|
||||
# 서비스 상태 확인
|
||||
echo "⏳ 서비스 시작 대기 중..."
|
||||
sleep 10
|
||||
|
||||
echo "🔍 서비스 상태 확인 중..."
|
||||
docker-compose ps
|
||||
|
||||
# 헬스 체크
|
||||
echo "🏥 헬스 체크 중..."
|
||||
echo "- 프론트엔드: http://localhost:3000"
|
||||
echo "- API 서버: http://localhost:3001/health"
|
||||
echo "- 지도 서버: http://localhost:8080"
|
||||
echo "- 데이터베이스: localhost:5432"
|
||||
|
||||
# API 서버 헬스 체크
|
||||
echo ""
|
||||
echo "📡 API 서버 연결 테스트..."
|
||||
sleep 5
|
||||
curl -f http://localhost:3001/health || echo "⚠️ API 서버가 아직 시작 중입니다. 잠시 후 다시 시도하세요."
|
||||
|
||||
echo ""
|
||||
echo "🎉 Travel Planner Docker 환경이 시작되었습니다!"
|
||||
echo ""
|
||||
echo "📖 사용법:"
|
||||
echo " - 웹 애플리케이션: http://localhost:3000"
|
||||
echo " - 초기 설정: http://localhost:3000/?debug=true"
|
||||
echo " - API 서버: http://localhost:3001"
|
||||
echo " - 로그 확인: docker-compose logs -f"
|
||||
echo " - 중지: docker-compose down"
|
||||
echo ""
|
||||
4
env.docker
Normal file
4
env.docker
Normal file
@@ -0,0 +1,4 @@
|
||||
# Docker 환경 변수 (기존 DB 사용)
|
||||
VITE_API_URL=http://localhost:3001
|
||||
VITE_MAP_TILES_URL=http://localhost:8080/tiles
|
||||
VITE_GOOGLE_OAUTH_CLIENT_ID=your-google-oauth-client-id
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>구마모토 여행 계획 - 2025년 2월</title>
|
||||
<title>Travel Planner - 여행 계획 관리 시스템</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
56
package-lock.json
generated
56
package-lock.json
generated
@@ -13,7 +13,8 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^7.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
@@ -1565,6 +1566,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2525,6 +2535,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz",
|
||||
"integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.9.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.5.tgz",
|
||||
"integrity": "sha512-mkEmq/K8tKN63Ae2M7Xgz3c9l9YNbY+NHH6NNeUmLA3kDkhKXRsNb/ZpxaEunvGo2/3YXdk5EJU3Hxp3ocaBPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.9.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2665,6 +2713,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "kumamoto-travel-planner",
|
||||
"name": "travel-planner",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -14,7 +14,8 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1"
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^7.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
|
||||
27
server/Dockerfile
Normal file
27
server/Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
||||
# API Server Dockerfile
|
||||
FROM node:18-alpine
|
||||
|
||||
# 작업 디렉토리 설정
|
||||
WORKDIR /app
|
||||
|
||||
# 패키지 파일 복사
|
||||
COPY package*.json ./
|
||||
|
||||
# 의존성 설치
|
||||
RUN npm ci --only=production
|
||||
|
||||
# 소스 코드 복사
|
||||
COPY . .
|
||||
|
||||
# 업로드 디렉토리 생성
|
||||
RUN mkdir -p uploads
|
||||
|
||||
# 포트 노출
|
||||
EXPOSE 3000
|
||||
|
||||
# 헬스체크
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/health || exit 1
|
||||
|
||||
# 서버 시작
|
||||
CMD ["npm", "start"]
|
||||
45
server/db.js
Normal file
45
server/db.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// PostgreSQL 연결 풀 생성
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
});
|
||||
|
||||
// 데이터베이스 초기화 (테이블 생성)
|
||||
async function initializeDatabase() {
|
||||
try {
|
||||
// v2 스키마 사용 (새로운 멀티유저 시스템)
|
||||
const schemaPath = fs.existsSync(path.join(__dirname, 'schema_v2.sql'))
|
||||
? 'schema_v2.sql'
|
||||
: 'schema.sql';
|
||||
|
||||
const schema = fs.readFileSync(path.join(__dirname, schemaPath), 'utf8');
|
||||
await pool.query(schema);
|
||||
console.log(`✅ Database tables initialized successfully (${schemaPath})`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error initializing database:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행 헬퍼 함수
|
||||
async function query(text, params) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const res = await pool.query(text, params);
|
||||
const duration = Date.now() - start;
|
||||
console.log('Executed query', { text, duration, rows: res.rowCount });
|
||||
return res;
|
||||
} catch (error) {
|
||||
console.error('Query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
pool,
|
||||
initializeDatabase,
|
||||
};
|
||||
18
server/env.example
Normal file
18
server/env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgresql://username:password@localhost:5432/kumamoto_travel
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
||||
# Google Maps API (Optional)
|
||||
GOOGLE_MAPS_API_KEY=your-google-maps-api-key
|
||||
|
||||
# Email Configuration (Optional)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASS=your-app-password
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
80
server/migrate.js
Normal file
80
server/migrate.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { query } = require('./db');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function runMigrations() {
|
||||
try {
|
||||
console.log('🔄 데이터베이스 마이그레이션 시작...');
|
||||
|
||||
// 마이그레이션 디렉토리 확인
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
if (!fs.existsSync(migrationsDir)) {
|
||||
console.log('📁 마이그레이션 디렉토리가 없습니다. 건너뜁니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 마이그레이션 파일 목록
|
||||
const migrationFiles = fs.readdirSync(migrationsDir)
|
||||
.filter(file => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
if (migrationFiles.length === 0) {
|
||||
console.log('📄 실행할 마이그레이션이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 마이그레이션 테이블 생성
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) UNIQUE NOT NULL,
|
||||
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 각 마이그레이션 실행
|
||||
for (const filename of migrationFiles) {
|
||||
// 이미 실행된 마이그레이션인지 확인
|
||||
const existingResult = await query(
|
||||
'SELECT id FROM migrations WHERE filename = $1',
|
||||
[filename]
|
||||
);
|
||||
|
||||
if (existingResult.rows.length > 0) {
|
||||
console.log(`⏭️ ${filename} - 이미 실행됨`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`🔧 ${filename} 실행 중...`);
|
||||
|
||||
// 마이그레이션 파일 읽기 및 실행
|
||||
const migrationPath = path.join(migrationsDir, filename);
|
||||
const migrationSQL = fs.readFileSync(migrationPath, 'utf8');
|
||||
|
||||
await query(migrationSQL);
|
||||
|
||||
// 마이그레이션 기록
|
||||
await query(
|
||||
'INSERT INTO migrations (filename) VALUES ($1)',
|
||||
[filename]
|
||||
);
|
||||
|
||||
console.log(`✅ ${filename} 완료`);
|
||||
}
|
||||
|
||||
console.log('🎉 모든 마이그레이션이 완료되었습니다!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 마이그레이션 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 직접 실행 시
|
||||
if (require.main === module) {
|
||||
runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
}
|
||||
|
||||
module.exports = { runMigrations };
|
||||
129
server/migrations/001_add_user_system.sql
Normal file
129
server/migrations/001_add_user_system.sql
Normal file
@@ -0,0 +1,129 @@
|
||||
-- 기존 DB에 사용자 시스템 추가 마이그레이션
|
||||
|
||||
-- 사용자 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기존 travel_plans 테이블에 컬럼 추가 (안전하게 하나씩)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='user_id') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='title') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN title VARCHAR(255) DEFAULT 'Untitled Trip';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='description') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN description TEXT;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='destination') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN destination VARCHAR(255) DEFAULT 'Kumamoto';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='is_public') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN is_public BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='is_template') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN is_template BOOLEAN DEFAULT false;
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='template_category') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN template_category VARCHAR(20);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='tags') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN tags TEXT[] DEFAULT '{}';
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='travel_plans' AND column_name='status') THEN
|
||||
ALTER TABLE travel_plans ADD COLUMN status VARCHAR(20) DEFAULT 'draft';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 공유 링크 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id),
|
||||
share_code VARCHAR(8) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
max_access_count INTEGER,
|
||||
can_view BOOLEAN DEFAULT true,
|
||||
can_edit BOOLEAN DEFAULT false,
|
||||
can_comment BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TIMESTAMP
|
||||
);
|
||||
|
||||
-- 댓글 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS trip_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trip_id INTEGER NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
is_edited BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 시스템 설정 테이블 추가
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 추가
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_user ON travel_plans(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_status ON travel_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_code ON share_links(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_trip ON share_links(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_trip ON trip_comments(trip_id);
|
||||
|
||||
-- 초기 시스템 설정
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('app_version', '2.0.0', '애플리케이션 버전'),
|
||||
('setup_completed', 'false', '초기 설정 완료 여부'),
|
||||
('jwt_secret_set', 'false', 'JWT 시크릿 설정 여부')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- 업데이트 트리거 함수
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 업데이트 트리거 적용
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_travel_plans_updated_at ON travel_plans;
|
||||
CREATE TRIGGER update_travel_plans_updated_at BEFORE UPDATE ON travel_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_comments_updated_at ON trip_comments;
|
||||
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON trip_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_settings_updated_at ON system_settings;
|
||||
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON system_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
2154
server/package-lock.json
generated
Normal file
2154
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
server/package.json
Normal file
22
server/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "travel-planner-api",
|
||||
"version": "2.0.0",
|
||||
"description": "Backend API for Travel Planner - Multi-user travel planning system",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon server.js",
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
179
server/routes/auth.js
Normal file
179
server/routes/auth.js
Normal file
@@ -0,0 +1,179 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// JWT 시크릿 (환경변수에서 가져오거나 기본값 사용)
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
|
||||
|
||||
// 회원가입
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { email, password, name } = req.body;
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({ success: false, message: '이미 사용 중인 이메일입니다' });
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 사용자 생성
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id, email, name, role, created_at',
|
||||
[email, passwordHash, name]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
},
|
||||
token,
|
||||
message: '회원가입이 완료되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ success: false, message: '회원가입 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// 사용자 조회
|
||||
const result = await query(
|
||||
'SELECT id, email, password_hash, name, role, is_active FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(400).json({ success: false, message: '등록되지 않은 이메일입니다' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(400).json({ success: false, message: '비활성화된 계정입니다' });
|
||||
}
|
||||
|
||||
// 비밀번호 확인
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({ success: false, message: '비밀번호가 올바르지 않습니다' });
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
await query('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = $1', [user.id]);
|
||||
|
||||
// JWT 토큰 생성
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, email: user.email, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
is_active: user.is_active
|
||||
},
|
||||
token,
|
||||
message: '로그인되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ success: false, message: '로그인 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// 토큰 검증
|
||||
router.get('/verify', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, is_active, last_login FROM users WHERE id = $1',
|
||||
[req.user.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ success: false, message: '사용자를 찾을 수 없습니다' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
if (!user.is_active) {
|
||||
return res.status(403).json({ success: false, message: '비활성화된 계정입니다' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
is_active: user.is_active,
|
||||
last_login: user.last_login
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
res.status(500).json({ success: false, message: '토큰 검증 중 오류가 발생했습니다' });
|
||||
}
|
||||
});
|
||||
|
||||
// JWT 토큰 인증 미들웨어
|
||||
function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ success: false, message: '액세스 토큰이 필요합니다' });
|
||||
}
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) {
|
||||
return res.status(403).json({ success: false, message: '유효하지 않은 토큰입니다' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// 관리자 권한 확인 미들웨어
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, message: '관리자 권한이 필요합니다' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
router,
|
||||
authenticateToken,
|
||||
requireAdmin
|
||||
};
|
||||
77
server/routes/basePoints.js
Normal file
77
server/routes/basePoints.js
Normal file
@@ -0,0 +1,77 @@
|
||||
const express = require('express');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 모든 기본 포인트 조회
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await query(
|
||||
'SELECT * FROM base_points ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
const basePoints = result.rows.map(row => ({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
type: row.type,
|
||||
coordinates: {
|
||||
lat: parseFloat(row.lat),
|
||||
lng: parseFloat(row.lng)
|
||||
},
|
||||
memo: row.memo
|
||||
}));
|
||||
|
||||
res.json(basePoints);
|
||||
} catch (error) {
|
||||
console.error('Error fetching base points:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 기본 포인트 추가
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { name, address, type, coordinates, memo } = req.body;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO base_points (name, address, type, lat, lng, memo)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, address || null, type, coordinates.lat, coordinates.lng, memo || null]
|
||||
);
|
||||
|
||||
const basePoint = {
|
||||
id: result.rows[0].id.toString(),
|
||||
name: result.rows[0].name,
|
||||
address: result.rows[0].address,
|
||||
type: result.rows[0].type,
|
||||
coordinates: {
|
||||
lat: parseFloat(result.rows[0].lat),
|
||||
lng: parseFloat(result.rows[0].lng)
|
||||
},
|
||||
memo: result.rows[0].memo
|
||||
};
|
||||
|
||||
res.json(basePoint);
|
||||
} catch (error) {
|
||||
console.error('Error creating base point:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 기본 포인트 삭제
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await query('DELETE FROM base_points WHERE id = $1', [id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error deleting base point:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
187
server/routes/setup.js
Normal file
187
server/routes/setup.js
Normal file
@@ -0,0 +1,187 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 설정 상태 확인
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
// 시스템 설정 확인
|
||||
const settingsResult = await query('SELECT key, value FROM system_settings');
|
||||
const settings = {};
|
||||
settingsResult.rows.forEach(row => {
|
||||
settings[row.key] = row.value;
|
||||
});
|
||||
|
||||
// 관리자 계정 존재 여부 확인
|
||||
const adminResult = await query('SELECT COUNT(*) as count FROM users WHERE role = $1', ['admin']);
|
||||
const hasAdmin = parseInt(adminResult.rows[0].count) > 0;
|
||||
|
||||
// 전체 사용자 수
|
||||
const userCountResult = await query('SELECT COUNT(*) as count FROM users');
|
||||
const totalUsers = parseInt(userCountResult.rows[0].count);
|
||||
|
||||
const isSetupComplete = settings.setup_completed === 'true' && hasAdmin;
|
||||
|
||||
res.json({
|
||||
isSetupComplete,
|
||||
is_setup_required: !isSetupComplete,
|
||||
setup_step: isSetupComplete ? 'completed' : 'initial',
|
||||
has_admin: hasAdmin,
|
||||
total_users: totalUsers,
|
||||
version: settings.app_version || '2.0.0',
|
||||
settings: {
|
||||
jwt_secret_set: settings.jwt_secret_set === 'true',
|
||||
google_maps_configured: settings.google_maps_configured === 'true',
|
||||
email_configured: settings.email_configured === 'true'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup status check error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '설정 상태 확인 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 초기 관리자 계정 생성
|
||||
router.post('/admin', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
// 입력 검증
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이름, 이메일, 비밀번호를 모두 입력해주세요'
|
||||
});
|
||||
}
|
||||
|
||||
// 이미 관리자가 있는지 확인
|
||||
const existingAdmin = await query('SELECT id FROM users WHERE role = $1', ['admin']);
|
||||
if (existingAdmin.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 관리자 계정이 존재합니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingUser = await query('SELECT id FROM users WHERE email = $1', [email]);
|
||||
if (existingUser.rows.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 사용 중인 이메일입니다'
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 해시
|
||||
const saltRounds = 10;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// 관리자 계정 생성
|
||||
const result = await query(
|
||||
'INSERT INTO users (email, password_hash, name, role) VALUES ($1, $2, $3, $4) RETURNING id, email, name, role, created_at',
|
||||
[email, passwordHash, name, 'admin']
|
||||
);
|
||||
|
||||
const admin = result.rows[0];
|
||||
|
||||
// 설정 완료 표시
|
||||
await query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
|
||||
['true', 'setup_completed']
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '관리자 계정이 생성되었습니다',
|
||||
admin: {
|
||||
id: admin.id,
|
||||
email: admin.email,
|
||||
name: admin.name,
|
||||
role: admin.role,
|
||||
created_at: admin.created_at
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin creation error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '관리자 계정 생성 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 환경 설정 업데이트
|
||||
router.post('/config', async (req, res) => {
|
||||
try {
|
||||
const { jwt_secret, google_maps_api_key, email_config } = req.body;
|
||||
|
||||
const updates = [];
|
||||
|
||||
// JWT 시크릿 설정
|
||||
if (jwt_secret) {
|
||||
process.env.JWT_SECRET = jwt_secret;
|
||||
updates.push(['jwt_secret_set', 'true']);
|
||||
}
|
||||
|
||||
// Google Maps API 키 설정
|
||||
if (google_maps_api_key) {
|
||||
process.env.GOOGLE_MAPS_API_KEY = google_maps_api_key;
|
||||
updates.push(['google_maps_configured', 'true']);
|
||||
}
|
||||
|
||||
// 이메일 설정
|
||||
if (email_config) {
|
||||
// 이메일 설정 로직 (SMTP 등)
|
||||
updates.push(['email_configured', 'true']);
|
||||
}
|
||||
|
||||
// 설정 업데이트
|
||||
for (const [key, value] of updates) {
|
||||
await query(
|
||||
'UPDATE system_settings SET value = $1, updated_at = CURRENT_TIMESTAMP WHERE key = $2',
|
||||
[value, key]
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: '환경 설정이 업데이트되었습니다',
|
||||
updated: updates.map(([key]) => key)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Config update error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '환경 설정 업데이트 중 오류가 발생했습니다',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
router.get('/test-db', async (req, res) => {
|
||||
try {
|
||||
const result = await query('SELECT NOW() as current_time, version() as db_version');
|
||||
res.json({
|
||||
success: true,
|
||||
message: '데이터베이스 연결 성공',
|
||||
data: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Database test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: '데이터베이스 연결 실패',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
183
server/routes/travelPlans.js
Normal file
183
server/routes/travelPlans.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const express = require('express');
|
||||
const { query } = require('../db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 여행 계획 전체 조회 (최신 1개)
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
// 최신 여행 계획 조회
|
||||
const planResult = await query(
|
||||
'SELECT * FROM travel_plans ORDER BY created_at DESC LIMIT 1'
|
||||
);
|
||||
|
||||
if (planResult.rows.length === 0) {
|
||||
return res.json(null);
|
||||
}
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// 해당 계획의 모든 일정 조회
|
||||
const schedules = await query(
|
||||
`SELECT ds.id, ds.schedule_date,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', a.id,
|
||||
'time', a.time,
|
||||
'title', a.title,
|
||||
'description', a.description,
|
||||
'location', a.location,
|
||||
'type', a.type,
|
||||
'coordinates', CASE
|
||||
WHEN a.lat IS NOT NULL AND a.lng IS NOT NULL
|
||||
THEN json_build_object('lat', a.lat, 'lng', a.lng)
|
||||
ELSE NULL
|
||||
END,
|
||||
'images', a.images,
|
||||
'links', a.links,
|
||||
'relatedPlaces', (
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', rp.id,
|
||||
'name', rp.name,
|
||||
'description', rp.description,
|
||||
'address', rp.address,
|
||||
'coordinates', CASE
|
||||
WHEN rp.lat IS NOT NULL AND rp.lng IS NOT NULL
|
||||
THEN json_build_object('lat', rp.lat, 'lng', rp.lng)
|
||||
ELSE NULL
|
||||
END,
|
||||
'memo', rp.memo,
|
||||
'willVisit', rp.will_visit,
|
||||
'category', rp.category,
|
||||
'images', rp.images,
|
||||
'links', rp.links
|
||||
)
|
||||
)
|
||||
FROM related_places rp
|
||||
WHERE rp.activity_id = a.id
|
||||
)
|
||||
) ORDER BY a.time
|
||||
) FILTER (WHERE a.id IS NOT NULL) as activities
|
||||
FROM day_schedules ds
|
||||
LEFT JOIN activities a ON a.day_schedule_id = ds.id
|
||||
WHERE ds.travel_plan_id = $1
|
||||
GROUP BY ds.id, ds.schedule_date
|
||||
ORDER BY ds.schedule_date`,
|
||||
[plan.id]
|
||||
);
|
||||
|
||||
const travelPlan = {
|
||||
id: plan.id,
|
||||
startDate: plan.start_date,
|
||||
endDate: plan.end_date,
|
||||
schedule: schedules.rows.map(row => ({
|
||||
date: row.schedule_date,
|
||||
activities: row.activities || []
|
||||
})),
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0
|
||||
},
|
||||
checklist: []
|
||||
};
|
||||
|
||||
res.json(travelPlan);
|
||||
} catch (error) {
|
||||
console.error('Error fetching travel plan:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 여행 계획 저장/업데이트
|
||||
router.post('/', async (req, res) => {
|
||||
const client = await query('BEGIN');
|
||||
|
||||
try {
|
||||
const { startDate, endDate, schedule } = req.body;
|
||||
|
||||
// 기존 계획 삭제 (단순화: 항상 최신 1개만 유지)
|
||||
await query('DELETE FROM travel_plans');
|
||||
|
||||
// 새 여행 계획 생성
|
||||
const planResult = await query(
|
||||
'INSERT INTO travel_plans (start_date, end_date) VALUES ($1, $2) RETURNING id',
|
||||
[startDate, endDate]
|
||||
);
|
||||
|
||||
const planId = planResult.rows[0].id;
|
||||
|
||||
// 일정별 데이터 삽입
|
||||
for (const day of schedule) {
|
||||
// day_schedule 삽입
|
||||
const scheduleResult = await query(
|
||||
'INSERT INTO day_schedules (travel_plan_id, schedule_date) VALUES ($1, $2) RETURNING id',
|
||||
[planId, day.date]
|
||||
);
|
||||
|
||||
const scheduleId = scheduleResult.rows[0].id;
|
||||
|
||||
// activities 삽입
|
||||
if (day.activities && day.activities.length > 0) {
|
||||
for (const activity of day.activities) {
|
||||
const activityResult = await query(
|
||||
`INSERT INTO activities (
|
||||
day_schedule_id, time, title, description, location, type, lat, lng, images, links
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
|
||||
[
|
||||
scheduleId,
|
||||
activity.time,
|
||||
activity.title,
|
||||
activity.description || null,
|
||||
activity.location || null,
|
||||
activity.type,
|
||||
activity.coordinates?.lat || null,
|
||||
activity.coordinates?.lng || null,
|
||||
activity.images || null,
|
||||
activity.links || null
|
||||
]
|
||||
);
|
||||
|
||||
const activityId = activityResult.rows[0].id;
|
||||
|
||||
// related_places 삽입
|
||||
if (activity.relatedPlaces && activity.relatedPlaces.length > 0) {
|
||||
for (const place of activity.relatedPlaces) {
|
||||
await query(
|
||||
`INSERT INTO related_places (
|
||||
activity_id, name, description, address, lat, lng, memo, will_visit, category, images, links
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
[
|
||||
activityId,
|
||||
place.name,
|
||||
place.description || null,
|
||||
place.address || null,
|
||||
place.coordinates?.lat || null,
|
||||
place.coordinates?.lng || null,
|
||||
place.memo || null,
|
||||
place.willVisit || false,
|
||||
place.category || 'other',
|
||||
place.images || null,
|
||||
place.links || null
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await query('COMMIT');
|
||||
res.json({ success: true, id: planId });
|
||||
} catch (error) {
|
||||
await query('ROLLBACK');
|
||||
console.error('Error saving travel plan:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
88
server/routes/uploads.js
Normal file
88
server/routes/uploads.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// uploads 디렉토리 생성
|
||||
const uploadsDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Multer 설정: 파일 저장 위치와 파일명 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadsDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
|
||||
}
|
||||
});
|
||||
|
||||
// 파일 필터: 이미지만 허용
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('이미지 파일만 업로드 가능합니다 (jpeg, jpg, png, gif, webp)'));
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB 제한
|
||||
},
|
||||
fileFilter: fileFilter
|
||||
});
|
||||
|
||||
// 다중 이미지 업로드 (최대 5개)
|
||||
router.post('/', upload.array('images', 5), (req, res) => {
|
||||
try {
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
|
||||
}
|
||||
|
||||
// 업로드된 파일의 URL 배열 생성
|
||||
const fileUrls = req.files.map(file => `/uploads/${file.filename}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
files: fileUrls,
|
||||
message: `${req.files.length}개의 파일이 업로드되었습니다`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 단일 이미지 업로드
|
||||
router.post('/single', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: '파일이 업로드되지 않았습니다' });
|
||||
}
|
||||
|
||||
const fileUrl = `/uploads/${req.file.filename}`;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
file: fileUrl,
|
||||
message: '파일이 업로드되었습니다'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
66
server/schema.sql
Normal file
66
server/schema.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- 여행 계획 테이블
|
||||
CREATE TABLE IF NOT EXISTS travel_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 날짜별 일정 테이블
|
||||
CREATE TABLE IF NOT EXISTS day_schedules (
|
||||
id SERIAL PRIMARY KEY,
|
||||
travel_plan_id INTEGER REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
schedule_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 활동 테이블
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id SERIAL PRIMARY KEY,
|
||||
day_schedule_id INTEGER REFERENCES day_schedules(id) ON DELETE CASCADE,
|
||||
time VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
images TEXT[],
|
||||
links TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 관련 장소 테이블
|
||||
CREATE TABLE IF NOT EXISTS related_places (
|
||||
id SERIAL PRIMARY KEY,
|
||||
activity_id INTEGER REFERENCES activities(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
address VARCHAR(255),
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
memo TEXT,
|
||||
will_visit BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other',
|
||||
images TEXT[],
|
||||
links TEXT[],
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기본 포인트 테이블
|
||||
CREATE TABLE IF NOT EXISTS base_points (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL,
|
||||
lat DECIMAL(10, 7) NOT NULL,
|
||||
lng DECIMAL(10, 7) NOT NULL,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_day_schedules_plan ON day_schedules(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_schedule ON activities(day_schedule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_related_places_activity ON related_places(activity_id);
|
||||
226
server/schema_v2.sql
Normal file
226
server/schema_v2.sql
Normal file
@@ -0,0 +1,226 @@
|
||||
-- Travel Planner v2.0 Database Schema
|
||||
-- 멀티 사용자 및 여행 관리 시스템
|
||||
|
||||
-- 사용자 테이블
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login TIMESTAMP,
|
||||
created_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- 여행 계획 테이블 (확장)
|
||||
CREATE TABLE IF NOT EXISTS travel_plans (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
|
||||
-- 목적지 정보
|
||||
destination_country VARCHAR(100) NOT NULL,
|
||||
destination_city VARCHAR(100) NOT NULL,
|
||||
destination_region VARCHAR(100),
|
||||
destination_lat DECIMAL(10, 7),
|
||||
destination_lng DECIMAL(10, 7),
|
||||
|
||||
-- 날짜
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
|
||||
-- 메타데이터
|
||||
is_public BOOLEAN DEFAULT false,
|
||||
is_template BOOLEAN DEFAULT false,
|
||||
template_category VARCHAR(20) CHECK (template_category IN ('japan', 'korea', 'asia', 'europe', 'america', 'other')),
|
||||
tags TEXT[] DEFAULT '{}',
|
||||
thumbnail VARCHAR(500),
|
||||
status VARCHAR(20) DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'completed', 'cancelled')),
|
||||
|
||||
-- 타임스탬프
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 날짜별 일정 테이블
|
||||
CREATE TABLE IF NOT EXISTS day_schedules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
schedule_date DATE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 활동 테이블 (확장)
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
day_schedule_id UUID NOT NULL REFERENCES day_schedules(id) ON DELETE CASCADE,
|
||||
time VARCHAR(10) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
location VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('attraction', 'food', 'accommodation', 'transport', 'other')),
|
||||
|
||||
-- 좌표
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
|
||||
-- 미디어
|
||||
images TEXT[] DEFAULT '{}',
|
||||
links JSONB DEFAULT '[]',
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 관련 장소 테이블
|
||||
CREATE TABLE IF NOT EXISTS related_places (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
activity_id UUID NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
address VARCHAR(255),
|
||||
lat DECIMAL(10, 7),
|
||||
lng DECIMAL(10, 7),
|
||||
memo TEXT,
|
||||
will_visit BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other' CHECK (category IN ('restaurant', 'attraction', 'shopping', 'accommodation', 'other')),
|
||||
images TEXT[] DEFAULT '{}',
|
||||
links JSONB DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 기본 포인트 테이블
|
||||
CREATE TABLE IF NOT EXISTS base_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255),
|
||||
type VARCHAR(50) NOT NULL CHECK (type IN ('accommodation', 'airport', 'station', 'parking', 'other')),
|
||||
lat DECIMAL(10, 7) NOT NULL,
|
||||
lng DECIMAL(10, 7) NOT NULL,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 예산 테이블
|
||||
CREATE TABLE IF NOT EXISTS budgets (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
total_amount DECIMAL(12, 2) DEFAULT 0,
|
||||
accommodation DECIMAL(12, 2) DEFAULT 0,
|
||||
food DECIMAL(12, 2) DEFAULT 0,
|
||||
transportation DECIMAL(12, 2) DEFAULT 0,
|
||||
shopping DECIMAL(12, 2) DEFAULT 0,
|
||||
activities DECIMAL(12, 2) DEFAULT 0,
|
||||
currency VARCHAR(3) DEFAULT 'KRW',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 체크리스트 테이블
|
||||
CREATE TABLE IF NOT EXISTS checklist_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
travel_plan_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
text VARCHAR(500) NOT NULL,
|
||||
checked BOOLEAN DEFAULT false,
|
||||
category VARCHAR(50) DEFAULT 'other' CHECK (category IN ('preparation', 'shopping', 'visit', 'other')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 공유 링크 테이블
|
||||
CREATE TABLE IF NOT EXISTS share_links (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trip_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
share_code VARCHAR(8) UNIQUE NOT NULL,
|
||||
expires_at TIMESTAMP,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
access_count INTEGER DEFAULT 0,
|
||||
max_access_count INTEGER,
|
||||
|
||||
-- 권한
|
||||
can_view BOOLEAN DEFAULT true,
|
||||
can_edit BOOLEAN DEFAULT false,
|
||||
can_comment BOOLEAN DEFAULT false,
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_accessed TIMESTAMP
|
||||
);
|
||||
|
||||
-- 댓글 테이블
|
||||
CREATE TABLE IF NOT EXISTS trip_comments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
trip_id UUID NOT NULL REFERENCES travel_plans(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
content TEXT NOT NULL,
|
||||
is_edited BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 시스템 설정 테이블
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_user ON travel_plans(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_status ON travel_plans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_category ON travel_plans(template_category);
|
||||
CREATE INDEX IF NOT EXISTS idx_travel_plans_public ON travel_plans(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_day_schedules_plan ON day_schedules(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_schedule ON activities(day_schedule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_related_places_activity ON related_places(activity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_base_points_user ON base_points(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_budgets_plan ON budgets(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_checklist_plan ON checklist_items(travel_plan_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_code ON share_links(share_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_share_links_trip ON share_links(trip_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_trip ON trip_comments(trip_id);
|
||||
|
||||
-- 초기 시스템 설정 데이터
|
||||
INSERT INTO system_settings (key, value, description) VALUES
|
||||
('app_version', '2.0.0', '애플리케이션 버전'),
|
||||
('setup_completed', 'false', '초기 설정 완료 여부'),
|
||||
('jwt_secret_set', 'false', 'JWT 시크릿 설정 여부'),
|
||||
('google_maps_configured', 'false', 'Google Maps API 설정 여부'),
|
||||
('email_configured', 'false', '이메일 설정 여부')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- 업데이트 트리거 함수
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
-- 업데이트 트리거 적용
|
||||
DROP TRIGGER IF EXISTS update_users_updated_at ON users;
|
||||
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_travel_plans_updated_at ON travel_plans;
|
||||
CREATE TRIGGER update_travel_plans_updated_at BEFORE UPDATE ON travel_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_budgets_updated_at ON budgets;
|
||||
CREATE TRIGGER update_budgets_updated_at BEFORE UPDATE ON budgets FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_checklist_updated_at ON checklist_items;
|
||||
CREATE TRIGGER update_checklist_updated_at BEFORE UPDATE ON checklist_items FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_comments_updated_at ON trip_comments;
|
||||
CREATE TRIGGER update_comments_updated_at BEFORE UPDATE ON trip_comments FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_settings_updated_at ON system_settings;
|
||||
CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON system_settings FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
66
server/server.js
Normal file
66
server/server.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const { initializeDatabase } = require('./db');
|
||||
const { runMigrations } = require('./migrate');
|
||||
const travelPlanRoutes = require('./routes/travelPlans');
|
||||
const basePointsRoutes = require('./routes/basePoints');
|
||||
const uploadsRoutes = require('./routes/uploads');
|
||||
const { router: authRoutes } = require('./routes/auth');
|
||||
const setupRoutes = require('./routes/setup');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// 미들웨어
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 정적 파일 제공 (업로드된 이미지)
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// 로그 미들웨어
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 라우트
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/travel-plans', travelPlanRoutes);
|
||||
app.use('/api/base-points', basePointsRoutes);
|
||||
app.use('/api/uploads', uploadsRoutes);
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// 에러 핸들러
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
});
|
||||
|
||||
// 서버 시작
|
||||
async function startServer() {
|
||||
try {
|
||||
// 데이터베이스 초기화 (기존 스키마)
|
||||
await initializeDatabase();
|
||||
|
||||
// 마이그레이션 실행 (새로운 기능 추가)
|
||||
await runMigrations();
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🚀 Server running on port ${PORT}`);
|
||||
console.log(`📊 API available at http://localhost:${PORT}`);
|
||||
console.log(`🗄️ Database: Using existing kumamoto_map database`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
BIN
server/uploads/images-1762666367693-99912431.webp
Normal file
BIN
server/uploads/images-1762666367693-99912431.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
server/uploads/images-1762666492636-867051147.jpg
Normal file
BIN
server/uploads/images-1762666492636-867051147.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
378
src/App.tsx
378
src/App.tsx
@@ -1,144 +1,252 @@
|
||||
import { useState } from 'react'
|
||||
import Header from './components/Header'
|
||||
import Timeline from './components/Timeline'
|
||||
import Attractions, { attractions } from './components/Attractions'
|
||||
import Map from './components/Map'
|
||||
import Budget from './components/Budget'
|
||||
import Checklist from './components/Checklist'
|
||||
import { TravelPlan } from './types'
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import PlanPage from './pages/PlanPage'
|
||||
import TripPage from './pages/TripPage'
|
||||
import InitialSetup from './components/InitialSetup'
|
||||
import AuthForm from './components/AuthForm'
|
||||
import AdminDashboard from './components/AdminDashboard'
|
||||
import UserDashboard from './components/UserDashboard'
|
||||
import SharedTripViewer from './components/SharedTripViewer'
|
||||
import DebugInfo from './components/DebugInfo'
|
||||
import { tripManagerService } from './services/tripManager'
|
||||
import { defaultTravelPlan } from './constants/defaultData'
|
||||
import { initialSetupService, SetupUtils } from './services/initialSetup'
|
||||
import { userAuthService } from './services/userAuth'
|
||||
|
||||
function App() {
|
||||
const [travelPlan, setTravelPlan] = useState<TravelPlan>({
|
||||
startDate: new Date('2025-02-17'),
|
||||
endDate: new Date('2025-02-20'),
|
||||
schedule: [
|
||||
{
|
||||
date: new Date('2025-02-18'),
|
||||
activities: [
|
||||
{
|
||||
id: '1',
|
||||
time: '09:00',
|
||||
title: '렌트카 픽업',
|
||||
description: '렌트카로 이동',
|
||||
type: 'transport',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
time: '10:00',
|
||||
title: '기쿠치협곡',
|
||||
description: '렌트카로 이동',
|
||||
location: '기쿠치시',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
time: '12:00',
|
||||
title: '쿠사센리',
|
||||
description: '렌트카로 이동',
|
||||
location: '아소시',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
time: '14:00',
|
||||
title: '아소산',
|
||||
description: '렌트카로 이동',
|
||||
location: '아소시',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
time: '16:00',
|
||||
title: '사라카와수원',
|
||||
description: '렌트카로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: new Date('2025-02-19'),
|
||||
activities: [
|
||||
{
|
||||
id: '6',
|
||||
time: '09:00',
|
||||
title: '구마모토성',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시 주오구',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
time: '11:30',
|
||||
title: '사쿠라노바바',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
time: '14:00',
|
||||
title: '스이젠지조주엔',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시 주오구',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
time: '16:00',
|
||||
title: '시모토리아케이드',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0,
|
||||
},
|
||||
checklist: [],
|
||||
})
|
||||
// 메인 대시보드 컴포넌트
|
||||
function Home() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<UserDashboard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 레거시 라우트 처리 컴포넌트
|
||||
function LegacyTripRedirect() {
|
||||
const navigate = useNavigate()
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const createLegacyTrip = async () => {
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
// 기존 구마모토 여행이 있는지 확인
|
||||
const userTrips = await tripManagerService.getUserTrips({ search: '구마모토' })
|
||||
|
||||
let legacyTrip = userTrips.trips.find(trip =>
|
||||
trip.title.includes('구마모토') || trip.destination.city === '구마모토'
|
||||
)
|
||||
|
||||
// 없으면 새로 생성
|
||||
if (!legacyTrip) {
|
||||
const result = await tripManagerService.createTrip({
|
||||
title: defaultTravelPlan.title,
|
||||
description: defaultTravelPlan.description,
|
||||
destination: defaultTravelPlan.destination,
|
||||
startDate: defaultTravelPlan.startDate,
|
||||
endDate: defaultTravelPlan.endDate,
|
||||
template_category: defaultTravelPlan.template_category,
|
||||
tags: defaultTravelPlan.tags,
|
||||
is_public: defaultTravelPlan.is_public
|
||||
})
|
||||
|
||||
if (result.success && result.trip) {
|
||||
// 기본 일정도 추가
|
||||
await tripManagerService.updateTrip(result.trip.id, {
|
||||
...result.trip,
|
||||
schedule: defaultTravelPlan.schedule,
|
||||
budget: defaultTravelPlan.budget,
|
||||
checklist: defaultTravelPlan.checklist
|
||||
})
|
||||
legacyTrip = result.trip
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 모드로 리다이렉트
|
||||
if (legacyTrip) {
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath.includes('/plan')) {
|
||||
navigate(`/plan/${legacyTrip.id}`)
|
||||
} else {
|
||||
navigate(`/trip/${legacyTrip.id}`)
|
||||
}
|
||||
} else {
|
||||
// 생성 실패시 메인으로
|
||||
navigate('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create legacy trip:', error)
|
||||
navigate('/')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
createLegacyTrip()
|
||||
}, [navigate])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-kumamoto-light">
|
||||
<Header />
|
||||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<div className="mb-8 relative">
|
||||
<div className="relative rounded-lg overflow-hidden mb-4 h-64">
|
||||
<img
|
||||
src="https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=1920&q=80"
|
||||
alt="구마모토 풍경"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 text-white">
|
||||
<h1 className="text-4xl font-bold mb-2">구마모토 여행 계획</h1>
|
||||
<p className="text-lg opacity-90">
|
||||
2025년 2월 17일 ~ 2월 20일 (4일간)
|
||||
</p>
|
||||
<div className="min-h-screen bg-kumamoto-light flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-kumamoto-primary mx-auto mb-4"></div>
|
||||
<div className="text-lg text-gray-600 mb-2">
|
||||
{isCreating ? '구마모토 여행 계획을 준비하는 중...' : '페이지를 로딩 중...'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
기존 여행 계획을 새로운 형태로 변환하고 있습니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isSetupRequired, setIsSetupRequired] = useState<boolean | null>(null)
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// 디버그 모드 (URL에 ?debug=true가 있으면 활성화)
|
||||
const showDebug = new URLSearchParams(window.location.search).get('debug') === 'true'
|
||||
|
||||
useEffect(() => {
|
||||
checkAppStatus()
|
||||
}, [])
|
||||
|
||||
const checkAppStatus = async () => {
|
||||
try {
|
||||
// 1. 초기 설정 필요 여부 확인
|
||||
const setupRequired = await SetupUtils.isSetupRequired()
|
||||
setIsSetupRequired(setupRequired)
|
||||
|
||||
if (!setupRequired) {
|
||||
// 2. 사용자 인증 상태 확인
|
||||
const authenticated = userAuthService.isAuthenticated()
|
||||
setIsAuthenticated(authenticated)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('App status check failed:', error)
|
||||
setIsSetupRequired(true) // 오류 시 초기 설정으로 안내
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetupComplete = () => {
|
||||
setIsSetupRequired(false)
|
||||
checkAppStatus() // 상태 재확인
|
||||
}
|
||||
|
||||
const handleAuthSuccess = () => {
|
||||
setIsAuthenticated(true)
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await userAuthService.logout()
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
// 로딩 중
|
||||
// 디버그 모드
|
||||
if (showDebug) {
|
||||
return <DebugInfo />
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-lg font-medium text-gray-800">시스템 초기화 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 초기 설정 필요
|
||||
if (isSetupRequired) {
|
||||
return <InitialSetup onSetupComplete={handleSetupComplete} />
|
||||
}
|
||||
|
||||
// 로그인 필요
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<AuthForm onSuccess={handleAuthSuccess} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 인증된 사용자 - 메인 앱
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-kumamoto-light">
|
||||
{/* 상단 네비게이션 */}
|
||||
<nav className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
🗺️ Travel Planner
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">
|
||||
{userAuthService.getCurrentUser()?.name}님
|
||||
{userAuthService.isAdmin() && (
|
||||
<span className="ml-2 px-2 py-1 bg-purple-100 text-purple-800 text-xs rounded">
|
||||
관리자
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{userAuthService.isAdmin() && (
|
||||
<a
|
||||
href="/admin"
|
||||
className="text-sm text-purple-600 hover:text-purple-800"
|
||||
>
|
||||
관리자 페이지
|
||||
</a>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Map attractions={attractions} />
|
||||
<Timeline travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
|
||||
<Attractions />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Budget travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
|
||||
<Checklist travelPlan={travelPlan} setTravelPlan={setTravelPlan} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/* 메인 콘텐츠 */}
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/plan/:tripId" element={<PlanPage />} />
|
||||
<Route path="/trip/:tripId" element={<TripPage />} />
|
||||
<Route path="/shared/:shareCode" element={<SharedTripViewer />} />
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
userAuthService.isAdmin() ? (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<AdminDashboard />
|
||||
</div>
|
||||
) : (
|
||||
<Navigate to="/" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{/* 레거시 라우트 (기존 구마모토 여행) - 테스트용 자동 생성 */}
|
||||
<Route path="/plan" element={<LegacyTripRedirect />} />
|
||||
<Route path="/trip" element={<LegacyTripRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
208
src/components/ActivityEditor.tsx
Normal file
208
src/components/ActivityEditor.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState } from 'react'
|
||||
import { Activity, RelatedPlace } from '../types'
|
||||
import RelatedPlacesManager from './RelatedPlacesManager'
|
||||
import LocationSelector from './forms/LocationSelector'
|
||||
import ImageUploadField from './forms/ImageUploadField'
|
||||
import LinkManagement from './forms/LinkManagement'
|
||||
|
||||
interface ActivityEditorProps {
|
||||
activity: Activity
|
||||
onUpdate: (activity: Activity) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ActivityEditor = ({ activity, onUpdate, onClose }: ActivityEditorProps) => {
|
||||
const [formData, setFormData] = useState<Activity>(activity)
|
||||
const [showRelatedPlacesManager, setShowRelatedPlacesManager] = useState(false)
|
||||
|
||||
const updateRelatedPlaces = (relatedPlaces: RelatedPlace[]) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
relatedPlaces
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.title || !formData.time) {
|
||||
alert('제목과 시간은 필수입니다.')
|
||||
return
|
||||
}
|
||||
|
||||
// 좌표가 0,0이면 제거
|
||||
const updatedActivity = {
|
||||
...formData,
|
||||
coordinates: formData.coordinates &&
|
||||
formData.coordinates.lat !== 0 &&
|
||||
formData.coordinates.lng !== 0
|
||||
? formData.coordinates
|
||||
: undefined
|
||||
}
|
||||
|
||||
onUpdate(updatedActivity)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold text-gray-800">일정 편집</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 시간 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
시간 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.time}
|
||||
onChange={(e) => setFormData({ ...formData, time: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
제목 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="일정 제목"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">설명</label>
|
||||
<textarea
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="설명 (선택)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 장소 및 위치 정보 - LocationSelector 사용 */}
|
||||
<div className="border border-gray-300 rounded p-4 bg-gray-50">
|
||||
<LocationSelector
|
||||
location={formData.location}
|
||||
coordinates={formData.coordinates}
|
||||
onLocationChange={(location) => setFormData({ ...formData, location })}
|
||||
onCoordinatesChange={(coordinates) => setFormData({ ...formData, coordinates: coordinates || undefined })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 분류 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as Activity['type'] })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="attraction">관광지</option>
|
||||
<option value="food">식사</option>
|
||||
<option value="accommodation">숙소</option>
|
||||
<option value="transport">교통</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 주변 관광지 관리 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
주변 관광지 / 세부 관광지
|
||||
</label>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formData.relatedPlaces?.length || 0}개 등록됨
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRelatedPlacesManager(true)}
|
||||
className="w-full px-4 py-3 bg-gradient-to-r from-blue-50 to-indigo-50 border-2 border-blue-200 text-blue-700 rounded-lg hover:from-blue-100 hover:to-indigo-100 transition-all flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🏞️</span>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">주변 관광지 관리</div>
|
||||
<div className="text-xs text-blue-600">
|
||||
세부 관광지 추가/수정 및 가볼 곳 체크
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-blue-500">→</span>
|
||||
</button>
|
||||
{formData.relatedPlaces && formData.relatedPlaces.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-600">
|
||||
{formData.relatedPlaces.filter(p => p.willVisit).length}개 가볼 곳으로 선택됨
|
||||
{formData.relatedPlaces.filter(p => p.willVisit).length > 0 && (
|
||||
<span className="text-green-600 ml-1">
|
||||
(Trip 페이지에 표시됨)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이미지 업로드 - ImageUploadField 사용 */}
|
||||
<div className="border-t pt-4">
|
||||
<ImageUploadField
|
||||
images={formData.images || []}
|
||||
onChange={(images) => setFormData({ ...formData, images })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 링크 추가 - LinkManagement 사용 */}
|
||||
<div className="border-t pt-4">
|
||||
<LinkManagement
|
||||
links={formData.links || []}
|
||||
onChange={(links) => setFormData({ ...formData, links })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 p-4 flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex-1 px-4 py-2 bg-kumamoto-blue text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주변 관광지 관리 모달 */}
|
||||
{showRelatedPlacesManager && (
|
||||
<RelatedPlacesManager
|
||||
activity={formData}
|
||||
onUpdate={(_, relatedPlaces) => updateRelatedPlaces(relatedPlaces)}
|
||||
onClose={() => setShowRelatedPlacesManager(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActivityEditor
|
||||
510
src/components/AdminDashboard.tsx
Normal file
510
src/components/AdminDashboard.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userAuthService, User } from '../services/userAuth'
|
||||
|
||||
interface AdminDashboardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AdminDashboard = ({ className = '' }: AdminDashboardProps) => {
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null)
|
||||
const [stats, setStats] = useState({
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
adminUsers: 0,
|
||||
recentLogins: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers()
|
||||
}, [])
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const allUsers = await userAuthService.getAllUsers()
|
||||
setUsers(allUsers)
|
||||
calculateStats(allUsers)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '사용자 목록을 불러올 수 없습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateStats = (userList: User[]) => {
|
||||
const now = new Date()
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
|
||||
setStats({
|
||||
totalUsers: userList.length,
|
||||
activeUsers: userList.filter(u => u.is_active).length,
|
||||
adminUsers: userList.filter(u => u.role === 'admin').length,
|
||||
recentLogins: userList.filter(u =>
|
||||
u.last_login && new Date(u.last_login) > oneDayAgo
|
||||
).length
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleUserStatus = async (userId: string) => {
|
||||
try {
|
||||
const result = await userAuthService.toggleUserStatus(userId)
|
||||
if (result.success) {
|
||||
await loadUsers()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('사용자 상태 변경 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteUser = async (userId: string, userName: string) => {
|
||||
if (!confirm(`"${userName}" 사용자를 정말 삭제하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await userAuthService.deleteUser(userId)
|
||||
if (result.success) {
|
||||
await loadUsers()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('사용자 삭제 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getRoleDisplay = (role: string) => {
|
||||
return role === 'admin' ? '👨💼 관리자' : '👤 사용자'
|
||||
}
|
||||
|
||||
const getStatusDisplay = (isActive: boolean) => {
|
||||
return isActive ? '✅ 활성' : '❌ 비활성'
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-3"></div>
|
||||
<span>사용자 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-4xl mb-4">⚠️</div>
|
||||
<div className="text-red-700">{error}</div>
|
||||
<button
|
||||
onClick={loadUsers}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-800">👨💼 관리자 대시보드</h2>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
➕ 사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-800">{stats.totalUsers}</div>
|
||||
<div className="text-sm text-blue-600">전체 사용자</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-800">{stats.activeUsers}</div>
|
||||
<div className="text-sm text-green-600">활성 사용자</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg border border-purple-200">
|
||||
<div className="text-2xl font-bold text-purple-800">{stats.adminUsers}</div>
|
||||
<div className="text-sm text-purple-600">관리자</div>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-4 rounded-lg border border-orange-200">
|
||||
<div className="text-2xl font-bold text-orange-800">{stats.recentLogins}</div>
|
||||
<div className="text-sm text-orange-600">24시간 내 로그인</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용자 목록 */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">사용자 목록</h3>
|
||||
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<div className="text-4xl mb-4">👥</div>
|
||||
<div>등록된 사용자가 없습니다</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">사용자</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">권한</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">상태</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">가입일</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">마지막 로그인</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-gray-700">작업</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt={user.name} className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
<div className="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center text-sm">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{user.name}</div>
|
||||
<div className="text-sm text-gray-600">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-purple-100 text-purple-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{getRoleDisplay(user.role)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{getStatusDisplay(user.is_active)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{formatDate(user.created_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-600">
|
||||
{formatDate(user.last_login)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggleUserStatus(user.id)}
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
user.is_active
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
}`}
|
||||
title={user.is_active ? '비활성화' : '활성화'}
|
||||
>
|
||||
{user.is_active ? '🔒' : '🔓'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
title="수정"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
{user.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id, user.name)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
title="삭제"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 사용자 생성 모달 */}
|
||||
{showCreateForm && (
|
||||
<CreateUserModal
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false)
|
||||
loadUsers()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 사용자 수정 모달 */}
|
||||
{selectedUser && (
|
||||
<EditUserModal
|
||||
user={selectedUser}
|
||||
onClose={() => setSelectedUser(null)}
|
||||
onSuccess={() => {
|
||||
setSelectedUser(null)
|
||||
loadUsers()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 사용자 생성 모달
|
||||
const CreateUserModal = ({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
name: '',
|
||||
role: 'user' as 'admin' | 'user'
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await userAuthService.createUser(formData)
|
||||
if (result.success) {
|
||||
alert(result.message)
|
||||
onSuccess()
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('사용자 생성 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">새 사용자 추가</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">권한</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="user">👤 사용자</option>
|
||||
<option value="admin">👨💼 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '생성 중...' : '생성'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 사용자 수정 모달
|
||||
const EditUserModal = ({
|
||||
user,
|
||||
onClose,
|
||||
onSuccess
|
||||
}: {
|
||||
user: User;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
is_active: user.is_active
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await userAuthService.updateUser(user.id, formData)
|
||||
if (result.success) {
|
||||
alert(result.message)
|
||||
onSuccess()
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('사용자 정보 수정 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold mb-4">사용자 정보 수정</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||||
<input
|
||||
type="email"
|
||||
value={user.email}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded bg-gray-100 text-gray-500"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">이메일은 수정할 수 없습니다</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">권한</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'admin' | 'user' })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="user">👤 사용자</option>
|
||||
<option value="admin">👨💼 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">계정 활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '수정 중...' : '수정'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdminDashboard
|
||||
@@ -6,10 +6,10 @@ const attractions: Attraction[] = [
|
||||
name: 'Kumamoto Castle',
|
||||
nameKo: '구마모토성',
|
||||
description:
|
||||
'일본 3대 명성 중 하나로, 400년의 역사를 가진 웅장한 성입니다. 2016년 지진으로 일부 손상되었지만 복구 작업이 진행 중입니다.',
|
||||
'1607년 가토 기요마사에 의해 축성된 일본 3대 명성 중 하나. 정교한 돌담과 웅장한 성곽으로 유명하며, 2016년 지진 피해 복구 작업이 진행 중입니다. 천수각과 우토야구라에서 구마모토 시내를 한눈에 볼 수 있습니다.',
|
||||
location: '구마모토시 주오구 혼마루 1-1',
|
||||
estimatedTime: '2-3시간',
|
||||
admissionFee: 500,
|
||||
admissionFee: 800,
|
||||
category: 'castle',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=800&q=80',
|
||||
coordinates: { lat: 32.8061, lng: 130.7058 },
|
||||
@@ -19,8 +19,8 @@ const attractions: Attraction[] = [
|
||||
name: 'Mount Aso',
|
||||
nameKo: '아소산',
|
||||
description:
|
||||
'세계 최대 규모의 칼데라를 가진 활화산입니다. 아소산 로프웨이를 타고 분화구를 관람할 수 있습니다.',
|
||||
location: '아소시',
|
||||
'세계 최대 규모의 칼데라(남북 25km, 동서 18km)를 가진 활화산. 나카다케 분화구에서는 에메랄드빛 화구호를 볼 수 있으며, 로프웨이나 셔틀버스로 접근 가능합니다. 화산 활동 상황에 따라 접근이 제한될 수 있습니다.',
|
||||
location: '아소시 나카다케',
|
||||
estimatedTime: '반나절',
|
||||
admissionFee: 1200,
|
||||
category: 'nature',
|
||||
@@ -32,18 +32,20 @@ const attractions: Attraction[] = [
|
||||
name: 'Kurokawa Onsen',
|
||||
nameKo: '구로카와 온천',
|
||||
description:
|
||||
'일본에서 가장 아름다운 온천 마을 중 하나로, 전통적인 일본 풍경을 즐길 수 있습니다.',
|
||||
location: '아소군 미나미아소무라',
|
||||
'미슐랭 가이드 별 2개를 받은 전통 온천 마을. 30여 개의 료칸이 모여 있으며, "입욕 수첩(1,300엔)"으로 3곳의 노천탕을 체험할 수 있습니다. 자연에 둘러싸인 정취 있는 온천가로 유명합니다.',
|
||||
location: '아소군 미나미오구니마치 만간지',
|
||||
estimatedTime: '하루',
|
||||
admissionFee: 1300,
|
||||
category: 'onsen',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?w=800&q=80',
|
||||
coordinates: { lat: 33.0667, lng: 131.1167 },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Suizenji Jojuen Garden',
|
||||
nameKo: '수이젠지 조주엔',
|
||||
description:
|
||||
'400년 전통의 정원으로, 일본의 미니어처 풍경을 재현한 아름다운 정원입니다.',
|
||||
'1636년 호소카와 가문이 조성한 회유식 일본정원. 도카이도 53차의 풍경을 축소 재현했으며, 특히 후지산을 모방한 인공 언덕이 유명합니다. 정원 내 이즈미 신사에서는 장수의 물을 마실 수 있습니다.',
|
||||
location: '구마모토시 주오구 수이젠지 공원 8-1',
|
||||
estimatedTime: '1-2시간',
|
||||
admissionFee: 400,
|
||||
@@ -56,30 +58,32 @@ const attractions: Attraction[] = [
|
||||
name: 'Amakusa',
|
||||
nameKo: '아마쿠사',
|
||||
description:
|
||||
'크리스천 다이묘의 역사가 있는 아름다운 섬들로, 바다와 교회가 어우러진 풍경을 즐길 수 있습니다.',
|
||||
'크리스천 다이문의 역사가 있는 아름다운 섬들로, 바다와 교회가 어우러진 풍경을 즐길 수 있습니다.',
|
||||
location: '아마쿠사시',
|
||||
estimatedTime: '하루',
|
||||
category: 'other',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=800&q=80',
|
||||
coordinates: { lat: 32.4833, lng: 130.1833 },
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Kumamoto Ramen',
|
||||
nameKo: '구마모토 라멘',
|
||||
description:
|
||||
'돼지뼈 육수에 마늘 기름을 넣은 구마모토 특유의 라멘을 맛볼 수 있습니다.',
|
||||
'돼지뼈를 우린 진한 돈코츠 육수에 마늘 기름(마유)과 볶은 마늘을 올린 구마모토 특유의 라멘. 면은 중간 굵기의 스트레이트면을 사용하며, 진한 국물과 고소한 마늘향이 특징입니다. 구마모토 시내 곳곳에서 맛볼 수 있습니다.',
|
||||
location: '구마모토시 전역',
|
||||
estimatedTime: '1시간',
|
||||
category: 'food',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=800&q=80',
|
||||
coordinates: { lat: 32.8031, lng: 130.7079 },
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: 'Kikuchi Gorge',
|
||||
nameKo: '기쿠치협곡',
|
||||
description:
|
||||
'아름다운 계곡과 폭포가 있는 자연 명소로, 특히 가을 단풍이 유명합니다.',
|
||||
location: '기쿠치시',
|
||||
'기쿠치강 상류에 위치한 길이 4km의 아름다운 계곡. 맑은 물과 기암괴석, 울창한 숲이 어우러져 사계절 내내 아름다운 풍경을 자랑합니다. 특히 가을 단풍이 유명하며, 산책로를 따라 여러 폭포를 감상할 수 있습니다.',
|
||||
location: '기쿠치시 오리노오 1208',
|
||||
estimatedTime: '1-2시간',
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
|
||||
@@ -90,8 +94,8 @@ const attractions: Attraction[] = [
|
||||
name: 'Kusasenri',
|
||||
nameKo: '쿠사센리',
|
||||
description:
|
||||
'아소산 중턱에 있는 넓은 초원으로, 말 타기와 하이킹을 즐길 수 있습니다.',
|
||||
location: '아소시',
|
||||
'아소산 중턱 해발 1,140m에 위치한 직경 1km의 광활한 초원. 중앙에 작은 연못이 있고, 배경으로 보이는 나카다케 분화구와 함께 아소산의 대표적인 풍경을 이룹니다. 승마 체험과 산책을 즐길 수 있으며, 아소 화산 박물관도 인근에 있습니다.',
|
||||
location: '아소시 아카미즈',
|
||||
estimatedTime: '1-2시간',
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80',
|
||||
@@ -107,7 +111,7 @@ const attractions: Attraction[] = [
|
||||
estimatedTime: '30분-1시간',
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
|
||||
coordinates: { lat: 32.8031, lng: 130.7079 },
|
||||
coordinates: { lat: 32.885833, lng: 130.879444 },
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
@@ -119,7 +123,7 @@ const attractions: Attraction[] = [
|
||||
estimatedTime: '1시간',
|
||||
category: 'other',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1522383225653-1113756be3d8?w=800&q=80',
|
||||
coordinates: { lat: 32.8031, lng: 130.7079 },
|
||||
coordinates: { lat: 32.807, lng: 130.706 },
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
@@ -133,6 +137,67 @@ const attractions: Attraction[] = [
|
||||
imageUrl: 'https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=800&q=80',
|
||||
coordinates: { lat: 32.8031, lng: 130.7079 },
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
name: 'Shirakawa Suigen',
|
||||
nameKo: '시라카와 수원',
|
||||
description:
|
||||
'매분 60톤의 깨끗한 샘물이 솟아나는 남아소의 대표적인 수원지. 환경성 명수백선에 선정되었으며, 수온은 연중 14도로 일정합니다. 방문객들은 자유롭게 물을 마시거나 가져갈 수 있습니다.',
|
||||
location: '아소군 미나미아소무라 시라카와',
|
||||
estimatedTime: '30분-1시간',
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
|
||||
coordinates: { lat: 32.8267, lng: 131.0389 },
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
name: 'Takachiho Gorge',
|
||||
nameKo: '다카치호 협곡',
|
||||
description:
|
||||
'아소산의 화산 활동으로 형성된 절경의 협곡. 깊이 100m의 절벽과 에메랄드 그린의 강물이 어우러진 신비로운 곳입니다. 보트를 타고 마나이 폭포를 가까이에서 감상할 수 있습니다.',
|
||||
location: '미야자키현 다카치호초',
|
||||
estimatedTime: '2-3시간',
|
||||
admissionFee: 300,
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80',
|
||||
coordinates: { lat: 32.7131, lng: 131.3142 },
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
name: 'Nabegatataki Falls',
|
||||
nameKo: '나베가타탄키 폭포',
|
||||
description:
|
||||
'낙차 10m, 너비 20m의 웅장한 폭포로 물이 커튼처럼 떨어지는 모습이 인상적입니다. 폭포 뒤쪽에서 조명을 켜는 이벤트도 개최되며, 햇살이 비추면 신비로운 풍경을 연출합니다.',
|
||||
location: '오기군 오기마치',
|
||||
estimatedTime: '1-2시간',
|
||||
category: 'nature',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1501594907352-04cda38ebc29?w=800&q=80',
|
||||
coordinates: { lat: 33.2167, lng: 130.8833 },
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
name: 'Sakuranobaba Josaien',
|
||||
nameKo: '사쿠라노바바 조사이엔',
|
||||
description:
|
||||
'구마모토성 바로 아래 위치한 역사 문화 체험 시설. 에도 시대 건물을 재현한 공간에서 구마모토의 역사와 문화를 배울 수 있으며, 전통 공예품 쇼핑과 지역 특산품 맛보기가 가능합니다.',
|
||||
location: '구마모토시 주오구 혼마루 1-1',
|
||||
estimatedTime: '1-2시간',
|
||||
category: 'other',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1522383225653-1113756be3d8?w=800&q=80',
|
||||
coordinates: { lat: 32.8061, lng: 130.7058 },
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
name: 'Kumamon Square',
|
||||
nameKo: '쿠마몬 스퀘어',
|
||||
description:
|
||||
'구마모토의 마스코트 쿠마몬의 공식 샵. 쿠마몬 관련 굿즈를 구매할 수 있고, 운이 좋으면 쿠마몬을 직접 만나볼 수도 있습니다. 아이들과 가족 단위 방문객에게 인기가 높습니다.',
|
||||
location: '구마모토시 주오구 하나바타초 3-35',
|
||||
estimatedTime: '30분-1시간',
|
||||
category: 'other',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1522383225653-1113756be3d8?w=800&q=80',
|
||||
coordinates: { lat: 32.7903, lng: 130.7414 },
|
||||
},
|
||||
]
|
||||
|
||||
export { attractions }
|
||||
|
||||
298
src/components/AuthForm.tsx
Normal file
298
src/components/AuthForm.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { useState } from 'react'
|
||||
import { userAuthService, LoginCredentials, RegisterData } from '../services/userAuth'
|
||||
|
||||
interface AuthFormProps {
|
||||
onSuccess: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AuthForm = ({ onSuccess, className = '' }: AuthFormProps) => {
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [loginData, setLoginData] = useState<LoginCredentials>({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const [registerData, setRegisterData] = useState<RegisterData>({
|
||||
email: '',
|
||||
password: '',
|
||||
name: ''
|
||||
})
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await userAuthService.login(loginData)
|
||||
if (result.success) {
|
||||
onSuccess()
|
||||
} else {
|
||||
setError(result.message || '로그인에 실패했습니다')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('로그인 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await userAuthService.register(registerData)
|
||||
if (result.success) {
|
||||
onSuccess()
|
||||
} else {
|
||||
setError(result.message || '회원가입에 실패했습니다')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('회원가입 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoLogin = async (role: 'admin' | 'user') => {
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
const demoCredentials = {
|
||||
admin: { email: 'admin@travel.com', password: 'admin123' },
|
||||
user: { email: 'demo@travel.com', password: 'demo123' }
|
||||
}
|
||||
|
||||
// 데모 사용자가 없으면 생성
|
||||
if (role === 'user') {
|
||||
try {
|
||||
await userAuthService.register({
|
||||
email: 'demo@travel.com',
|
||||
password: 'demo123',
|
||||
name: '데모 사용자'
|
||||
})
|
||||
} catch (error) {
|
||||
// 이미 존재하는 경우 무시
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await userAuthService.login(demoCredentials[role])
|
||||
if (result.success) {
|
||||
onSuccess()
|
||||
} else {
|
||||
setError(result.message || '데모 로그인에 실패했습니다')
|
||||
}
|
||||
} catch (error) {
|
||||
setError('데모 로그인 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-lg p-8 w-full max-w-md ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl mb-4">🗺️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-2">
|
||||
{mode === 'login' ? '로그인' : '회원가입'}
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
{mode === 'login'
|
||||
? '여행 계획을 관리하려면 로그인하세요'
|
||||
: '새 계정을 만들어 여행 계획을 시작하세요'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<span>⚠️</span>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데모 계정 버튼 */}
|
||||
<div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800 mb-3">🚀 빠른 체험</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDemoLogin('admin')}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-purple-600 text-white text-sm rounded hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
👨💼 관리자로 체험
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDemoLogin('user')}
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
👤 사용자로 체험
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-blue-600 mt-2">
|
||||
• 관리자: 모든 사용자와 여행 일정 관리<br/>
|
||||
• 사용자: 개인 여행 일정 생성 및 관리
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 로그인 폼 */}
|
||||
{mode === 'login' && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={loginData.email}
|
||||
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="example@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
로그인 중...
|
||||
</div>
|
||||
) : (
|
||||
'로그인'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 회원가입 폼 */}
|
||||
{mode === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={registerData.name}
|
||||
onChange={(e) => setRegisterData({ ...registerData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="홍길동"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={registerData.email}
|
||||
onChange={(e) => setRegisterData({ ...registerData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="example@email.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={registerData.password}
|
||||
onChange={(e) => setRegisterData({ ...registerData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="6자 이상 입력하세요"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full py-3 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
가입 중...
|
||||
</div>
|
||||
) : (
|
||||
'회원가입'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 모드 전환 */}
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login')
|
||||
setError(null)
|
||||
}}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
||||
>
|
||||
{mode === 'login'
|
||||
? '계정이 없으신가요? 회원가입'
|
||||
: '이미 계정이 있으신가요? 로그인'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 개발 정보 */}
|
||||
<div className="mt-6 p-3 bg-gray-50 border border-gray-200 rounded-lg">
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="font-medium mb-1">💡 개발 버전 안내</div>
|
||||
<div>• 관리자: admin@travel.com / admin123</div>
|
||||
<div>• 데이터는 브라우저에 저장됩니다</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthForm
|
||||
164
src/components/DebugInfo.tsx
Normal file
164
src/components/DebugInfo.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { userAuthService } from '../services/userAuth'
|
||||
import { initialSetupService } from '../services/initialSetup'
|
||||
|
||||
const DebugInfo = () => {
|
||||
const [debugInfo, setDebugInfo] = useState({
|
||||
isSetupComplete: false,
|
||||
isAuthenticated: false,
|
||||
currentUser: null as any,
|
||||
error: null as string | null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const setupStatus = await initialSetupService.checkSetupStatus()
|
||||
const authStatus = userAuthService.isAuthenticated()
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
|
||||
setDebugInfo({
|
||||
isSetupComplete: setupStatus.isSetupComplete,
|
||||
isAuthenticated: authStatus,
|
||||
currentUser: currentUser,
|
||||
error: null
|
||||
})
|
||||
} catch (error) {
|
||||
setDebugInfo(prev => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
checkStatus()
|
||||
}, [])
|
||||
|
||||
const handleCreateTestUser = async () => {
|
||||
try {
|
||||
// 테스트 관리자 계정 생성
|
||||
const result = await initialSetupService.createInitialAdmin({
|
||||
name: '테스트 관리자',
|
||||
email: 'admin@test.com',
|
||||
password: 'admin123'
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
alert('테스트 관리자 계정이 생성되었습니다!\n이메일: admin@test.com\n비밀번호: admin123')
|
||||
window.location.reload()
|
||||
} else {
|
||||
alert(`계정 생성 실패: ${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`오류 발생: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const result = await userAuthService.login({
|
||||
email: 'admin@test.com',
|
||||
password: 'admin123'
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
alert('로그인 성공!')
|
||||
window.location.reload()
|
||||
} else {
|
||||
alert(`로그인 실패: ${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`로그인 오류: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 p-8">
|
||||
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">🔍 디버그 정보</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">현재 상태</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span>초기 설정 완료:</span>
|
||||
<span className={debugInfo.isSetupComplete ? 'text-green-600' : 'text-red-600'}>
|
||||
{debugInfo.isSetupComplete ? '✅ 완료' : '❌ 미완료'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>로그인 상태:</span>
|
||||
<span className={debugInfo.isAuthenticated ? 'text-green-600' : 'text-red-600'}>
|
||||
{debugInfo.isAuthenticated ? '✅ 로그인됨' : '❌ 로그아웃'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>현재 사용자:</span>
|
||||
<span className="text-gray-600">
|
||||
{debugInfo.currentUser ? debugInfo.currentUser.name : '없음'}
|
||||
</span>
|
||||
</div>
|
||||
{debugInfo.error && (
|
||||
<div className="flex justify-between">
|
||||
<span>오류:</span>
|
||||
<span className="text-red-600">{debugInfo.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">로컬 스토리지 정보</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<strong>dev_setup_complete:</strong> {localStorage.getItem('dev_setup_complete') || '없음'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>dev_auth_token:</strong> {localStorage.getItem('dev_auth_token') ? '있음' : '없음'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>dev_current_user:</strong> {localStorage.getItem('dev_current_user') ? '있음' : '없음'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleCreateTestUser}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
🔧 테스트 관리자 계정 생성
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
className="w-full px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700"
|
||||
>
|
||||
🔑 테스트 계정으로 로그인
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
localStorage.clear()
|
||||
window.location.reload()
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
🗑️ 모든 데이터 초기화
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.href = '/'}
|
||||
className="w-full px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
🏠 메인 페이지로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DebugInfo
|
||||
80
src/components/DynamicMap.tsx
Normal file
80
src/components/DynamicMap.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Attraction } from '../types'
|
||||
|
||||
interface MapProps {
|
||||
attractions: Attraction[]
|
||||
}
|
||||
|
||||
const DynamicMap = ({ attractions }: MapProps) => {
|
||||
const [MapComponent, setMapComponent] = useState<React.ComponentType<MapProps> | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 동적으로 Map 컴포넌트 로드 (클라이언트에서만)
|
||||
const loadMap = async () => {
|
||||
try {
|
||||
const { default: KumamotoMap } = await import('./Map')
|
||||
setMapComponent(() => KumamotoMap)
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Map 컴포넌트 로드 실패:', err)
|
||||
setError('지도를 불러올 수 없습니다.')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 클라이언트에서만 실행
|
||||
if (typeof window !== 'undefined') {
|
||||
loadMap()
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kumamoto-blue mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">지도를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ {error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!MapComponent) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-gray-100">
|
||||
<p className="text-gray-500">지도 컴포넌트를 찾을 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <MapComponent attractions={attractions} />
|
||||
}
|
||||
|
||||
export default DynamicMap
|
||||
285
src/components/GoogleAuthManager.tsx
Normal file
285
src/components/GoogleAuthManager.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { googleAuthService, GoogleUser, SavedPlace, GoogleMyMap } from '../services/googleAuth'
|
||||
|
||||
interface GoogleAuthManagerProps {
|
||||
onSavedPlacesLoad?: (places: SavedPlace[]) => void
|
||||
onMyMapsLoad?: (maps: GoogleMyMap[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const GoogleAuthManager = ({ onSavedPlacesLoad, onMyMapsLoad, className = '' }: GoogleAuthManagerProps) => {
|
||||
const [user, setUser] = useState<GoogleUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [savedPlaces, setSavedPlaces] = useState<SavedPlace[]>([])
|
||||
const [myMaps, setMyMaps] = useState<GoogleMyMap[]>([])
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 컴포넌트 마운트 시 초기화
|
||||
initializeAuth()
|
||||
}, [])
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// 환경변수에서 Client ID 가져오기 (없으면 사용자 입력 받기)
|
||||
const clientId = import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID ||
|
||||
localStorage.getItem('google_oauth_client_id')
|
||||
|
||||
if (!clientId) {
|
||||
setError('Google OAuth Client ID가 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
await googleAuthService.initialize(clientId)
|
||||
setIsInitialized(true)
|
||||
|
||||
// 이미 로그인된 사용자가 있는지 확인
|
||||
const currentUser = googleAuthService.getCurrentUser()
|
||||
if (currentUser && googleAuthService.isSignedIn()) {
|
||||
setUser(currentUser)
|
||||
await loadUserData()
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Google Auth 초기화 실패')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignIn = async () => {
|
||||
if (!isInitialized) {
|
||||
setError('Google Auth가 초기화되지 않았습니다')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const signedInUser = await googleAuthService.signIn()
|
||||
setUser(signedInUser)
|
||||
await loadUserData()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인 실패')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSignOut = async () => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await googleAuthService.signOut()
|
||||
setUser(null)
|
||||
setSavedPlaces([])
|
||||
setMyMaps([])
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그아웃 실패')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadUserData = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
// 저장된 장소 로드
|
||||
const places = await googleAuthService.getSavedPlaces()
|
||||
setSavedPlaces(places)
|
||||
if (onSavedPlacesLoad) {
|
||||
onSavedPlacesLoad(places)
|
||||
}
|
||||
|
||||
// My Maps 로드
|
||||
const maps = await googleAuthService.getMyMaps()
|
||||
setMyMaps(maps)
|
||||
if (onMyMapsLoad) {
|
||||
onMyMapsLoad(maps)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 데이터 로드 실패:', err)
|
||||
setError('데이터 로드 실패 (캐시된 데이터 사용)')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToItinerary = (place: SavedPlace) => {
|
||||
const itineraryItem = googleAuthService.convertToItineraryFormat(place)
|
||||
// 부모 컴포넌트로 전달하거나 직접 일정에 추가
|
||||
console.log('일정에 추가:', itineraryItem)
|
||||
alert(`"${place.name}"을(를) 일정에 추가했습니다!`)
|
||||
}
|
||||
|
||||
// Client ID 설정 (개발용)
|
||||
const handleSetClientId = () => {
|
||||
const clientId = prompt('Google OAuth Client ID를 입력하세요:')
|
||||
if (clientId) {
|
||||
localStorage.setItem('google_oauth_client_id', clientId)
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">🔐 Google 계정 연동</h3>
|
||||
{!isInitialized && (
|
||||
<button
|
||||
onClick={handleSetClientId}
|
||||
className="text-sm text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
Client ID 설정
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<span>⚠️</span>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!user ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<div className="text-4xl mb-2">🗺️</div>
|
||||
<p className="text-gray-600 mb-2">Google Maps에 저장된 장소를 가져와서</p>
|
||||
<p className="text-gray-600">구마모토 여행 계획에 추가하세요!</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSignIn}
|
||||
disabled={isLoading || !isInitialized}
|
||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
연결 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
Google로 로그인
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="mt-4 text-xs text-gray-500">
|
||||
<p>• Google My Maps의 저장된 장소</p>
|
||||
<p>• 즐겨찾기 및 가고 싶은 곳 리스트</p>
|
||||
<p>• 개인 여행 계획 및 메모</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* 사용자 정보 */}
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-gray-50 rounded-lg">
|
||||
<img
|
||||
src={user.picture}
|
||||
alt={user.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">{user.name}</div>
|
||||
<div className="text-sm text-gray-600">{user.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSignOut}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 데이터 새로고침 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={loadUserData}
|
||||
disabled={isLoading}
|
||||
className="flex-1 py-2 px-3 bg-blue-100 text-blue-700 rounded hover:bg-blue-200 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isLoading ? '로딩...' : '🔄 데이터 새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* My Maps 목록 */}
|
||||
{myMaps.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="font-medium text-gray-800 mb-2">📋 My Maps ({myMaps.length}개)</h4>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{myMaps.map((map) => (
|
||||
<div key={map.id} className="p-2 bg-gray-50 rounded border">
|
||||
<div className="font-medium text-sm">{map.title}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{map.places.length}개 장소 • {new Date(map.updated_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 장소 목록 */}
|
||||
{savedPlaces.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-800 mb-2">⭐ 저장된 장소 ({savedPlaces.length}개)</h4>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{savedPlaces.slice(0, 10).map((place) => (
|
||||
<div key={place.place_id} className="p-3 bg-gray-50 rounded border hover:bg-gray-100 transition-colors">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-800 truncate">
|
||||
{place.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 truncate">
|
||||
{place.formatted_address}
|
||||
</div>
|
||||
{place.rating && (
|
||||
<div className="text-xs text-yellow-600 mt-1">
|
||||
⭐ {place.rating} {place.user_ratings_total && `(${place.user_ratings_total})`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleAddToItinerary(place)}
|
||||
className="ml-2 px-2 py-1 bg-kumamoto-primary text-white text-xs rounded hover:bg-kumamoto-primary/90"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{savedPlaces.length > 10 && (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
+{savedPlaces.length - 10}개 더 있음
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{savedPlaces.length === 0 && myMaps.length === 0 && !isLoading && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-4xl mb-2">📭</div>
|
||||
<p>저장된 장소나 My Maps가 없습니다</p>
|
||||
<p className="text-sm mt-1">Google Maps에서 장소를 저장해보세요!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleAuthManager
|
||||
497
src/components/GoogleMapComponent.tsx
Normal file
497
src/components/GoogleMapComponent.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Attraction, BasePoint } from '../types'
|
||||
import { basePointsAPI } from '../services/api'
|
||||
import { canUseGoogleMaps, incrementGoogleMapsUsage } from '../services/googleMapsUsage'
|
||||
import GoogleMapsUsageIndicator from './GoogleMapsUsageIndicator'
|
||||
import GoogleMapsApiKeyManager from './GoogleMapsApiKeyManager'
|
||||
import { getGoogleMapsApiKey } from '../services/apiKeyManager'
|
||||
import { googleDirectionsService } from '../services/googleDirections'
|
||||
|
||||
interface GoogleMapComponentProps {
|
||||
attractions: Attraction[]
|
||||
basePoints: BasePoint[]
|
||||
onBasePointsUpdate?: (basePoints: BasePoint[]) => void
|
||||
selectedActivityId?: string
|
||||
currentDaySchedule?: any
|
||||
onActivityClick?: (activity: any) => void
|
||||
getActivityCoords?: (activity: any) => [number, number] | null
|
||||
onMapInit?: (map: google.maps.Map) => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google: any
|
||||
initGoogleMap: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const GoogleMapComponent = ({
|
||||
attractions,
|
||||
basePoints,
|
||||
onBasePointsUpdate,
|
||||
selectedActivityId,
|
||||
currentDaySchedule,
|
||||
onActivityClick,
|
||||
getActivityCoords,
|
||||
onMapInit
|
||||
}: GoogleMapComponentProps) => {
|
||||
const [map, setMap] = useState<any>(null)
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation' as BasePoint['type'],
|
||||
memo: ''
|
||||
})
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
|
||||
const [usageBlocked, setUsageBlocked] = useState(false)
|
||||
const [forceLeaflet, setForceLeaflet] = useState(false)
|
||||
const [hasValidApiKey, setHasValidApiKey] = useState(false)
|
||||
const [currentApiKey, setCurrentApiKey] = useState<string | null>(null)
|
||||
|
||||
// API 키 동적 로드
|
||||
useEffect(() => {
|
||||
const key = getGoogleMapsApiKey()
|
||||
setCurrentApiKey(key)
|
||||
}, [hasValidApiKey])
|
||||
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter = { lat: 32.8031, lng: 130.7079 }
|
||||
|
||||
const initializeMap = useCallback(() => {
|
||||
if (!window.google || !document.getElementById('google-map')) return
|
||||
|
||||
// API 사용량 체크
|
||||
const usageCheck = canUseGoogleMaps()
|
||||
if (!usageCheck.allowed) {
|
||||
setError(`구글 맵 사용량 한도 초과: ${usageCheck.reason}`)
|
||||
setUsageBlocked(true)
|
||||
return
|
||||
}
|
||||
|
||||
// API 사용량 증가
|
||||
incrementGoogleMapsUsage()
|
||||
|
||||
try {
|
||||
const mapInstance = new window.google.maps.Map(document.getElementById('google-map'), {
|
||||
center: kumamotoCenter,
|
||||
zoom: 10,
|
||||
mapTypeControl: true,
|
||||
streetViewControl: true,
|
||||
fullscreenControl: true,
|
||||
})
|
||||
|
||||
setMap(mapInstance)
|
||||
|
||||
// Directions Service 초기화
|
||||
googleDirectionsService.initialize()
|
||||
|
||||
// onMapInit 콜백 호출
|
||||
if (onMapInit) {
|
||||
onMapInit(mapInstance)
|
||||
}
|
||||
|
||||
// 클릭 이벤트로 위치 선택
|
||||
mapInstance.addListener('click', (event: any) => {
|
||||
if (showAddForm) {
|
||||
setSelectedLocation({
|
||||
lat: event.latLng.lat(),
|
||||
lng: event.latLng.lng()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 관광지 마커 추가
|
||||
attractions.forEach((attraction) => {
|
||||
if (attraction.coordinates) {
|
||||
const marker = new window.google.maps.Marker({
|
||||
position: { lat: attraction.coordinates.lat, lng: attraction.coordinates.lng },
|
||||
map: mapInstance,
|
||||
title: attraction.nameKo,
|
||||
icon: {
|
||||
url: 'https://maps.google.com/mapfiles/ms/icons/red-dot.png',
|
||||
scaledSize: new window.google.maps.Size(32, 32)
|
||||
}
|
||||
})
|
||||
|
||||
const infoWindow = new window.google.maps.InfoWindow({
|
||||
content: `
|
||||
<div style="max-width: 300px;">
|
||||
<h3 style="margin: 0 0 8px 0; font-weight: bold; color: #1f2937;">${attraction.nameKo}</h3>
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #4b5563;">${attraction.description}</p>
|
||||
<p style="margin: 0; font-size: 12px; color: #6b7280;">📍 ${attraction.location}</p>
|
||||
${attraction.admissionFee ? `<p style="margin: 4px 0 0 0; font-size: 12px; color: #6b7280;">💰 입장료: ${attraction.admissionFee.toLocaleString()}엔</p>` : ''}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
marker.addListener('click', () => {
|
||||
infoWindow.open(mapInstance, marker)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 기본 포인트 마커 추가
|
||||
basePoints.forEach((point) => {
|
||||
const marker = new window.google.maps.Marker({
|
||||
position: { lat: point.coordinates.lat, lng: point.coordinates.lng },
|
||||
map: mapInstance,
|
||||
title: point.name,
|
||||
icon: {
|
||||
url: 'https://maps.google.com/mapfiles/ms/icons/purple-dot.png',
|
||||
scaledSize: new window.google.maps.Size(32, 32)
|
||||
}
|
||||
})
|
||||
|
||||
const infoWindow = new window.google.maps.InfoWindow({
|
||||
content: `
|
||||
<div style="max-width: 300px;">
|
||||
<h3 style="margin: 0 0 8px 0; font-weight: bold; color: #1f2937;">${getTypeEmoji(point.type)} ${point.name}</h3>
|
||||
<p style="margin: 0 0 8px 0; font-size: 14px; color: #4b5563;">📍 ${point.address}</p>
|
||||
${point.memo ? `<p style="margin: 0; font-size: 12px; color: #6b7280;">📝 ${point.memo}</p>` : ''}
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
marker.addListener('click', () => {
|
||||
infoWindow.open(mapInstance, marker)
|
||||
})
|
||||
})
|
||||
|
||||
setIsLoaded(true)
|
||||
} catch (err) {
|
||||
console.error('Google Maps initialization error:', err)
|
||||
setError('구글 맵을 초기화할 수 없습니다.')
|
||||
}
|
||||
}, [attractions, basePoints, showAddForm])
|
||||
|
||||
useEffect(() => {
|
||||
const apiKey = getGoogleMapsApiKey()
|
||||
if (!apiKey) {
|
||||
setError('Google Maps API 키가 설정되지 않았습니다.')
|
||||
return
|
||||
}
|
||||
|
||||
// 초기 사용량 체크
|
||||
const usageCheck = canUseGoogleMaps()
|
||||
if (!usageCheck.allowed) {
|
||||
setError(`구글 맵 사용량 한도 초과: ${usageCheck.reason}`)
|
||||
setUsageBlocked(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Google Maps API 로드
|
||||
if (!window.google) {
|
||||
window.initGoogleMap = initializeMap
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=initGoogleMap&libraries=places`
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onerror = () => setError('Google Maps API를 로드할 수 없습니다.')
|
||||
document.head.appendChild(script)
|
||||
} else {
|
||||
initializeMap()
|
||||
}
|
||||
|
||||
return () => {
|
||||
// 클린업
|
||||
if (window.initGoogleMap) {
|
||||
delete window.initGoogleMap
|
||||
}
|
||||
}
|
||||
}, [currentApiKey, initializeMap])
|
||||
|
||||
const getTypeEmoji = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '🏨'
|
||||
case 'airport': return '✈️'
|
||||
case 'station': return '🚉'
|
||||
case 'parking': return '🅿️'
|
||||
default: return '📍'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeName = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '숙소'
|
||||
case 'airport': return '공항'
|
||||
case 'station': return '역/정류장'
|
||||
case 'parking': return '주차장'
|
||||
default: return '기타'
|
||||
}
|
||||
}
|
||||
|
||||
const addBasePoint = async () => {
|
||||
if (!formData.name || !formData.address || !selectedLocation) {
|
||||
alert('이름, 주소를 입력하고 지도에서 위치를 클릭해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const newPoint = await basePointsAPI.create({
|
||||
name: formData.name,
|
||||
address: formData.address,
|
||||
type: formData.type,
|
||||
coordinates: selectedLocation,
|
||||
memo: formData.memo || undefined
|
||||
})
|
||||
|
||||
const updatedBasePoints = [...basePoints, newPoint]
|
||||
onBasePointsUpdate(updatedBasePoints)
|
||||
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation',
|
||||
memo: ''
|
||||
})
|
||||
setSelectedLocation(null)
|
||||
setShowAddForm(false)
|
||||
|
||||
// 지도 다시 초기화 (새 마커 추가를 위해)
|
||||
setTimeout(initializeMap, 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to add base point:', error)
|
||||
alert('기본 포인트 추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBasePoint = async (id: string) => {
|
||||
if (confirm('이 위치를 삭제하시겠습니까?')) {
|
||||
try {
|
||||
await basePointsAPI.delete(id)
|
||||
const updatedBasePoints = basePoints.filter(p => p.id !== id)
|
||||
onBasePointsUpdate(updatedBasePoints)
|
||||
|
||||
// 지도 다시 초기화 (마커 제거를 위해)
|
||||
setTimeout(initializeMap, 100)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete base point:', error)
|
||||
alert('기본 포인트 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUsageExceeded = () => {
|
||||
setUsageBlocked(true)
|
||||
setForceLeaflet(true)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 사용량 초과로 인한 에러인 경우 오픈소스 맵으로 전환 제안 */}
|
||||
{usageBlocked && (
|
||||
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-yellow-600">⚠️</span>
|
||||
<span className="font-medium text-yellow-800">구글 맵 사용량 한도 초과</span>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-700 mb-3">
|
||||
구글 맵 API의 무료 사용량을 초과했습니다. 오픈소스 맵(OpenStreetMap)으로 전환하시겠습니까?
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setForceLeaflet(true)}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700 text-sm"
|
||||
>
|
||||
🌍 오픈소스 맵으로 전환
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ {error}</p>
|
||||
{!currentApiKey && (
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Google Maps를 사용하려면 API 키가 필요합니다.</p>
|
||||
<p className="mt-1">아래 API 키 관리자에서 설정해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
{usageBlocked && (
|
||||
<div className="text-sm text-gray-600 mt-2">
|
||||
<p>무료 사용량을 초과했습니다.</p>
|
||||
<p>내일 다시 시도하거나 오픈소스 맵을 사용해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 사용량 초과로 강제 전환된 경우
|
||||
if (forceLeaflet) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-blue-600">🌍</span>
|
||||
<span className="font-medium text-blue-800">오픈소스 맵으로 전환됨</span>
|
||||
</div>
|
||||
<p className="text-sm text-blue-700">
|
||||
구글 맵 사용량 한도로 인해 OpenStreetMap으로 전환되었습니다. 모든 기능은 동일하게 사용 가능합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
오픈소스 맵 컴포넌트를 로드하는 중...
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* API 키 관리 */}
|
||||
<div className="mb-4">
|
||||
<GoogleMapsApiKeyManager onApiKeyChange={setHasValidApiKey} />
|
||||
</div>
|
||||
|
||||
{/* Google Maps 사용량 표시 */}
|
||||
{hasValidApiKey && (
|
||||
<div className="mb-4">
|
||||
<GoogleMapsUsageIndicator onUsageExceeded={handleUsageExceeded} />
|
||||
</div>
|
||||
)}
|
||||
{/* 기본 정보 추가 버튼 */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="px-4 py-2 bg-kumamoto-primary text-white rounded hover:bg-kumamoto-secondary transition-colors"
|
||||
>
|
||||
{showAddForm ? '닫기' : '+ 기본 정보 추가'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">기본 정보 추가 (숙소, 공항, 이동 포인트)</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 호텔 이름, 구마모토 공항 등"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as BasePoint['type'] })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="accommodation">🏨 숙소</option>
|
||||
<option value="airport">✈️ 공항</option>
|
||||
<option value="station">🚉 역/정류장</option>
|
||||
<option value="parking">🅿️ 주차장</option>
|
||||
<option value="other">📍 기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="849-7 Hikimizu, Ozu, Kikuchi District, Kumamoto 869-1234"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">위치 선택</label>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
지도를 클릭하여 위치를 선택하세요.
|
||||
</p>
|
||||
{selectedLocation && (
|
||||
<p className="text-xs text-green-600 font-medium">
|
||||
✓ 위치 선택됨 (위도: {selectedLocation.lat.toFixed(6)}, 경도: {selectedLocation.lng.toFixed(6)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
|
||||
<textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
|
||||
placeholder="추가 정보를 입력하세요"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addBasePoint}
|
||||
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
추가하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 기본 포인트 목록 */}
|
||||
{basePoints.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded">
|
||||
<h4 className="font-semibold text-gray-800 mb-2 text-sm">저장된 기본 정보</h4>
|
||||
<div className="space-y-2">
|
||||
{basePoints.map((point) => (
|
||||
<div key={point.id} className="flex items-start justify-between bg-white p-2 rounded text-sm">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">
|
||||
{getTypeEmoji(point.type)} {point.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{point.address}</div>
|
||||
{point.memo && <div className="text-xs text-gray-400 mt-1">{point.memo}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteBasePoint(point.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs ml-2"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google Map */}
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200">
|
||||
{!isLoaded && !error && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kumamoto-blue mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">Google Maps를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div id="google-map" className="w-full h-full"></div>
|
||||
</div>
|
||||
|
||||
{/* 마커 설명 */}
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-purple-50 border border-purple-200 rounded text-sm text-purple-800">
|
||||
<strong>🟣 보라색 마커:</strong> 기본 정보 (숙소, 공항, 이동 포인트)
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
|
||||
<strong>🔴 빨간색 마커:</strong> 관광지
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleMapComponent
|
||||
245
src/components/GoogleMapsApiKeyManager.tsx
Normal file
245
src/components/GoogleMapsApiKeyManager.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
getGoogleMapsApiKey,
|
||||
setGoogleMapsApiKey,
|
||||
validateGoogleMapsApiKey,
|
||||
clearGoogleMapsApiKey,
|
||||
getApiKeyInfo,
|
||||
maskApiKey
|
||||
} from '../services/apiKeyManager'
|
||||
|
||||
interface ApiKeyManagerProps {
|
||||
onApiKeyChange?: (hasValidKey: boolean) => void
|
||||
}
|
||||
|
||||
const GoogleMapsApiKeyManager = ({ onApiKeyChange }: ApiKeyManagerProps) => {
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<{ isValid: boolean; error?: string } | null>(null)
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [apiKeyInfo, setApiKeyInfo] = useState(getApiKeyInfo())
|
||||
const [showManager, setShowManager] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
updateApiKeyInfo()
|
||||
}, [])
|
||||
|
||||
const updateApiKeyInfo = () => {
|
||||
const info = getApiKeyInfo()
|
||||
setApiKeyInfo(info)
|
||||
|
||||
if (onApiKeyChange) {
|
||||
onApiKeyChange(info.hasApiKey && info.isValid)
|
||||
}
|
||||
}
|
||||
|
||||
const handleValidateAndSave = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
setValidationResult({ isValid: false, error: 'API 키를 입력해주세요.' })
|
||||
return
|
||||
}
|
||||
|
||||
setIsValidating(true)
|
||||
setValidationResult(null)
|
||||
|
||||
try {
|
||||
const result = await validateGoogleMapsApiKey(apiKey.trim())
|
||||
setValidationResult(result)
|
||||
|
||||
if (result.isValid) {
|
||||
setGoogleMapsApiKey(apiKey.trim())
|
||||
setApiKey('')
|
||||
updateApiKeyInfo()
|
||||
setShowManager(false)
|
||||
}
|
||||
} catch (error) {
|
||||
setValidationResult({
|
||||
isValid: false,
|
||||
error: `검증 중 오류가 발생했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`
|
||||
})
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearApiKey = () => {
|
||||
if (confirm('저장된 API 키를 삭제하시겠습니까?')) {
|
||||
clearGoogleMapsApiKey()
|
||||
setApiKey('')
|
||||
setValidationResult(null)
|
||||
updateApiKeyInfo()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRevalidate = async () => {
|
||||
setIsValidating(true)
|
||||
const result = await validateGoogleMapsApiKey()
|
||||
setValidationResult(result)
|
||||
setIsValidating(false)
|
||||
updateApiKeyInfo()
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!apiKeyInfo.hasApiKey) return 'text-gray-600 bg-gray-50 border-gray-200'
|
||||
if (apiKeyInfo.isValid) return 'text-green-600 bg-green-50 border-green-200'
|
||||
return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||
}
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (!apiKeyInfo.hasApiKey) return '⚙️'
|
||||
if (apiKeyInfo.isValid) return '✅'
|
||||
return '⚠️'
|
||||
}
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (!apiKeyInfo.hasApiKey) return 'Google Maps API 키가 설정되지 않았습니다'
|
||||
if (apiKeyInfo.isValid) return 'Google Maps API 키가 유효합니다'
|
||||
return 'Google Maps API 키 검증이 필요합니다'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-3 rounded-lg border text-sm ${getStatusColor()}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getStatusIcon()}</span>
|
||||
<span className="font-medium">Google Maps API</span>
|
||||
<button
|
||||
onClick={() => setShowManager(!showManager)}
|
||||
className="text-xs underline opacity-75 hover:opacity-100"
|
||||
>
|
||||
{showManager ? '닫기' : '설정'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{apiKeyInfo.hasApiKey && (
|
||||
<span>
|
||||
{apiKeyInfo.isFromStorage ? '💾 저장됨' : '🔧 환경변수'}
|
||||
</span>
|
||||
)}
|
||||
{apiKeyInfo.isValid && (
|
||||
<button
|
||||
onClick={handleRevalidate}
|
||||
disabled={isValidating}
|
||||
className="px-2 py-1 bg-white bg-opacity-50 rounded hover:bg-opacity-75 disabled:opacity-50"
|
||||
>
|
||||
{isValidating ? '검증중...' : '재검증'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs opacity-90">
|
||||
{getStatusMessage()}
|
||||
</div>
|
||||
|
||||
{showManager && (
|
||||
<div className="mt-4 space-y-3 pt-3 border-t border-current border-opacity-20">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-2">
|
||||
Google Maps API 키 입력
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="AIzaSyC..."
|
||||
className="flex-1 px-3 py-2 border border-current border-opacity-30 rounded text-sm bg-white"
|
||||
disabled={isValidating}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="px-3 py-2 bg-white bg-opacity-50 rounded hover:bg-opacity-75 text-xs"
|
||||
type="button"
|
||||
>
|
||||
{showApiKey ? '🙈' : '👁️'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleValidateAndSave}
|
||||
disabled={isValidating || !apiKey.trim()}
|
||||
className="flex-1 px-3 py-2 bg-current text-white rounded hover:opacity-90 disabled:opacity-50 text-xs font-medium"
|
||||
>
|
||||
{isValidating ? '검증 중...' : '검증 후 저장'}
|
||||
</button>
|
||||
|
||||
{apiKeyInfo.hasApiKey && apiKeyInfo.isFromStorage && (
|
||||
<button
|
||||
onClick={handleClearApiKey}
|
||||
className="px-3 py-2 bg-red-500 text-white rounded hover:bg-red-600 text-xs"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검증 결과 */}
|
||||
{validationResult && (
|
||||
<div className={`p-2 rounded text-xs ${
|
||||
validationResult.isValid
|
||||
? 'bg-green-100 text-green-800 border border-green-200'
|
||||
: 'bg-red-100 text-red-800 border border-red-200'
|
||||
}`}>
|
||||
{validationResult.isValid ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>✅</span>
|
||||
<span>API 키가 유효합니다!</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>❌</span>
|
||||
<span>{validationResult.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 API 키 정보 */}
|
||||
{apiKeyInfo.hasApiKey && (
|
||||
<div className="p-2 bg-white bg-opacity-30 rounded text-xs">
|
||||
<div className="font-medium mb-1">현재 설정된 API 키</div>
|
||||
<div className="space-y-1 opacity-75">
|
||||
<div>키: {maskApiKey()}</div>
|
||||
<div>출처: {apiKeyInfo.isFromStorage ? '로컬 저장소' : '환경변수'}</div>
|
||||
{apiKeyInfo.lastUpdated && (
|
||||
<div>업데이트: {new Date(apiKeyInfo.lastUpdated).toLocaleString()}</div>
|
||||
)}
|
||||
<div>상태: {apiKeyInfo.isValid ? '✅ 유효함' : '⚠️ 검증 필요'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="p-2 bg-white bg-opacity-20 rounded text-xs opacity-75">
|
||||
<div className="font-medium mb-1">💡 Google Maps API 키 발급 방법</div>
|
||||
<div className="space-y-1">
|
||||
<div>1. <a href="https://console.cloud.google.com/" target="_blank" rel="noopener noreferrer" className="underline">Google Cloud Console</a>에 접속</div>
|
||||
<div>2. 프로젝트 생성 또는 선택</div>
|
||||
<div>3. "API 및 서비스" → "사용자 인증 정보"</div>
|
||||
<div>4. "사용자 인증 정보 만들기" → "API 키"</div>
|
||||
<div>5. Maps JavaScript API 활성화</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 무료 한도 정보 */}
|
||||
<div className="p-2 bg-white bg-opacity-20 rounded text-xs opacity-75">
|
||||
<div className="font-medium mb-1">📊 무료 사용 한도</div>
|
||||
<div className="space-y-1">
|
||||
<div>• Maps JavaScript API: 월 28,000 요청</div>
|
||||
<div>• 일반적인 개인 사용에는 충분함</div>
|
||||
<div>• 사용량은 자동으로 모니터링됨</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleMapsApiKeyManager
|
||||
179
src/components/GoogleMapsUsageIndicator.tsx
Normal file
179
src/components/GoogleMapsUsageIndicator.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getGoogleMapsUsage, getGoogleMapsWarningLevel, resetGoogleMapsUsage } from '../services/googleMapsUsage'
|
||||
|
||||
interface UsageIndicatorProps {
|
||||
onUsageExceeded?: () => void
|
||||
}
|
||||
|
||||
const GoogleMapsUsageIndicator = ({ onUsageExceeded }: UsageIndicatorProps) => {
|
||||
const [usage, setUsage] = useState<any>(null)
|
||||
const [warningLevel, setWarningLevel] = useState<'safe' | 'warning' | 'danger' | 'blocked'>('safe')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
updateUsage()
|
||||
|
||||
// 1분마다 사용량 업데이트
|
||||
const interval = setInterval(updateUsage, 60000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const updateUsage = () => {
|
||||
const currentUsage = getGoogleMapsUsage()
|
||||
const currentWarningLevel = getGoogleMapsWarningLevel()
|
||||
|
||||
setUsage(currentUsage)
|
||||
setWarningLevel(currentWarningLevel)
|
||||
|
||||
// 사용량 초과 시 콜백 호출
|
||||
if (currentWarningLevel === 'blocked' && onUsageExceeded) {
|
||||
onUsageExceeded()
|
||||
}
|
||||
}
|
||||
|
||||
const getWarningColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'safe': return 'text-green-600 bg-green-50 border-green-200'
|
||||
case 'warning': return 'text-yellow-600 bg-yellow-50 border-yellow-200'
|
||||
case 'danger': return 'text-orange-600 bg-orange-50 border-orange-200'
|
||||
case 'blocked': return 'text-red-600 bg-red-50 border-red-200'
|
||||
default: return 'text-gray-600 bg-gray-50 border-gray-200'
|
||||
}
|
||||
}
|
||||
|
||||
const getWarningIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'safe': return '✅'
|
||||
case 'warning': return '⚠️'
|
||||
case 'danger': return '🚨'
|
||||
case 'blocked': return '🚫'
|
||||
default: return 'ℹ️'
|
||||
}
|
||||
}
|
||||
|
||||
const getWarningMessage = (level: string) => {
|
||||
switch (level) {
|
||||
case 'safe': return '구글 맵 사용량이 안전합니다'
|
||||
case 'warning': return '구글 맵 사용량이 70%를 초과했습니다'
|
||||
case 'danger': return '구글 맵 사용량이 90%를 초과했습니다'
|
||||
case 'blocked': return '구글 맵 사용량 한도를 초과했습니다'
|
||||
default: return '사용량을 확인할 수 없습니다'
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
if (confirm('구글 맵 사용량을 초기화하시겠습니까? (개발/테스트 목적으로만 사용하세요)')) {
|
||||
resetGoogleMapsUsage()
|
||||
updateUsage()
|
||||
}
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`p-3 rounded-lg border text-sm ${getWarningColor(warningLevel)}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{getWarningIcon(warningLevel)}</span>
|
||||
<span className="font-medium">구글 맵 사용량</span>
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="text-xs underline opacity-75 hover:opacity-100"
|
||||
>
|
||||
{showDetails ? '숨기기' : '자세히'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs">
|
||||
일일: {usage.daily.current}/{usage.daily.limit}
|
||||
</span>
|
||||
{import.meta.env.DEV && (
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="text-xs px-2 py-1 bg-white bg-opacity-50 rounded hover:bg-opacity-75"
|
||||
title="개발용: 사용량 초기화"
|
||||
>
|
||||
리셋
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs opacity-90">
|
||||
{getWarningMessage(warningLevel)}
|
||||
</div>
|
||||
|
||||
{showDetails && (
|
||||
<div className="mt-3 space-y-2 text-xs">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* 시간당 사용량 */}
|
||||
<div>
|
||||
<div className="font-medium mb-1">시간당</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-white bg-opacity-50 rounded-full h-2">
|
||||
<div
|
||||
className="h-full bg-current rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usage.hourly.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{usage.hourly.current}/{usage.hourly.limit}</span>
|
||||
</div>
|
||||
<div className="mt-1 opacity-75">{usage.hourly.percentage}%</div>
|
||||
</div>
|
||||
|
||||
{/* 일일 사용량 */}
|
||||
<div>
|
||||
<div className="font-medium mb-1">일일</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-white bg-opacity-50 rounded-full h-2">
|
||||
<div
|
||||
className="h-full bg-current rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usage.daily.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{usage.daily.current}/{usage.daily.limit}</span>
|
||||
</div>
|
||||
<div className="mt-1 opacity-75">{usage.daily.percentage}%</div>
|
||||
</div>
|
||||
|
||||
{/* 월간 사용량 */}
|
||||
<div>
|
||||
<div className="font-medium mb-1">월간</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-white bg-opacity-50 rounded-full h-2">
|
||||
<div
|
||||
className="h-full bg-current rounded-full transition-all"
|
||||
style={{ width: `${Math.min(usage.monthly.percentage, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{usage.monthly.current}/{usage.monthly.limit}</span>
|
||||
</div>
|
||||
<div className="mt-1 opacity-75">{usage.monthly.percentage}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-current border-opacity-20">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>마지막 리셋: {usage.lastReset}</span>
|
||||
<span className="opacity-75">
|
||||
{warningLevel === 'blocked' ? '🚫 사용 차단됨' : '✅ 사용 가능'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-current border-opacity-20 text-xs opacity-75">
|
||||
<div>💡 <strong>무료 한도 정보:</strong></div>
|
||||
<div>• Google Maps JavaScript API: 월 28,000 요청</div>
|
||||
<div>• 안전 마진 20% 적용하여 22,400 요청으로 제한</div>
|
||||
<div>• 시간당 30회, 일일 750회 제한</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleMapsUsageIndicator
|
||||
@@ -10,9 +10,9 @@ const Header = () => {
|
||||
/>
|
||||
<div className="relative container mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">🇯🇵 구마모토 여행</h1>
|
||||
<h1 className="text-2xl font-bold">✈️ Travel Planner</h1>
|
||||
<div className="text-sm opacity-90">
|
||||
가족과 함께하는 여행 계획
|
||||
스마트한 여행 계획 관리 시스템
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
429
src/components/InitialSetup.tsx
Normal file
429
src/components/InitialSetup.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { initialSetupService, InitialSetupData, SetupStatus } from '../services/initialSetup'
|
||||
|
||||
interface InitialSetupProps {
|
||||
onSetupComplete: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const InitialSetup = ({ onSetupComplete, className = '' }: InitialSetupProps) => {
|
||||
const [setupStatus, setSetupStatus] = useState<SetupStatus | null>(null)
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [setupData, setSetupData] = useState<InitialSetupData>({
|
||||
admin_name: '',
|
||||
admin_email: '',
|
||||
admin_password: '',
|
||||
site_name: 'Travel Planner',
|
||||
site_description: '스마트한 여행 계획 도구',
|
||||
default_language: 'ko',
|
||||
default_currency: 'KRW'
|
||||
})
|
||||
|
||||
const [envCheck, setEnvCheck] = useState({
|
||||
database_url: false,
|
||||
jwt_secret: false,
|
||||
google_maps_key: false,
|
||||
email_config: false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
checkInitialStatus()
|
||||
}, [])
|
||||
|
||||
const checkInitialStatus = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const status = await initialSetupService.checkSetupStatus()
|
||||
setSetupStatus(status)
|
||||
|
||||
if (!status.is_setup_required) {
|
||||
onSetupComplete()
|
||||
return
|
||||
}
|
||||
|
||||
// 환경 변수 확인
|
||||
const env = await initialSetupService.checkEnvironment()
|
||||
setEnvCheck(env)
|
||||
} catch (error) {
|
||||
setError('초기 상태 확인 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await initialSetupService.performInitialSetup(setupData)
|
||||
|
||||
if (result.success) {
|
||||
alert('초기 설정이 완료되었습니다! 관리자 계정으로 로그인해주세요.')
|
||||
onSetupComplete()
|
||||
} else {
|
||||
setError(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
setError('초기 설정 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const testDatabaseConnection = async () => {
|
||||
try {
|
||||
const result = await initialSetupService.testDatabaseConnection()
|
||||
if (result.success) {
|
||||
alert('✅ 데이터베이스 연결 성공!')
|
||||
} else {
|
||||
alert(`❌ 데이터베이스 연결 실패: ${result.message}`)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ 데이터베이스 연결 테스트 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center ${className}`}>
|
||||
<div className="bg-white rounded-lg shadow-lg p-8 w-full max-w-md">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-lg font-medium text-gray-800">시스템 상태 확인 중...</div>
|
||||
<div className="text-sm text-gray-600 mt-2">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center p-4 ${className}`}>
|
||||
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-2xl">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-6xl mb-4">🚀</div>
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
Travel Planner 초기 설정
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
서버를 처음 실행하시는군요! 관리자 계정과 기본 설정을 구성해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 진행 단계 */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{[1, 2, 3].map((step) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
currentStep >= step
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{step}
|
||||
</div>
|
||||
{step < 3 && (
|
||||
<div className={`w-16 h-1 mx-2 ${
|
||||
currentStep > step ? 'bg-blue-600' : 'bg-gray-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
<span>환경 확인</span>
|
||||
<span>관리자 계정</span>
|
||||
<span>사이트 설정</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<span>⚠️</span>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">1. 환경 설정 확인</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className={`p-4 rounded-lg border ${envCheck.database_url ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">데이터베이스</span>
|
||||
<span className={envCheck.database_url ? 'text-green-600' : 'text-red-600'}>
|
||||
{envCheck.database_url ? '✅' : '❌'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{envCheck.database_url ? '연결 가능' : 'DATABASE_URL 설정 필요'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-lg border ${envCheck.jwt_secret ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">JWT 시크릿</span>
|
||||
<span className={envCheck.jwt_secret ? 'text-green-600' : 'text-red-600'}>
|
||||
{envCheck.jwt_secret ? '✅' : '❌'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{envCheck.jwt_secret ? '설정됨' : 'JWT_SECRET 설정 필요'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-lg border ${envCheck.google_maps_key ? 'bg-green-50 border-green-200' : 'bg-yellow-50 border-yellow-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">Google Maps</span>
|
||||
<span className={envCheck.google_maps_key ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{envCheck.google_maps_key ? '✅' : '⚠️'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{envCheck.google_maps_key ? '설정됨' : '선택사항 (나중에 설정 가능)'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-lg border ${envCheck.email_config ? 'bg-green-50 border-green-200' : 'bg-yellow-50 border-yellow-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">이메일 설정</span>
|
||||
<span className={envCheck.email_config ? 'text-green-600' : 'text-yellow-600'}>
|
||||
{envCheck.email_config ? '✅' : '⚠️'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">
|
||||
{envCheck.email_config ? '설정됨' : '선택사항 (알림 기능용)'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={testDatabaseConnection}
|
||||
className="px-4 py-2 bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
🔍 DB 연결 테스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={() => setCurrentStep(2)}
|
||||
disabled={!envCheck.database_url || !envCheck.jwt_secret}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
다음 단계 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">2. 관리자 계정 생성</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
관리자 이름 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={setupData.admin_name}
|
||||
onChange={(e) => setSetupData({ ...setupData, admin_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="홍길동"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
관리자 이메일 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={setupData.admin_email}
|
||||
onChange={(e) => setSetupData({ ...setupData, admin_email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="admin@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
관리자 비밀번호 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupData.admin_password}
|
||||
onChange={(e) => setSetupData({ ...setupData, admin_password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="8자 이상의 안전한 비밀번호"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
⚠️ 이 계정으로 모든 사용자와 시스템을 관리할 수 있습니다. 안전한 비밀번호를 사용하세요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentStep(1)}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
← 이전 단계
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentStep(3)}
|
||||
disabled={!setupData.admin_name || !setupData.admin_email || !setupData.admin_password}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
다음 단계 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">3. 사이트 기본 설정</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
사이트 이름
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={setupData.site_name}
|
||||
onChange={(e) => setSetupData({ ...setupData, site_name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="Travel Planner"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
기본 언어
|
||||
</label>
|
||||
<select
|
||||
value={setupData.default_language}
|
||||
onChange={(e) => setSetupData({ ...setupData, default_language: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="ko">🇰🇷 한국어</option>
|
||||
<option value="en">🇺🇸 English</option>
|
||||
<option value="ja">🇯🇵 日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
기본 통화
|
||||
</label>
|
||||
<select
|
||||
value={setupData.default_currency}
|
||||
onChange={(e) => setSetupData({ ...setupData, default_currency: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="KRW">₩ 원 (KRW)</option>
|
||||
<option value="JPY">¥ 엔 (JPY)</option>
|
||||
<option value="USD">$ 달러 (USD)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
사이트 설명 (선택사항)
|
||||
</label>
|
||||
<textarea
|
||||
value={setupData.site_description}
|
||||
onChange={(e) => setSetupData({ ...setupData, site_description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="스마트한 여행 계획 도구"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 className="font-medium text-blue-800 mb-2">📋 설정 요약</h3>
|
||||
<div className="text-sm text-blue-700 space-y-1">
|
||||
<div>• 관리자: {setupData.admin_name} ({setupData.admin_email})</div>
|
||||
<div>• 사이트: {setupData.site_name}</div>
|
||||
<div>• 언어: {setupData.default_language === 'ko' ? '한국어' : setupData.default_language === 'en' ? 'English' : '日本語'}</div>
|
||||
<div>• 통화: {setupData.default_currency}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentStep(2)}
|
||||
className="px-6 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
← 이전 단계
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-8 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
설정 중...
|
||||
</div>
|
||||
) : (
|
||||
'🚀 설정 완료'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 개발 환경 안내 */}
|
||||
{import.meta.env.DEV && (
|
||||
<div className="mt-8 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div className="text-sm text-yellow-800">
|
||||
<div className="font-medium mb-1">🛠️ 개발 환경</div>
|
||||
<div>• 데이터는 브라우저 로컬 스토리지에 저장됩니다</div>
|
||||
<div>• 실제 서버에서는 데이터베이스를 사용합니다</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('모든 설정을 초기화하시겠습니까?')) {
|
||||
initialSetupService.resetSetup()
|
||||
window.location.reload()
|
||||
}
|
||||
}}
|
||||
className="mt-2 text-xs text-yellow-700 hover:text-yellow-900 underline"
|
||||
>
|
||||
설정 초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InitialSetup
|
||||
561
src/components/LeafletMapComponent.tsx
Normal file
561
src/components/LeafletMapComponent.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
|
||||
import { Icon } from 'leaflet'
|
||||
import { Attraction, BasePoint } from '../types'
|
||||
import { basePointsAPI } from '../services/api'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
interface LeafletMapComponentProps {
|
||||
attractions: Attraction[]
|
||||
basePoints: BasePoint[]
|
||||
onBasePointsUpdate?: (basePoints: BasePoint[]) => void
|
||||
selectedActivityId?: string
|
||||
currentDaySchedule?: any
|
||||
onActivityClick?: (activity: any) => void
|
||||
getActivityCoords?: (activity: any) => [number, number] | null
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
display_name: string
|
||||
lat: string
|
||||
lon: string
|
||||
}
|
||||
|
||||
const LeafletMapComponent = ({
|
||||
attractions,
|
||||
basePoints,
|
||||
onBasePointsUpdate,
|
||||
selectedActivityId,
|
||||
currentDaySchedule,
|
||||
onActivityClick,
|
||||
getActivityCoords
|
||||
}: LeafletMapComponentProps) => {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [mapError, setMapError] = useState<string | null>(null)
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation' as BasePoint['type'],
|
||||
memo: ''
|
||||
})
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 클라이언트에서만 실행
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsMounted(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 주소 검색 (Nominatim API)
|
||||
const searchAddress = async (query: string) => {
|
||||
if (!query.trim()) return
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
// 구마모토 지역에 검색 제한
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query + ' 熊本')}&` +
|
||||
`format=json&` +
|
||||
`limit=5&` +
|
||||
`countrycodes=jp&` +
|
||||
`accept-language=ja`
|
||||
)
|
||||
const data = await response.json()
|
||||
setSearchResults(data)
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error)
|
||||
alert('주소 검색에 실패했습니다.')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 결과 선택
|
||||
const selectSearchResult = (result: SearchResult) => {
|
||||
setSelectedLocation({
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon)
|
||||
})
|
||||
setFormData({ ...formData, address: result.display_name })
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
// 기본 포인트 추가
|
||||
const addBasePoint = async () => {
|
||||
if (!formData.name || !formData.address || !selectedLocation) {
|
||||
alert('이름과 주소를 입력하고 검색 결과에서 선택해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const newPoint = await basePointsAPI.create({
|
||||
name: formData.name,
|
||||
address: formData.address,
|
||||
type: formData.type,
|
||||
coordinates: selectedLocation,
|
||||
memo: formData.memo || undefined
|
||||
})
|
||||
|
||||
const updatedBasePoints = [...basePoints, newPoint]
|
||||
onBasePointsUpdate(updatedBasePoints)
|
||||
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation',
|
||||
memo: ''
|
||||
})
|
||||
setSelectedLocation(null)
|
||||
setShowAddForm(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to add base point:', error)
|
||||
alert('기본 포인트 추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 포인트 삭제
|
||||
const deleteBasePoint = async (id: string) => {
|
||||
if (confirm('이 위치를 삭제하시겠습니까?')) {
|
||||
try {
|
||||
await basePointsAPI.delete(id)
|
||||
const updatedBasePoints = basePoints.filter(p => p.id !== id)
|
||||
onBasePointsUpdate(updatedBasePoints)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete base point:', error)
|
||||
alert('기본 포인트 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter: [number, number] = [32.8031, 130.7079]
|
||||
|
||||
// 타일 서버 URL
|
||||
const tilesUrl = import.meta.env.VITE_MAP_TILES_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const isCustomTiles = !!import.meta.env.VITE_MAP_TILES_URL
|
||||
|
||||
// 타입별 아이콘 색상
|
||||
const getTypeEmoji = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '🏨'
|
||||
case 'airport': return '✈️'
|
||||
case 'station': return '🚉'
|
||||
case 'parking': return '🅿️'
|
||||
default: return '📍'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeName = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '숙소'
|
||||
case 'airport': return '공항'
|
||||
case 'station': return '역/정류장'
|
||||
case 'parking': return '주차장'
|
||||
default: return '기타'
|
||||
}
|
||||
}
|
||||
|
||||
// 마커 아이콘 설정
|
||||
const attractionIcon = new Icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
const basePointIcon = new Icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-violet.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
// 서버 사이드에서는 로딩 화면 표시
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kumamoto-blue mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">지도를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 에러 발생 시
|
||||
if (mapError) {
|
||||
return (
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ {mapError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMapError(null)
|
||||
window.location.reload()
|
||||
}}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 클라이언트에서 렌더링
|
||||
try {
|
||||
return (
|
||||
<div>
|
||||
{/* 기본 정보 추가 버튼 */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="px-4 py-2 bg-kumamoto-primary text-white rounded hover:bg-kumamoto-secondary transition-colors"
|
||||
>
|
||||
{showAddForm ? '닫기' : '+ 기본 정보 추가'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">기본 정보 추가 (숙소, 공항, 이동 포인트)</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 호텔 이름, 구마모토 공항 등"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as BasePoint['type'] })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="accommodation">🏨 숙소</option>
|
||||
<option value="airport">✈️ 공항</option>
|
||||
<option value="station">🚉 역/정류장</option>
|
||||
<option value="parking">🅿️ 주차장</option>
|
||||
<option value="other">📍 기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="849-7 Hikimizu, Ozu, Kikuchi District, Kumamoto 869-1234"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
주소를 직접 입력하세요 (영어, 한글, 일본어 모두 가능)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">위치 좌표 설정</label>
|
||||
|
||||
{/* 옵션 1: 간단한 검색으로 좌표 찾기 */}
|
||||
<div className="mb-3 p-3 bg-blue-50 rounded border border-blue-200">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">옵션 1: 간단한 검색으로 좌표 찾기</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="간단한 키워드 입력 (예: 구마모토 공항, 오즈 등)"
|
||||
className="flex-1 px-3 py-2 border rounded text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
searchAddress(e.currentTarget.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement
|
||||
searchAddress(input.value)
|
||||
}}
|
||||
disabled={isSearching}
|
||||
className="px-3 py-2 bg-kumamoto-blue text-white rounded hover:bg-opacity-90 disabled:bg-gray-400 text-sm"
|
||||
>
|
||||
{isSearching ? '검색중...' : '검색'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 border rounded max-h-40 overflow-y-auto bg-white">
|
||||
{searchResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => selectSearchResult(result)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-100 border-b last:border-b-0 text-xs"
|
||||
>
|
||||
<div className="font-medium text-gray-800">결과 {idx + 1}</div>
|
||||
<div className="text-gray-600 truncate">{result.display_name}</div>
|
||||
<div className="text-gray-400 text-xs">
|
||||
위도: {parseFloat(result.lat).toFixed(4)}, 경도: {parseFloat(result.lon).toFixed(4)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 2: 좌표 직접 입력 */}
|
||||
<div className="p-3 bg-green-50 rounded border border-green-200">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">옵션 2: 좌표 직접 입력 (Google Maps에서 확인)</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="위도 (예: 32.8031)"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={selectedLocation?.lat || ''}
|
||||
onChange={(e) => {
|
||||
const lat = parseFloat(e.target.value)
|
||||
if (!isNaN(lat)) {
|
||||
setSelectedLocation({
|
||||
lat,
|
||||
lng: selectedLocation?.lng || 0
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="경도 (예: 130.7079)"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={selectedLocation?.lng || ''}
|
||||
onChange={(e) => {
|
||||
const lng = parseFloat(e.target.value)
|
||||
if (!isNaN(lng)) {
|
||||
setSelectedLocation({
|
||||
lat: selectedLocation?.lat || 0,
|
||||
lng
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 Google Maps에서 장소 우클릭 → 첫 번째 숫자 복사
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLocation && selectedLocation.lat !== 0 && selectedLocation.lng !== 0 && (
|
||||
<p className="mt-2 text-xs text-green-600 font-medium">
|
||||
✓ 위치 선택됨 (위도: {selectedLocation.lat.toFixed(6)}, 경도: {selectedLocation.lng.toFixed(6)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
|
||||
<textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
|
||||
placeholder="추가 정보를 입력하세요"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addBasePoint}
|
||||
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
추가하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 기본 포인트 목록 */}
|
||||
{basePoints.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded">
|
||||
<h4 className="font-semibold text-gray-800 mb-2 text-sm">저장된 기본 정보</h4>
|
||||
<div className="space-y-2">
|
||||
{basePoints.map((point) => (
|
||||
<div key={point.id} className="flex items-start justify-between bg-white p-2 rounded text-sm">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">
|
||||
{getTypeEmoji(point.type)} {point.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{point.address}</div>
|
||||
{point.memo && <div className="text-xs text-gray-400 mt-1">{point.memo}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteBasePoint(point.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs ml-2"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200">
|
||||
<MapContainer
|
||||
center={kumamotoCenter}
|
||||
zoom={10}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<TileLayer
|
||||
url={tilesUrl}
|
||||
attribution={
|
||||
isCustomTiles
|
||||
? '© 구마모토 여행 지도 | OpenStreetMap contributors'
|
||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}
|
||||
maxZoom={18}
|
||||
/>
|
||||
|
||||
{/* 기본 포인트 마커 (보라색) */}
|
||||
{basePoints.map((point) => (
|
||||
<Marker
|
||||
key={point.id}
|
||||
position={[point.coordinates.lat, point.coordinates.lng]}
|
||||
icon={basePointIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-2 max-w-xs">
|
||||
<h3 className="font-bold text-gray-800 mb-2 text-base">
|
||||
{getTypeEmoji(point.type)} {point.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
<span className="inline-block px-2 py-0.5 bg-purple-100 rounded">
|
||||
{getTypeName(point.type)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
📍 {point.address}
|
||||
</p>
|
||||
{point.memo && (
|
||||
<p className="text-gray-500 text-xs">
|
||||
📝 {point.memo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 관광지 마커 (빨간색) */}
|
||||
{attractions.map((attraction) => (
|
||||
attraction.coordinates && (
|
||||
<Marker
|
||||
key={attraction.id}
|
||||
position={[attraction.coordinates.lat, attraction.coordinates.lng]}
|
||||
icon={attractionIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-2 max-w-xs">
|
||||
<h3 className="font-bold text-gray-800 mb-2 text-base">
|
||||
{attraction.nameKo}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
{attraction.description}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs mb-1">
|
||||
📍 {attraction.location}
|
||||
</p>
|
||||
{attraction.admissionFee && (
|
||||
<p className="text-gray-500 text-xs">
|
||||
💰 입장료: {attraction.admissionFee.toLocaleString()}엔
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
{/* 지도 정보 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-purple-50 border border-purple-200 rounded text-sm text-purple-800">
|
||||
<strong>🟣 보라색 마커:</strong> 기본 정보 (숙소, 공항, 이동 포인트)
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
|
||||
<strong>🔴 빨간색 마커:</strong> 관광지
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded text-sm text-green-800">
|
||||
{isCustomTiles ? (
|
||||
<>
|
||||
🎯 <strong>구마모토 전용 지도 서버</strong> 사용 중!
|
||||
<br />
|
||||
<span className="text-xs mt-1 block">
|
||||
• 빠른 로딩 속도와 오프라인 지원<br />
|
||||
• Google Maps API 키 불필요<br />
|
||||
• 구마모토 지역 최적화된 지도 데이터
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
🌍 <strong>OpenStreetMap</strong> 사용 중
|
||||
<br />
|
||||
<span className="text-xs mt-1 block">
|
||||
• 무료 오픈소스 지도 서비스<br />
|
||||
• API 키 불필요<br />
|
||||
• Docker 지도 서버 실행 시 더 빠른 성능 제공
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('지도 렌더링 에러:', error)
|
||||
return (
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ 지도를 렌더링할 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default LeafletMapComponent
|
||||
@@ -18,7 +18,7 @@ const attractionIcon = new Icon({
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
const Map = ({ attractions }: MapProps) => {
|
||||
const KumamotoMap = ({ attractions }: MapProps) => {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
// 클라이언트 사이드에서만 렌더링 (SSR 에러 방지)
|
||||
@@ -129,4 +129,4 @@ const Map = ({ attractions }: MapProps) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default Map
|
||||
export default KumamotoMap
|
||||
|
||||
47
src/components/MobileTabBar.tsx
Normal file
47
src/components/MobileTabBar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
badge?: number
|
||||
}
|
||||
|
||||
interface MobileTabBarProps {
|
||||
tabs: Tab[]
|
||||
activeTab: string
|
||||
onTabChange: (tabId: string) => void
|
||||
}
|
||||
|
||||
const MobileTabBar = ({ tabs, activeTab, onTabChange }: MobileTabBarProps) => {
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50">
|
||||
<div className="grid grid-cols-4 h-16">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex flex-col items-center justify-center gap-1 transition-colors relative ${
|
||||
activeTab === tab.id
|
||||
? 'text-kumamoto-primary bg-kumamoto-light/20'
|
||||
: 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<div className="relative">
|
||||
<span className="text-xl">{tab.icon}</span>
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span className="absolute -top-1 -right-1 inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
{tab.badge > 9 ? '9+' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs font-medium">{tab.label}</span>
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute top-0 left-1/2 transform -translate-x-1/2 w-8 h-1 bg-kumamoto-primary rounded-full"></div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MobileTabBar
|
||||
272
src/components/PlacesSearch.tsx
Normal file
272
src/components/PlacesSearch.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { googlePlacesService, PlaceResult, PlaceSearchOptions, KumamotoSearchPresets } from '../services/googlePlaces'
|
||||
|
||||
interface PlacesSearchProps {
|
||||
onPlaceSelect: (place: PlaceResult) => void
|
||||
onAddToItinerary?: (place: PlaceResult) => void
|
||||
currentLocation?: { lat: number; lng: number }
|
||||
className?: string
|
||||
}
|
||||
|
||||
const PlacesSearch = ({ onPlaceSelect, onAddToItinerary, currentLocation, className = '' }: PlacesSearchProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<PlaceResult[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('')
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const [autocompleteResults, setAutocompleteResults] = useState<google.maps.places.AutocompletePrediction[]>([])
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false)
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const resultsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 자동완성 검색
|
||||
useEffect(() => {
|
||||
if (searchQuery.length > 2) {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const predictions = await googlePlacesService.getAutocompletePredictions(
|
||||
searchQuery,
|
||||
currentLocation || { lat: 32.7898, lng: 130.7417 } // 구마모토 중심
|
||||
)
|
||||
setAutocompleteResults(predictions)
|
||||
setShowAutocomplete(true)
|
||||
} catch (error) {
|
||||
console.error('Autocomplete failed:', error)
|
||||
setAutocompleteResults([])
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
} else {
|
||||
setAutocompleteResults([])
|
||||
setShowAutocomplete(false)
|
||||
}
|
||||
}, [searchQuery, currentLocation])
|
||||
|
||||
// 검색 실행
|
||||
const handleSearch = async (options?: PlaceSearchOptions) => {
|
||||
if (!searchQuery.trim() && !options) return
|
||||
|
||||
setIsLoading(true)
|
||||
setShowAutocomplete(false)
|
||||
|
||||
try {
|
||||
const searchOptions: PlaceSearchOptions = options || {
|
||||
query: searchQuery,
|
||||
location: currentLocation || { lat: 32.7898, lng: 130.7417 },
|
||||
radius: 10000
|
||||
}
|
||||
|
||||
const results = await googlePlacesService.textSearch(searchOptions)
|
||||
setSearchResults(results)
|
||||
setShowResults(true)
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error)
|
||||
setSearchResults([])
|
||||
// Google Maps API가 로드되지 않은 경우 사용자에게 알림
|
||||
if (error instanceof Error && error.message.includes('not initialized')) {
|
||||
alert('Google Maps API 키를 먼저 설정해주세요.')
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 프리셋 검색
|
||||
const handlePresetSearch = async (presetKey: string) => {
|
||||
const preset = KumamotoSearchPresets[presetKey as keyof typeof KumamotoSearchPresets]
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey)
|
||||
setSearchQuery(preset.query)
|
||||
await handleSearch({
|
||||
...preset,
|
||||
location: currentLocation || { lat: 32.7898, lng: 130.7417 },
|
||||
radius: 15000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 자동완성 항목 선택
|
||||
const handleAutocompleteSelect = async (prediction: google.maps.places.AutocompletePrediction) => {
|
||||
setSearchQuery(prediction.description)
|
||||
setShowAutocomplete(false)
|
||||
|
||||
// Place ID로 상세 정보 가져오기
|
||||
try {
|
||||
const placeDetails = await googlePlacesService.getPlaceDetails(prediction.place_id)
|
||||
if (placeDetails) {
|
||||
setSearchResults([placeDetails])
|
||||
setShowResults(true)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get place details:', error)
|
||||
// 일반 텍스트 검색으로 폴백
|
||||
await handleSearch()
|
||||
}
|
||||
}
|
||||
|
||||
// 장소 선택
|
||||
const handlePlaceClick = (place: PlaceResult) => {
|
||||
onPlaceSelect(place)
|
||||
setShowResults(false)
|
||||
}
|
||||
|
||||
// 일정에 추가
|
||||
const handleAddToItinerary = (place: PlaceResult, event: React.MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (onAddToItinerary) {
|
||||
onAddToItinerary(place)
|
||||
}
|
||||
}
|
||||
|
||||
// 가격 레벨 표시
|
||||
const getPriceLevelDisplay = (level?: number) => {
|
||||
if (!level) return ''
|
||||
return '₩'.repeat(level)
|
||||
}
|
||||
|
||||
// 별점 표시
|
||||
const getRatingDisplay = (rating?: number) => {
|
||||
if (!rating) return ''
|
||||
return '⭐'.repeat(Math.floor(rating)) + ` ${rating.toFixed(1)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* 검색 바 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-4 mb-4">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="구마모토 장소 검색... (예: 구마모토성, 라멘집)"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
/>
|
||||
|
||||
{/* 자동완성 결과 */}
|
||||
{showAutocomplete && autocompleteResults.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 bg-white border border-gray-200 rounded-lg shadow-lg z-50 max-h-60 overflow-y-auto">
|
||||
{autocompleteResults.map((prediction) => (
|
||||
<button
|
||||
key={prediction.place_id}
|
||||
onClick={() => handleAutocompleteSelect(prediction)}
|
||||
className="w-full text-left px-4 py-2 hover:bg-gray-50 border-b border-gray-100 last:border-b-0"
|
||||
>
|
||||
<div className="font-medium text-gray-800">{prediction.structured_formatting.main_text}</div>
|
||||
<div className="text-sm text-gray-500">{prediction.structured_formatting.secondary_text}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
disabled={isLoading}
|
||||
className="px-6 py-2 bg-kumamoto-primary text-white rounded-lg hover:bg-kumamoto-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? '검색중...' : '검색'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 버튼들 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(KumamotoSearchPresets).map(([key, preset]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => handlePresetSearch(key)}
|
||||
className={`px-3 py-1 text-sm rounded-full border transition-colors ${
|
||||
selectedPreset === key
|
||||
? 'bg-kumamoto-primary text-white border-kumamoto-primary'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{preset.query.replace('구마모토 ', '')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 */}
|
||||
{showResults && (
|
||||
<div ref={resultsRef} className="bg-white rounded-lg shadow-md max-h-96 overflow-y-auto">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
검색 결과 ({searchResults.length}개)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<div className="text-4xl mb-2">🔍</div>
|
||||
<div>검색 결과가 없습니다.</div>
|
||||
<div className="text-sm">다른 키워드로 검색해보세요.</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100">
|
||||
{searchResults.map((place) => (
|
||||
<div
|
||||
key={place.place_id}
|
||||
onClick={() => handlePlaceClick(place)}
|
||||
className="p-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-800 mb-1">{place.name}</h4>
|
||||
<p className="text-sm text-gray-600 mb-2">{place.formatted_address}</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{place.rating && (
|
||||
<span className="text-yellow-600">
|
||||
{getRatingDisplay(place.rating)}
|
||||
</span>
|
||||
)}
|
||||
{place.price_level && (
|
||||
<span className="text-green-600">
|
||||
{getPriceLevelDisplay(place.price_level)}
|
||||
</span>
|
||||
)}
|
||||
{place.opening_hours?.open_now !== undefined && (
|
||||
<span className={place.opening_hours.open_now ? 'text-green-600' : 'text-red-600'}>
|
||||
{place.opening_hours.open_now ? '영업중' : '영업종료'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{place.types.slice(0, 3).map((type) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
|
||||
>
|
||||
{type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onAddToItinerary && (
|
||||
<button
|
||||
onClick={(e) => handleAddToItinerary(place, e)}
|
||||
className="ml-4 px-3 py-1 bg-kumamoto-primary text-white text-sm rounded hover:bg-kumamoto-primary/90"
|
||||
>
|
||||
일정 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlacesSearch
|
||||
472
src/components/RelatedPlacesManager.tsx
Normal file
472
src/components/RelatedPlacesManager.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import { useState } from 'react'
|
||||
import { Activity, RelatedPlace } from '../types'
|
||||
import CoordinateInput from './forms/CoordinateInput'
|
||||
import ImageUploadField from './forms/ImageUploadField'
|
||||
import LinkManagement from './forms/LinkManagement'
|
||||
|
||||
interface RelatedPlacesManagerProps {
|
||||
activity: Activity
|
||||
onUpdate: (activityId: string, relatedPlaces: RelatedPlace[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const RelatedPlacesManager = ({ activity, onUpdate, onClose }: RelatedPlacesManagerProps) => {
|
||||
const [mode, setMode] = useState<'view' | 'add' | 'edit'>('view')
|
||||
const [editingPlace, setEditingPlace] = useState<RelatedPlace | null>(null)
|
||||
const [newPlace, setNewPlace] = useState<Partial<RelatedPlace>>({
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
memo: '',
|
||||
})
|
||||
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null)
|
||||
const [uploadImages, setUploadImages] = useState<string[]>([])
|
||||
const [uploadLinks, setUploadLinks] = useState<{ url: string; label?: string }[]>([])
|
||||
|
||||
const handleEditPlace = (place: RelatedPlace) => {
|
||||
setEditingPlace(place)
|
||||
setNewPlace({
|
||||
name: place.name,
|
||||
description: place.description,
|
||||
address: place.address,
|
||||
memo: place.memo,
|
||||
category: place.category,
|
||||
})
|
||||
setCoords(place.coordinates || null)
|
||||
setUploadImages(place.images || [])
|
||||
setUploadLinks(place.links || [])
|
||||
setMode('edit')
|
||||
}
|
||||
|
||||
const handleUpdatePlace = () => {
|
||||
if (!editingPlace) return
|
||||
if (!newPlace.name) {
|
||||
alert('장소 이름을 입력해주세요!')
|
||||
return
|
||||
}
|
||||
|
||||
const validCoords = coords && coords.lat !== 0 && coords.lng !== 0 ? coords : undefined
|
||||
|
||||
const updatedPlace: RelatedPlace = {
|
||||
...editingPlace,
|
||||
name: newPlace.name,
|
||||
description: newPlace.description,
|
||||
address: newPlace.address,
|
||||
coordinates: validCoords,
|
||||
memo: newPlace.memo,
|
||||
category: newPlace.category,
|
||||
images: uploadImages.length > 0 ? uploadImages : undefined,
|
||||
links: uploadLinks.length > 0 ? uploadLinks : undefined,
|
||||
}
|
||||
|
||||
const updatedPlaces = (activity.relatedPlaces || []).map(p =>
|
||||
p.id === editingPlace.id ? updatedPlace : p
|
||||
)
|
||||
onUpdate(activity.id, updatedPlaces)
|
||||
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const handleAddPlace = () => {
|
||||
if (!newPlace.name) {
|
||||
alert('장소 이름을 입력해주세요!')
|
||||
return
|
||||
}
|
||||
|
||||
const validCoords = coords && coords.lat !== 0 && coords.lng !== 0 ? coords : undefined
|
||||
|
||||
const relatedPlace: RelatedPlace = {
|
||||
id: Date.now().toString(),
|
||||
name: newPlace.name,
|
||||
description: newPlace.description,
|
||||
address: newPlace.address,
|
||||
coordinates: validCoords,
|
||||
memo: newPlace.memo,
|
||||
willVisit: false,
|
||||
category: newPlace.category || 'other',
|
||||
images: uploadImages.length > 0 ? uploadImages : undefined,
|
||||
links: uploadLinks.length > 0 ? uploadLinks : undefined,
|
||||
}
|
||||
|
||||
const updatedPlaces = [...(activity.relatedPlaces || []), relatedPlace]
|
||||
onUpdate(activity.id, updatedPlaces)
|
||||
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingPlace(null)
|
||||
setNewPlace({
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
memo: '',
|
||||
})
|
||||
setCoords(null)
|
||||
setUploadImages([])
|
||||
setUploadLinks([])
|
||||
setMode('view')
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
resetForm()
|
||||
}
|
||||
|
||||
const toggleWillVisit = (placeId: string) => {
|
||||
const updatedPlaces = (activity.relatedPlaces || []).map((place) =>
|
||||
place.id === placeId
|
||||
? { ...place, willVisit: !place.willVisit }
|
||||
: place
|
||||
)
|
||||
onUpdate(activity.id, updatedPlaces)
|
||||
}
|
||||
|
||||
const handleDeletePlace = (placeId: string) => {
|
||||
if (!confirm('이 관련 장소를 삭제하시겠습니까?')) return
|
||||
|
||||
const updatedPlaces = (activity.relatedPlaces || []).filter((p) => p.id !== placeId)
|
||||
onUpdate(activity.id, updatedPlaces)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[2000] p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-kumamoto-primary text-white px-6 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">📍 주변 관광지 관리</h2>
|
||||
<p className="text-sm opacity-90 mt-1">{activity.title}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-2xl hover:bg-white/20 w-8 h-8 rounded flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 전환 */}
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'edit' || mode === 'add') {
|
||||
handleCancelEdit()
|
||||
} else {
|
||||
setMode('view')
|
||||
}
|
||||
}}
|
||||
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||
mode === 'view'
|
||||
? 'bg-white text-kumamoto-primary border-b-2 border-kumamoto-primary'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
📋 목록 보기 ({activity.relatedPlaces?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'edit') {
|
||||
handleCancelEdit()
|
||||
}
|
||||
setMode('add')
|
||||
}}
|
||||
className={`flex-1 px-4 py-3 font-medium transition-colors ${
|
||||
mode === 'add'
|
||||
? 'bg-white text-kumamoto-primary border-b-2 border-kumamoto-primary'
|
||||
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
➕ 새로 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 영역 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{mode === 'view' ? (
|
||||
// 목록 보기 모드
|
||||
<div>
|
||||
{activity.relatedPlaces && activity.relatedPlaces.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{activity.relatedPlaces.map((place, index) => (
|
||||
<div
|
||||
key={place.id}
|
||||
className={`border rounded-lg p-4 hover:shadow-md transition-all ${
|
||||
place.willVisit
|
||||
? 'bg-green-50 border-green-300'
|
||||
: 'bg-gray-50 border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* 체크박스 */}
|
||||
<div className="pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={place.willVisit || false}
|
||||
onChange={() => toggleWillVisit(place.id)}
|
||||
className="w-5 h-5 text-green-600 rounded focus:ring-2 focus:ring-green-500 cursor-pointer"
|
||||
title="가볼 곳으로 선택"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-kumamoto-primary text-white text-xs font-bold rounded-full">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="text-lg font-bold text-gray-800">{place.name}</h3>
|
||||
{place.category && (
|
||||
<span className={`px-2 py-0.5 text-white text-xs rounded-full ${
|
||||
place.category === 'restaurant' ? 'bg-orange-500' :
|
||||
place.category === 'attraction' ? 'bg-blue-500' :
|
||||
place.category === 'shopping' ? 'bg-pink-500' :
|
||||
place.category === 'accommodation' ? 'bg-purple-500' :
|
||||
'bg-gray-500'
|
||||
}`}>
|
||||
{place.category === 'restaurant' ? '🍴 식당' :
|
||||
place.category === 'attraction' ? '🏞️ 관광지' :
|
||||
place.category === 'shopping' ? '🛍️ 쇼핑' :
|
||||
place.category === 'accommodation' ? '🏨 숙소' :
|
||||
'📍 기타'}
|
||||
</span>
|
||||
)}
|
||||
{place.willVisit && (
|
||||
<span className="px-2 py-0.5 bg-green-500 text-white text-xs rounded-full">
|
||||
✓ 가볼 곳
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{place.address && (
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
📍 {place.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{place.coordinates && (
|
||||
<div className="text-xs text-purple-600 mb-2">
|
||||
🗺️ 좌표: {place.coordinates.lat.toFixed(4)}, {place.coordinates.lng.toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{place.description && (
|
||||
<div className="text-sm text-gray-700 bg-white rounded p-2 mb-2">
|
||||
{place.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{place.memo && (
|
||||
<div className="text-sm text-kumamoto-primary bg-blue-50 rounded p-2 italic mb-2">
|
||||
💡 {place.memo}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지 갤러리 */}
|
||||
{place.images && place.images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{place.images.map((img, imgIdx) => (
|
||||
<img
|
||||
key={imgIdx}
|
||||
src={`http://localhost:3000${img}`}
|
||||
alt={`${place.name} ${imgIdx + 1}`}
|
||||
className="w-full h-20 object-cover rounded border border-gray-200"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 링크 목록 */}
|
||||
{place.links && place.links.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{place.links.map((link, linkIdx) => (
|
||||
<a
|
||||
key={linkIdx}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-xs text-blue-600 hover:underline bg-blue-50 px-2 py-1 rounded truncate"
|
||||
>
|
||||
🔗 {link.label || link.url}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => handleEditPlace(place)}
|
||||
className="px-3 py-1 bg-blue-100 text-blue-700 hover:bg-blue-200 rounded text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePlace(place.id)}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<div className="text-5xl mb-4">📭</div>
|
||||
<p className="text-lg">등록된 관련 장소가 없습니다.</p>
|
||||
<button
|
||||
onClick={() => setMode('add')}
|
||||
className="mt-4 px-4 py-2 bg-kumamoto-primary text-white rounded hover:bg-kumamoto-secondary transition-colors"
|
||||
>
|
||||
첫 장소 추가하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// 추가/편집 모드 (통합된 폼)
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<div className={`border rounded-lg p-4 mb-6 ${
|
||||
mode === 'edit' ? 'bg-amber-50 border-amber-200' : 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl">{mode === 'edit' ? '✏️' : '💡'}</div>
|
||||
<div className="flex-1 text-sm text-gray-700">
|
||||
<p className="font-medium mb-1">
|
||||
{mode === 'edit' ? '주변 관광지 편집 중' : '주변 관광지 등록 팁'}
|
||||
</p>
|
||||
{mode === 'edit' ? (
|
||||
<p className="text-xs">
|
||||
"{editingPlace?.name}" 정보를 수정하고 있습니다.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="list-disc list-inside space-y-1 text-xs">
|
||||
<li>Google Maps에서 장소를 검색하고 우클릭하여 좌표를 복사할 수 있습니다</li>
|
||||
<li>메모 필드에는 방문 시간, 소요 시간, 추천 이유 등을 적어두세요</li>
|
||||
<li>나중에 Trip 페이지에서 지도에 표시됩니다</li>
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 장소 이름 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
장소 이름 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: 아소 화산 박물관"
|
||||
value={newPlace.name}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 주소 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
주소
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="예: 熊本県阿蘇市赤水"
|
||||
value={newPlace.address}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, address: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
카테고리
|
||||
</label>
|
||||
<select
|
||||
value={newPlace.category || 'other'}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, category: e.target.value as RelatedPlace['category'] })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
>
|
||||
<option value="restaurant">🍴 식당/맛집</option>
|
||||
<option value="attraction">🏞️ 관광지</option>
|
||||
<option value="shopping">🛍️ 쇼핑</option>
|
||||
<option value="accommodation">🏨 숙소</option>
|
||||
<option value="other">📍 기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 좌표 입력 - CoordinateInput 사용 */}
|
||||
<CoordinateInput
|
||||
coordinates={coords}
|
||||
onChange={setCoords}
|
||||
/>
|
||||
|
||||
{/* 설명 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="예: 아소산의 화산 활동과 역사를 전시하는 박물관"
|
||||
value={newPlace.description}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, description: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메모 / 팁 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
메모 / 팁
|
||||
</label>
|
||||
<textarea
|
||||
placeholder="예: 쿠사센리 바로 옆에 위치. 소요 시간 약 30분"
|
||||
value={newPlace.memo}
|
||||
onChange={(e) => setNewPlace({ ...newPlace, memo: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 이미지 업로드 - ImageUploadField 사용 */}
|
||||
<ImageUploadField
|
||||
images={uploadImages}
|
||||
onChange={setUploadImages}
|
||||
/>
|
||||
|
||||
{/* 링크 추가 - LinkManagement 사용 */}
|
||||
<LinkManagement
|
||||
links={uploadLinks}
|
||||
onChange={setUploadLinks}
|
||||
/>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
onClick={mode === 'edit' ? handleUpdatePlace : handleAddPlace}
|
||||
className={`flex-1 px-6 py-3 text-white rounded-lg hover:bg-opacity-90 font-medium transition-colors ${
|
||||
mode === 'edit' ? 'bg-kumamoto-blue' : 'bg-kumamoto-green'
|
||||
}`}
|
||||
>
|
||||
{mode === 'edit' ? '✓ 수정 완료' : '✓ 추가하기'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-medium transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RelatedPlacesManager
|
||||
239
src/components/RouteOptimizer.tsx
Normal file
239
src/components/RouteOptimizer.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useState } from 'react'
|
||||
import { googleDirectionsService, OptimizedItinerary, TravelModeOptions, KumamotoRoutePresets } from '../services/googleDirections'
|
||||
|
||||
interface RouteOptimizerProps {
|
||||
activities: any[]
|
||||
onOptimizedRoute?: (optimizedItinerary: OptimizedItinerary) => void
|
||||
onApplyOptimization?: (optimizedActivities: any[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const RouteOptimizer = ({
|
||||
activities,
|
||||
onOptimizedRoute,
|
||||
onApplyOptimization,
|
||||
className = ''
|
||||
}: RouteOptimizerProps) => {
|
||||
const [isOptimizing, setIsOptimizing] = useState(false)
|
||||
const [optimizedResult, setOptimizedResult] = useState<OptimizedItinerary | null>(null)
|
||||
const [selectedTravelMode, setSelectedTravelMode] = useState<string>('DRIVING')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 최적화 실행
|
||||
const handleOptimize = async () => {
|
||||
if (activities.length < 2) {
|
||||
setError('최소 2개 이상의 활동이 필요합니다')
|
||||
return
|
||||
}
|
||||
|
||||
setIsOptimizing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Google Maps API 초기화 확인
|
||||
if (typeof google === 'undefined' || !google.maps) {
|
||||
throw new Error('Google Maps API가 로드되지 않았습니다')
|
||||
}
|
||||
|
||||
googleDirectionsService.initialize()
|
||||
|
||||
const result = await googleDirectionsService.optimizeItinerary(activities, selectedTravelMode)
|
||||
setOptimizedResult(result)
|
||||
|
||||
if (onOptimizedRoute) {
|
||||
onOptimizedRoute(result)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Route optimization failed:', err)
|
||||
setError(err instanceof Error ? err.message : '경로 최적화에 실패했습니다')
|
||||
} finally {
|
||||
setIsOptimizing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 최적화 결과 적용
|
||||
const handleApplyOptimization = () => {
|
||||
if (optimizedResult && onApplyOptimization) {
|
||||
onApplyOptimization(optimizedResult.optimizedActivities)
|
||||
setOptimizedResult(null)
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 모드별 아이콘
|
||||
const getTravelModeIcon = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'DRIVING':
|
||||
return '🚗'
|
||||
case 'TRANSIT':
|
||||
return '🚌'
|
||||
case 'WALKING':
|
||||
return '🚶'
|
||||
default:
|
||||
return '🗺️'
|
||||
}
|
||||
}
|
||||
|
||||
// 좌표가 있는 활동 개수
|
||||
const activitiesWithCoords = activities.filter(activity =>
|
||||
activity.coordinates && activity.coordinates.lat && activity.coordinates.lng
|
||||
).length
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-4 ${className}`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800">🗺️ 경로 최적화</h3>
|
||||
<div className="text-sm text-gray-500">
|
||||
{activitiesWithCoords}/{activities.length}개 활동
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여행 모드 선택 */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
이동 수단 선택
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Object.entries(TravelModeOptions).map(([key, option]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedTravelMode(option.mode)}
|
||||
className={`p-3 text-sm rounded-lg border transition-colors ${
|
||||
selectedTravelMode === option.mode
|
||||
? 'bg-kumamoto-primary text-white border-kumamoto-primary'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg mb-1">{getTravelModeIcon(option.mode)}</div>
|
||||
<div className="font-medium">{option.label.replace(/🚗|🚌|🚶/, '').trim()}</div>
|
||||
<div className="text-xs opacity-75">{option.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최적화 버튼 */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={handleOptimize}
|
||||
disabled={isOptimizing || activitiesWithCoords < 2}
|
||||
className="w-full py-3 px-4 bg-kumamoto-primary text-white rounded-lg hover:bg-kumamoto-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isOptimizing ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
경로 계산 중...
|
||||
</div>
|
||||
) : (
|
||||
`${getTravelModeIcon(selectedTravelMode)} 최적 경로 계산`
|
||||
)}
|
||||
</button>
|
||||
|
||||
{activitiesWithCoords < 2 && (
|
||||
<p className="text-xs text-gray-500 mt-2 text-center">
|
||||
좌표 정보가 있는 활동이 최소 2개 필요합니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-red-700">
|
||||
<span>⚠️</span>
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최적화 결과 */}
|
||||
{optimizedResult && (
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h4 className="font-medium text-gray-800 mb-3">📊 최적화 결과</h4>
|
||||
|
||||
{/* 개선 효과 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
|
||||
<div className="text-sm text-green-700 font-medium">⏱️ 시간</div>
|
||||
<div className="text-lg font-bold text-green-800">
|
||||
{optimizedResult.timeSaved}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<div className="text-sm text-blue-700 font-medium">📏 거리</div>
|
||||
<div className="text-lg font-bold text-blue-800">
|
||||
{optimizedResult.distanceSaved}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 경로 정보 */}
|
||||
<div className="bg-gray-50 p-3 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-600">총 거리:</span>
|
||||
<span className="font-medium ml-2">{optimizedResult.routeResult.totalDistance}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-600">총 시간:</span>
|
||||
<span className="font-medium ml-2">{optimizedResult.routeResult.totalDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 최적화된 순서 */}
|
||||
<div className="mb-4">
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">🎯 최적화된 순서</h5>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{optimizedResult.optimizedActivities.map((activity, index) => (
|
||||
<div key={activity.id} className="flex items-center gap-3 p-2 bg-gray-50 rounded">
|
||||
<div className="w-6 h-6 bg-kumamoto-primary text-white rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800 truncate">
|
||||
{activity.title}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
{activity.time} • {activity.location}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleApplyOptimization}
|
||||
className="flex-1 py-2 px-4 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
✅ 최적화된 순서 적용
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOptimizedResult(null)}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 도움말 */}
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm text-blue-800">
|
||||
<div className="font-medium mb-1">💡 경로 최적화 팁</div>
|
||||
<ul className="text-xs space-y-1 text-blue-700">
|
||||
<li>• 좌표 정보가 있는 활동만 최적화됩니다</li>
|
||||
<li>• 시작점과 끝점은 고정되고 중간 경유지만 최적화됩니다</li>
|
||||
<li>• 실시간 교통 상황이 반영됩니다</li>
|
||||
<li>• {getTravelModeIcon(selectedTravelMode)} 선택한 이동 수단에 맞는 경로를 계산합니다</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RouteOptimizer
|
||||
428
src/components/SharedTripViewer.tsx
Normal file
428
src/components/SharedTripViewer.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { CollaborativeTrip, TripComment } from '../types'
|
||||
import { tripSharingService } from '../services/tripSharing'
|
||||
import { userAuthService } from '../services/userAuth'
|
||||
import { format, eachDayOfInterval } from 'date-fns'
|
||||
import UnifiedMap from './UnifiedMap'
|
||||
|
||||
const SharedTripViewer = () => {
|
||||
const { shareCode } = useParams<{ shareCode: string }>()
|
||||
const [collaborativeTrip, setCollaborativeTrip] = useState<CollaborativeTrip | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentDay, setCurrentDay] = useState(0)
|
||||
const [showComments, setShowComments] = useState(false)
|
||||
const [newComment, setNewComment] = useState('')
|
||||
const [isAddingComment, setIsAddingComment] = useState(false)
|
||||
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
|
||||
useEffect(() => {
|
||||
if (shareCode) {
|
||||
loadSharedTrip(shareCode)
|
||||
}
|
||||
}, [shareCode])
|
||||
|
||||
const loadSharedTrip = async (code: string) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = await tripSharingService.getTripByShareCode(code)
|
||||
|
||||
if (result.success && result.trip) {
|
||||
setCollaborativeTrip(result.trip)
|
||||
|
||||
// 현재 날짜에 해당하는 일차 찾기
|
||||
const today = new Date()
|
||||
const days = eachDayOfInterval({
|
||||
start: new Date(result.trip.trip.startDate),
|
||||
end: new Date(result.trip.trip.endDate),
|
||||
})
|
||||
|
||||
const todayIndex = days.findIndex(day =>
|
||||
format(day, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
if (todayIndex !== -1) {
|
||||
setCurrentDay(todayIndex)
|
||||
}
|
||||
} else {
|
||||
setError(result.message || '공유된 여행 계획을 불러올 수 없습니다')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('공유된 여행 계획을 불러오는 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!collaborativeTrip || !newComment.trim() || !currentUser) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsAddingComment(true)
|
||||
try {
|
||||
const result = await tripSharingService.addComment(collaborativeTrip.trip.id, newComment.trim())
|
||||
|
||||
if (result.success && result.comment) {
|
||||
setCollaborativeTrip({
|
||||
...collaborativeTrip,
|
||||
comments: [...collaborativeTrip.comments, result.comment]
|
||||
})
|
||||
setNewComment('')
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('댓글 추가 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsAddingComment(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
return format(new Date(date), 'M월 d일 (E)', { locale: undefined })
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-lg text-gray-600">공유된 여행 계획을 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !collaborativeTrip) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-medium text-gray-800 mb-2">
|
||||
{error || '공유된 여행 계획을 찾을 수 없습니다'}
|
||||
</div>
|
||||
<div className="text-gray-600 mb-4">
|
||||
링크가 만료되었거나 유효하지 않을 수 있습니다
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
메인으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { trip, share_link, comments } = collaborativeTrip
|
||||
const days = eachDayOfInterval({
|
||||
start: new Date(trip.startDate),
|
||||
end: new Date(trip.endDate),
|
||||
})
|
||||
|
||||
const currentDaySchedule = trip.schedule.find(
|
||||
(daySchedule) =>
|
||||
format(daySchedule.date, 'yyyy-MM-dd') === format(days[currentDay], 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
const canComment = share_link.permissions.can_comment && currentUser
|
||||
const canEdit = share_link.permissions.can_edit && currentUser
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold text-gray-800">{trip.title}</h1>
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
공유된 여행 계획
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>📍 {trip.destination.city}, {trip.destination.country}</span>
|
||||
<span>📅 {formatDate(trip.startDate)} - {formatDate(trip.endDate)}</span>
|
||||
<span>👥 {share_link.access_count}명이 조회</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{canComment && (
|
||||
<button
|
||||
onClick={() => setShowComments(!showComments)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
💬 댓글 ({comments.length})
|
||||
</button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Link
|
||||
to={`/plan/${trip.id}`}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
✏️ 편집하기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 권한 정보 */}
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="font-medium text-gray-700">공유 권한:</span>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
share_link.permissions.can_edit ? 'bg-red-100 text-red-800' :
|
||||
share_link.permissions.can_comment ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{share_link.permissions.can_edit ? '편집 가능' :
|
||||
share_link.permissions.can_comment ? '댓글 가능' : '보기 전용'}
|
||||
</span>
|
||||
{share_link.expires_at && (
|
||||
<span className="text-gray-500">
|
||||
만료: {formatDateTime(share_link.expires_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜 선택 탭 */}
|
||||
<div className="flex gap-2 mt-4 overflow-x-auto pb-2">
|
||||
{days.map((day, index) => (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setCurrentDay(index)}
|
||||
className={`px-4 py-2 rounded-lg whitespace-nowrap text-sm transition-colors ${
|
||||
currentDay === index
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{formatDate(day)}</div>
|
||||
<div className="text-xs opacity-75">
|
||||
{index + 1}일차
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 지도 영역 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div className="h-96 lg:h-[600px]">
|
||||
<UnifiedMap
|
||||
attractions={[]}
|
||||
currentDaySchedule={currentDaySchedule}
|
||||
showControls={false}
|
||||
className="h-full rounded-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 일정 및 댓글 영역 */}
|
||||
<div className="space-y-6">
|
||||
{/* 오늘의 일정 */}
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{formatDate(days[currentDay])} 일정
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{currentDaySchedule && currentDaySchedule.activities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{currentDaySchedule.activities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="p-3 bg-gray-50 rounded-lg border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-sm font-medium text-blue-600 mt-0.5">
|
||||
{activity.time}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-1">
|
||||
{activity.title}
|
||||
</h3>
|
||||
{activity.location && (
|
||||
<div className="text-sm text-gray-600 mb-1">
|
||||
📍 {activity.location}
|
||||
</div>
|
||||
)}
|
||||
{activity.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{activity.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<span className={`inline-block px-2 py-1 text-xs rounded-full ${
|
||||
activity.type === 'attraction' ? 'bg-green-100 text-green-800' :
|
||||
activity.type === 'food' ? 'bg-orange-100 text-orange-800' :
|
||||
activity.type === 'accommodation' ? 'bg-purple-100 text-purple-800' :
|
||||
activity.type === 'transport' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{activity.type === 'attraction' ? '관광' :
|
||||
activity.type === 'food' ? '식사' :
|
||||
activity.type === 'accommodation' ? '숙박' :
|
||||
activity.type === 'transport' ? '이동' : '기타'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">📅</div>
|
||||
<div className="text-gray-600">
|
||||
이 날짜에 계획된 일정이 없습니다
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여행 정보 */}
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800">여행 정보</h3>
|
||||
</div>
|
||||
<div className="p-4 space-y-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">목적지</span>
|
||||
<span className="font-medium">
|
||||
{trip.destination.city}, {trip.destination.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">기간</span>
|
||||
<span className="font-medium">
|
||||
{format(new Date(trip.startDate), 'M/d')} - {format(new Date(trip.endDate), 'M/d')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">총 일정</span>
|
||||
<span className="font-medium">
|
||||
{trip.schedule.reduce((total, day) => total + day.activities.length, 0)}개
|
||||
</span>
|
||||
</div>
|
||||
{trip.description && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<div className="text-gray-600 text-xs mb-1">설명</div>
|
||||
<div className="text-gray-800">{trip.description}</div>
|
||||
</div>
|
||||
)}
|
||||
{trip.tags.length > 0 && (
|
||||
<div className="pt-2 border-t border-gray-200">
|
||||
<div className="text-gray-600 text-xs mb-2">태그</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{trip.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 섹션 */}
|
||||
{canComment && (
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800">
|
||||
댓글 ({comments.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* 댓글 작성 */}
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="이 여행 계획에 대한 의견을 남겨보세요..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<div className="text-xs text-gray-500">
|
||||
{currentUser ? `${currentUser.name}으로 댓글 작성` : '로그인이 필요합니다'}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={!newComment.trim() || isAddingComment}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isAddingComment ? '작성 중...' : '댓글 작성'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 댓글 목록 */}
|
||||
{comments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="border border-gray-200 rounded p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="font-medium text-gray-800 text-sm">
|
||||
{comment.user_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{formatDateTime(comment.created_at)}
|
||||
{comment.is_edited && ' (수정됨)'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-gray-700 text-sm">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6">
|
||||
<div className="text-gray-500 text-sm">
|
||||
아직 댓글이 없습니다. 첫 번째 댓글을 남겨보세요!
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SharedTripViewer
|
||||
55
src/components/TabNavigation.tsx
Normal file
55
src/components/TabNavigation.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface Tab {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
content: ReactNode
|
||||
badge?: number
|
||||
}
|
||||
|
||||
interface TabNavigationProps {
|
||||
tabs: Tab[]
|
||||
activeTab: string
|
||||
onTabChange: (tabId: string) => void
|
||||
}
|
||||
|
||||
const TabNavigation = ({ tabs, activeTab, onTabChange }: TabNavigationProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="border-b border-gray-200">
|
||||
<div className="flex">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors relative ${
|
||||
activeTab === tab.id
|
||||
? 'text-kumamoto-primary border-b-2 border-kumamoto-primary bg-kumamoto-light/20'
|
||||
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-lg">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge !== undefined && tab.badge > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold text-white bg-red-500 rounded-full">
|
||||
{tab.badge > 99 ? '99+' : tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<div className="p-6">
|
||||
{tabs.find(tab => tab.id === activeTab)?.content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabNavigation
|
||||
485
src/components/TemplateBrowser.tsx
Normal file
485
src/components/TemplateBrowser.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
import { useState } from 'react'
|
||||
import { tripTemplates, getTemplatesByCategory, getRecommendedTemplates, TripTemplate } from '../data/tripTemplates'
|
||||
import { TripCategories } from '../services/tripManager'
|
||||
import { tripManagerService } from '../services/tripManager'
|
||||
|
||||
interface TemplateBrowserProps {
|
||||
onTemplateSelect?: (template: TripTemplate) => void
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TemplateBrowser = ({ onTemplateSelect, onClose, className = '' }: TemplateBrowserProps) => {
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TripTemplate | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const getFilteredTemplates = () => {
|
||||
if (selectedCategory === 'all') {
|
||||
return tripTemplates
|
||||
} else if (selectedCategory === 'recommended') {
|
||||
return getRecommendedTemplates()
|
||||
} else {
|
||||
return getTemplatesByCategory(selectedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateFromTemplate = async (template: TripTemplate, customDates?: { startDate: Date; endDate: Date }) => {
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
// 템플릿을 기반으로 새 여행 계획 생성
|
||||
const startDate = customDates?.startDate || new Date()
|
||||
const endDate = customDates?.endDate || new Date(Date.now() + (template.duration - 1) * 24 * 60 * 60 * 1000)
|
||||
|
||||
const result = await tripManagerService.createTrip({
|
||||
title: template.title,
|
||||
description: template.description,
|
||||
destination: template.destination,
|
||||
startDate,
|
||||
endDate,
|
||||
template_category: template.category,
|
||||
tags: template.tags,
|
||||
is_public: false
|
||||
})
|
||||
|
||||
if (result.success && result.trip) {
|
||||
// 템플릿의 샘플 일정을 새 여행에 적용
|
||||
const updatedTrip = {
|
||||
...result.trip,
|
||||
schedule: template.sample_schedule.map((day, index) => ({
|
||||
...day,
|
||||
date: new Date(startDate.getTime() + index * 24 * 60 * 60 * 1000)
|
||||
}))
|
||||
}
|
||||
|
||||
await tripManagerService.updateTrip(result.trip.id, updatedTrip)
|
||||
|
||||
alert(`"${template.title}" 템플릿으로 여행 계획이 생성되었습니다!`)
|
||||
|
||||
if (onTemplateSelect) {
|
||||
onTemplateSelect(template)
|
||||
}
|
||||
if (onClose) {
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
alert(result.message || '템플릿 적용 중 오류가 발생했습니다')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('템플릿 적용 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatBudget = (template: TripTemplate) => {
|
||||
const { min, max, currency } = template.budget_range
|
||||
const symbol = currency === 'KRW' ? '₩' : currency === 'JPY' ? '¥' : '$'
|
||||
|
||||
if (currency === 'KRW') {
|
||||
return `${symbol}${(min / 10000).toFixed(0)}-${(max / 10000).toFixed(0)}만원`
|
||||
} else {
|
||||
return `${symbol}${min.toLocaleString()}-${max.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
|
||||
const getDifficultyInfo = (difficulty: string) => {
|
||||
const difficultyMap = {
|
||||
easy: { label: '쉬움', color: 'bg-green-100 text-green-800', icon: '😊' },
|
||||
medium: { label: '보통', color: 'bg-yellow-100 text-yellow-800', icon: '🙂' },
|
||||
hard: { label: '어려움', color: 'bg-red-100 text-red-800', icon: '😅' }
|
||||
}
|
||||
return difficultyMap[difficulty as keyof typeof difficultyMap] || difficultyMap.medium
|
||||
}
|
||||
|
||||
const filteredTemplates = getFilteredTemplates()
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-xl ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">🌟 여행 템플릿</h2>
|
||||
<p className="text-gray-600 mt-1">검증된 여행 코스로 쉽고 빠르게 계획을 세워보세요</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedCategory('all')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === 'all'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
🌍 전체
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedCategory('recommended')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === 'recommended'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
⭐ 추천
|
||||
</button>
|
||||
{Object.entries(TripCategories).map(([key, category]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setSelectedCategory(key)}
|
||||
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
|
||||
selectedCategory === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
<div className="p-6">
|
||||
{filteredTemplates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-4xl mb-4">🔍</div>
|
||||
<div className="text-lg font-medium text-gray-800 mb-2">
|
||||
해당 카테고리의 템플릿이 없습니다
|
||||
</div>
|
||||
<div className="text-gray-600">
|
||||
다른 카테고리를 선택해보세요
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map((template) => {
|
||||
const difficultyInfo = getDifficultyInfo(template.difficulty)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={template.id}
|
||||
className="border border-gray-200 rounded-lg overflow-hidden hover:shadow-lg transition-shadow cursor-pointer"
|
||||
onClick={() => setSelectedTemplate(template)}
|
||||
>
|
||||
{/* 템플릿 이미지 */}
|
||||
<div className="relative h-48 bg-gray-200">
|
||||
<img
|
||||
src={template.thumbnail}
|
||||
alt={template.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute top-3 left-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
TripCategories[template.category]?.color || 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{TripCategories[template.category]?.label || template.category}
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-3 right-3">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${difficultyInfo.color}`}>
|
||||
{difficultyInfo.icon} {difficultyInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 정보 */}
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-2">
|
||||
{template.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">📍 위치</span>
|
||||
<span className="font-medium">
|
||||
{template.destination.city}, {template.destination.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">⏱️ 기간</span>
|
||||
<span className="font-medium">{template.duration}일</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">💰 예산</span>
|
||||
<span className="font-medium">{formatBudget(template)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500">🌸 최적 시기</span>
|
||||
<span className="font-medium text-xs">{template.best_time}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{template.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
{template.tags.length > 3 && (
|
||||
<span className="inline-block px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded">
|
||||
+{template.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 템플릿 상세 모달 */}
|
||||
{selectedTemplate && (
|
||||
<TemplateDetailModal
|
||||
template={selectedTemplate}
|
||||
onClose={() => setSelectedTemplate(null)}
|
||||
onCreateFromTemplate={handleCreateFromTemplate}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 템플릿 상세 모달
|
||||
const TemplateDetailModal = ({
|
||||
template,
|
||||
onClose,
|
||||
onCreateFromTemplate,
|
||||
isCreating
|
||||
}: {
|
||||
template: TripTemplate
|
||||
onClose: () => void
|
||||
onCreateFromTemplate: (template: TripTemplate, dates?: { startDate: Date; endDate: Date }) => void
|
||||
isCreating: boolean
|
||||
}) => {
|
||||
const [customDates, setCustomDates] = useState({
|
||||
startDate: new Date(),
|
||||
endDate: new Date(Date.now() + (template.duration - 1) * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
|
||||
const difficultyInfo = getDifficultyInfo(template.difficulty)
|
||||
|
||||
const formatBudget = (template: TripTemplate) => {
|
||||
const { min, max, currency } = template.budget_range
|
||||
const symbol = currency === 'KRW' ? '₩' : currency === 'JPY' ? '¥' : '$'
|
||||
|
||||
if (currency === 'KRW') {
|
||||
return `${symbol}${(min / 10000).toFixed(0)}-${(max / 10000).toFixed(0)}만원`
|
||||
} else {
|
||||
return `${symbol}${min.toLocaleString()}-${max.toLocaleString()}`
|
||||
}
|
||||
}
|
||||
|
||||
const getDifficultyInfo = (difficulty: string) => {
|
||||
const difficultyMap = {
|
||||
easy: { label: '쉬움', color: 'bg-green-100 text-green-800', icon: '😊' },
|
||||
medium: { label: '보통', color: 'bg-yellow-100 text-yellow-800', icon: '🙂' },
|
||||
hard: { label: '어려움', color: 'bg-red-100 text-red-800', icon: '😅' }
|
||||
}
|
||||
return difficultyMap[difficulty as keyof typeof difficultyMap] || difficultyMap.medium
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src={template.thumbnail}
|
||||
alt={template.title}
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40"></div>
|
||||
<div className="absolute bottom-4 left-4 text-white">
|
||||
<h2 className="text-3xl font-bold mb-2">{template.title}</h2>
|
||||
<p className="text-lg opacity-90">{template.description}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl bg-black bg-opacity-50 rounded-full w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-2xl font-bold text-blue-600">{template.duration}일</div>
|
||||
<div className="text-sm text-gray-600">여행 기간</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-lg font-bold text-green-600">{formatBudget(template)}</div>
|
||||
<div className="text-sm text-gray-600">예상 예산</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${difficultyInfo.color}`}>
|
||||
{difficultyInfo.icon} {difficultyInfo.label}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 mt-1">난이도</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-bold text-purple-600">{template.best_time}</div>
|
||||
<div className="text-sm text-gray-600">최적 시기</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 주요 볼거리 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">🎯 주요 볼거리</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{template.highlights.map((highlight, index) => (
|
||||
<div key={index} className="flex items-start gap-3 p-3 bg-blue-50 rounded-lg">
|
||||
<div className="w-6 h-6 bg-blue-600 text-white rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="text-sm text-blue-800">{highlight}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 샘플 일정 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">📅 샘플 일정</h3>
|
||||
<div className="space-y-4">
|
||||
{template.sample_schedule.map((day, dayIndex) => (
|
||||
<div key={dayIndex} className="border border-gray-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-800 mb-3">
|
||||
Day {dayIndex + 1} ({day.activities.length}개 일정)
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{day.activities.map((activity, actIndex) => (
|
||||
<div key={actIndex} className="flex items-center gap-3 text-sm">
|
||||
<span className="text-blue-600 font-medium w-12">{activity.time}</span>
|
||||
<span className="flex-1">{activity.title}</span>
|
||||
<span className="text-gray-500 text-xs">{activity.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여행 팁 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">💡 여행 팁</h3>
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<ul className="space-y-2">
|
||||
{template.tips.map((tip, index) => (
|
||||
<li key={index} className="flex items-start gap-2 text-sm text-yellow-800">
|
||||
<span className="text-yellow-600 mt-0.5">•</span>
|
||||
<span>{tip}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 태그 */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-3">🏷️ 태그</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{template.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜 선택 및 생성 버튼 */}
|
||||
<div className="border-t border-gray-200 pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">📅 여행 날짜 선택</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
출발일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={customDates.startDate.toISOString().split('T')[0]}
|
||||
onChange={(e) => setCustomDates({
|
||||
...customDates,
|
||||
startDate: new Date(e.target.value)
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
도착일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={customDates.endDate.toISOString().split('T')[0]}
|
||||
onChange={(e) => setCustomDates({
|
||||
...customDates,
|
||||
endDate: new Date(e.target.value)
|
||||
})}
|
||||
min={customDates.startDate.toISOString().split('T')[0]}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-6 py-3 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCreateFromTemplate(template, customDates)}
|
||||
disabled={isCreating}
|
||||
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
생성 중...
|
||||
</div>
|
||||
) : (
|
||||
'🚀 이 템플릿으로 여행 만들기'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplateBrowser
|
||||
@@ -1,6 +1,11 @@
|
||||
import { format, eachDayOfInterval } from 'date-fns'
|
||||
import { TravelPlan, Activity } from '../types'
|
||||
import { TravelPlan, Activity, RelatedPlace } from '../types'
|
||||
import { useState } from 'react'
|
||||
import RelatedPlacesManager from './RelatedPlacesManager'
|
||||
import ActivityEditor from './ActivityEditor'
|
||||
import LocationSelector from './forms/LocationSelector'
|
||||
// import RouteOptimizer from './RouteOptimizer' // 임시 비활성화
|
||||
import { OptimizedItinerary } from '../services/googleDirections'
|
||||
|
||||
interface TimelineProps {
|
||||
travelPlan: TravelPlan
|
||||
@@ -13,6 +18,7 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
end: travelPlan.endDate,
|
||||
})
|
||||
|
||||
const [currentDayIndex, setCurrentDayIndex] = useState(0)
|
||||
const [selectedDay, setSelectedDay] = useState<Date | null>(null)
|
||||
const [editingTime, setEditingTime] = useState<string | null>(null)
|
||||
const [editingTimeValue, setEditingTimeValue] = useState('')
|
||||
@@ -23,9 +29,28 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
location: '',
|
||||
type: 'other',
|
||||
})
|
||||
const [showRouteOptimizer, setShowRouteOptimizer] = useState(false)
|
||||
|
||||
const [customCoordinates, setCustomCoordinates] = useState<{ lat: number; lng: number } | null>(null)
|
||||
|
||||
// 관련 장소 관리 모달
|
||||
const [managingActivity, setManagingActivity] = useState<Activity | null>(null)
|
||||
|
||||
// 일정 편집 모달
|
||||
const [editingActivity, setEditingActivity] = useState<Activity | null>(null)
|
||||
|
||||
const addActivity = (date: Date) => {
|
||||
if (!newActivity.time || !newActivity.title) return
|
||||
if (!newActivity.time || !newActivity.title) {
|
||||
alert('시간과 제목을 입력해주세요!')
|
||||
return
|
||||
}
|
||||
|
||||
// 좌표가 유효한지 확인 (0이 아닌 값이 있어야 함)
|
||||
const validCoordinates = customCoordinates &&
|
||||
customCoordinates.lat !== 0 &&
|
||||
customCoordinates.lng !== 0
|
||||
? customCoordinates
|
||||
: undefined
|
||||
|
||||
const activity: Activity = {
|
||||
id: Date.now().toString(),
|
||||
@@ -34,6 +59,7 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
description: newActivity.description,
|
||||
location: newActivity.location,
|
||||
type: newActivity.type as Activity['type'],
|
||||
coordinates: validCoordinates,
|
||||
}
|
||||
|
||||
const updatedSchedule = [...travelPlan.schedule]
|
||||
@@ -65,6 +91,7 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
location: '',
|
||||
type: 'other',
|
||||
})
|
||||
setCustomCoordinates(null)
|
||||
setSelectedDay(null)
|
||||
}
|
||||
|
||||
@@ -77,9 +104,13 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
}
|
||||
|
||||
const getDayLabel = (date: Date, index: number) => {
|
||||
const dayNames = ['월', '화', '수', '목', '금', '토', '일']
|
||||
const dayNames = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const dayName = dayNames[date.getDay()]
|
||||
return `${index + 1}일차 (${dayName})`
|
||||
return `${index + 1}일차 ${dayName}요일`
|
||||
}
|
||||
|
||||
const formatDateShort = (date: Date) => {
|
||||
return format(date, 'M월 d일')
|
||||
}
|
||||
|
||||
const updateActivityTime = (activityId: string, newTime: string) => {
|
||||
@@ -104,7 +135,11 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
setEditingTime(null)
|
||||
}
|
||||
|
||||
const deleteActivity = (activityId: string) => {
|
||||
const deleteActivity = (activityId: string, activityTitle: string) => {
|
||||
if (!confirm(`"${activityTitle}" 일정을 삭제하시겠습니까?\n\n⚠️ 삭제된 일정은 복구할 수 없습니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const updatedSchedule = travelPlan.schedule.map((daySchedule) => ({
|
||||
...daySchedule,
|
||||
activities: daySchedule.activities.filter(
|
||||
@@ -118,190 +153,348 @@ const Timeline = ({ travelPlan, setTravelPlan }: TimelineProps) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 경로 최적화 결과 처리
|
||||
const handleOptimizedRoute = (optimizedItinerary: OptimizedItinerary) => {
|
||||
console.log('Optimized route:', optimizedItinerary)
|
||||
// 최적화 결과를 사용자에게 보여주기 위해 상태에 저장
|
||||
}
|
||||
|
||||
// 최적화된 순서 적용
|
||||
const handleApplyOptimization = (optimizedActivities: any[]) => {
|
||||
if (!selectedDay) return
|
||||
|
||||
const dayKey = format(selectedDay, 'yyyy-MM-dd')
|
||||
const updatedSchedule = travelPlan.schedule.map((daySchedule) => {
|
||||
if (format(daySchedule.date, 'yyyy-MM-dd') === dayKey) {
|
||||
return {
|
||||
...daySchedule,
|
||||
activities: optimizedActivities
|
||||
}
|
||||
}
|
||||
return daySchedule
|
||||
})
|
||||
|
||||
setTravelPlan({
|
||||
...travelPlan,
|
||||
schedule: updatedSchedule,
|
||||
})
|
||||
|
||||
setShowRouteOptimizer(false)
|
||||
alert('경로가 최적화되었습니다! 🎉')
|
||||
}
|
||||
|
||||
const startEditTime = (activityId: string, currentTime: string) => {
|
||||
setEditingTime(activityId)
|
||||
setEditingTimeValue(currentTime)
|
||||
}
|
||||
|
||||
// 관련 장소 업데이트
|
||||
const updateRelatedPlaces = (activityId: string, relatedPlaces: RelatedPlace[]) => {
|
||||
const updatedSchedule = travelPlan.schedule.map((daySchedule) => ({
|
||||
...daySchedule,
|
||||
activities: daySchedule.activities.map((activity) =>
|
||||
activity.id === activityId
|
||||
? { ...activity, relatedPlaces }
|
||||
: activity
|
||||
),
|
||||
}))
|
||||
|
||||
setTravelPlan({
|
||||
...travelPlan,
|
||||
schedule: updatedSchedule,
|
||||
})
|
||||
}
|
||||
|
||||
// 일정 업데이트
|
||||
const updateActivity = (updatedActivity: Activity) => {
|
||||
const updatedSchedule = travelPlan.schedule.map((daySchedule) => ({
|
||||
...daySchedule,
|
||||
activities: daySchedule.activities.map((activity) =>
|
||||
activity.id === updatedActivity.id
|
||||
? updatedActivity
|
||||
: activity
|
||||
),
|
||||
}))
|
||||
|
||||
// 시간 순으로 다시 정렬
|
||||
updatedSchedule.forEach((daySchedule) => {
|
||||
daySchedule.activities.sort((a, b) => a.time.localeCompare(b.time))
|
||||
})
|
||||
|
||||
setTravelPlan({
|
||||
...travelPlan,
|
||||
schedule: updatedSchedule,
|
||||
})
|
||||
}
|
||||
|
||||
const currentDay = days[currentDayIndex]
|
||||
const activities = getDaySchedule(currentDay)
|
||||
const isAddingActivity = selectedDay?.getTime() === currentDay.getTime()
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">📅 여행 일정</h2>
|
||||
<div className="p-6">
|
||||
{/* 헤더는 부모 컴포넌트에서 처리 */}
|
||||
|
||||
<div className="space-y-6">
|
||||
{days.map((day, index) => {
|
||||
const activities = getDaySchedule(day)
|
||||
const isSelected = selectedDay?.getTime() === day.getTime()
|
||||
{/* 날짜 탭 */}
|
||||
<div className="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
{days.map((day, index) => (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => {
|
||||
setCurrentDayIndex(index)
|
||||
setSelectedDay(null) // 탭 변경시 추가 폼 닫기
|
||||
}}
|
||||
className={`px-4 py-2 rounded-lg whitespace-nowrap transition-all ${
|
||||
currentDayIndex === index
|
||||
? 'bg-kumamoto-blue text-white shadow-md'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold">{formatDateShort(day)}</div>
|
||||
<div className="text-xs mt-0.5">{getDayLabel(day, index)}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toISOString()}
|
||||
className="border-l-4 border-kumamoto-blue pl-4"
|
||||
{/* 현재 선택된 날짜의 일정 */}
|
||||
<div className="border-l-4 border-kumamoto-blue pl-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-800">
|
||||
{format(currentDay, 'yyyy년 M월 d일')} {getDayLabel(currentDay, currentDayIndex)}
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{/* 경로 최적화 버튼 */}
|
||||
{activities.length >= 2 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedDay(currentDay)
|
||||
setShowRouteOptimizer(!showRouteOptimizer)
|
||||
}}
|
||||
className={`px-3 py-2 text-sm rounded transition-colors ${
|
||||
showRouteOptimizer
|
||||
? 'bg-green-600 text-white'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
}`}
|
||||
>
|
||||
🗺️ 경로 최적화
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedDay(isAddingActivity ? null : currentDay)}
|
||||
className="px-4 py-2 bg-kumamoto-blue text-white rounded hover:bg-opacity-90 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{format(day, 'yyyy년 M월 d일')} ({getDayLabel(day, index)})
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedDay(isSelected ? null : day)}
|
||||
className="px-3 py-1 bg-kumamoto-blue text-white rounded hover:bg-opacity-90 text-sm"
|
||||
>
|
||||
{isSelected ? '취소' : '+ 일정 추가'}
|
||||
</button>
|
||||
</div>
|
||||
{isAddingActivity ? '취소' : '+ 일정 추가'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded-lg space-y-3">
|
||||
<input
|
||||
type="time"
|
||||
value={newActivity.time}
|
||||
onChange={(e) =>
|
||||
setNewActivity({ ...newActivity, time: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="시간"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newActivity.title}
|
||||
onChange={(e) =>
|
||||
setNewActivity({ ...newActivity, title: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="일정 제목"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newActivity.location}
|
||||
onChange={(e) =>
|
||||
setNewActivity({
|
||||
...newActivity,
|
||||
location: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="장소 (선택)"
|
||||
/>
|
||||
<textarea
|
||||
value={newActivity.description}
|
||||
onChange={(e) =>
|
||||
setNewActivity({
|
||||
...newActivity,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="설명 (선택)"
|
||||
rows={2}
|
||||
/>
|
||||
<select
|
||||
value={newActivity.type}
|
||||
onChange={(e) =>
|
||||
setNewActivity({
|
||||
...newActivity,
|
||||
type: e.target.value as Activity['type'],
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="attraction">관광지</option>
|
||||
<option value="food">식사</option>
|
||||
<option value="accommodation">숙소</option>
|
||||
<option value="transport">교통</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => addActivity(day)}
|
||||
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
추가하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{isAddingActivity && (
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded-lg space-y-3">
|
||||
<input
|
||||
type="time"
|
||||
value={newActivity.time}
|
||||
onChange={(e) =>
|
||||
setNewActivity({ ...newActivity, time: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="시간"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newActivity.title}
|
||||
onChange={(e) =>
|
||||
setNewActivity({ ...newActivity, title: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="일정 제목"
|
||||
/>
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{activities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="bg-gray-50 p-3 rounded-lg flex items-start gap-3 group"
|
||||
>
|
||||
{editingTime === activity.id ? (
|
||||
<div className="min-w-[80px]">
|
||||
<input
|
||||
type="time"
|
||||
value={editingTimeValue}
|
||||
onChange={(e) => setEditingTimeValue(e.target.value)}
|
||||
onBlur={() => updateActivityTime(activity.id, editingTimeValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateActivityTime(activity.id, editingTimeValue)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingTime(null)
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1 border border-kumamoto-blue rounded text-sm font-semibold text-kumamoto-blue"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="font-semibold text-kumamoto-blue min-w-[60px] cursor-pointer hover:text-kumamoto-green transition-colors"
|
||||
onClick={() => startEditTime(activity.id, activity.time)}
|
||||
title="시간을 클릭하여 수정"
|
||||
>
|
||||
{activity.time}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">
|
||||
{activity.title}
|
||||
</div>
|
||||
{activity.location && (
|
||||
<div className="text-sm text-gray-600">
|
||||
📍 {activity.location}
|
||||
</div>
|
||||
)}
|
||||
{activity.description && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{activity.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="inline-block px-2 py-0.5 bg-kumamoto-light text-xs rounded">
|
||||
{activity.type === 'attraction'
|
||||
? '관광지'
|
||||
: activity.type === 'food'
|
||||
? '식사'
|
||||
: activity.type === 'accommodation'
|
||||
? '숙소'
|
||||
: activity.type === 'transport'
|
||||
? '교통'
|
||||
: '기타'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteActivity(activity.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 text-xs transition-opacity"
|
||||
title="일정 삭제"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">아직 일정이 없습니다.</p>
|
||||
)}
|
||||
{/* 장소/좌표 입력 섹션 - LocationSelector 사용 */}
|
||||
<div className="border border-gray-300 rounded p-3 bg-white">
|
||||
<LocationSelector
|
||||
location={newActivity.location}
|
||||
coordinates={customCoordinates}
|
||||
onLocationChange={(location) => setNewActivity({ ...newActivity, location })}
|
||||
onCoordinatesChange={setCustomCoordinates}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
<textarea
|
||||
value={newActivity.description}
|
||||
onChange={(e) =>
|
||||
setNewActivity({
|
||||
...newActivity,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
placeholder="설명 (선택)"
|
||||
rows={2}
|
||||
/>
|
||||
<select
|
||||
value={newActivity.type}
|
||||
onChange={(e) =>
|
||||
setNewActivity({
|
||||
...newActivity,
|
||||
type: e.target.value as Activity['type'],
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="attraction">관광지</option>
|
||||
<option value="food">식사</option>
|
||||
<option value="accommodation">숙소</option>
|
||||
<option value="transport">교통</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => addActivity(currentDay)}
|
||||
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
추가하기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 경로 최적화 패널 */}
|
||||
{/* 임시 비활성화 - Google Maps API 이슈로 인해
|
||||
{showRouteOptimizer && selectedDay && (
|
||||
<div className="mb-4">
|
||||
<RouteOptimizer
|
||||
activities={activities}
|
||||
onOptimizedRoute={handleOptimizedRoute}
|
||||
onApplyOptimization={handleApplyOptimization}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
*/}
|
||||
|
||||
{activities.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{activities.map((activity) => {
|
||||
const hasRelatedPlaces = activity.relatedPlaces && activity.relatedPlaces.length > 0
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="bg-gray-50 p-2 rounded-lg flex items-start gap-2 group hover:bg-gray-100 cursor-pointer transition-colors text-sm"
|
||||
onClick={() => setEditingActivity(activity)}
|
||||
>
|
||||
{editingTime === activity.id ? (
|
||||
<div className="min-w-[80px]">
|
||||
<input
|
||||
type="time"
|
||||
value={editingTimeValue}
|
||||
onChange={(e) => setEditingTimeValue(e.target.value)}
|
||||
onBlur={() => updateActivityTime(activity.id, editingTimeValue)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
updateActivityTime(activity.id, editingTimeValue)
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingTime(null)
|
||||
}
|
||||
}}
|
||||
className="w-full px-2 py-1 border border-kumamoto-blue rounded text-sm font-semibold text-kumamoto-blue"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="font-semibold text-kumamoto-blue min-w-[60px] cursor-pointer hover:text-kumamoto-green transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
startEditTime(activity.id, activity.time)
|
||||
}}
|
||||
title="시간을 클릭하여 수정"
|
||||
>
|
||||
{activity.time}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">
|
||||
{activity.title}
|
||||
{hasRelatedPlaces && (
|
||||
<span className="ml-2 text-xs bg-green-100 text-green-700 px-2 py-0.5 rounded">
|
||||
+{activity.relatedPlaces?.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{activity.location && (
|
||||
<div className="text-sm text-gray-600">
|
||||
📍 {activity.location}
|
||||
</div>
|
||||
)}
|
||||
{activity.coordinates && (
|
||||
<div className="text-xs text-purple-600">
|
||||
🗺️ 지도 핀: {activity.coordinates.lat.toFixed(4)}, {activity.coordinates.lng.toFixed(4)}
|
||||
</div>
|
||||
)}
|
||||
{activity.description && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{activity.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="inline-block px-2 py-0.5 bg-kumamoto-light text-xs rounded">
|
||||
{activity.type === 'attraction'
|
||||
? '관광지'
|
||||
: activity.type === 'food'
|
||||
? '식사'
|
||||
: activity.type === 'accommodation'
|
||||
? '숙소'
|
||||
: activity.type === 'transport'
|
||||
? '교통'
|
||||
: '기타'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManagingActivity(activity)
|
||||
}}
|
||||
className="px-2 py-0.5 bg-blue-100 text-blue-700 hover:bg-blue-200 text-xs rounded transition-colors"
|
||||
>
|
||||
🏞️ 주변 관광지 ({activity.relatedPlaces?.length || 0})
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deleteActivity(activity.id, activity.title)
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-red-500 hover:text-red-700 text-xs transition-opacity"
|
||||
title="일정 삭제"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-400 text-sm">아직 일정이 없습니다.</p>
|
||||
)}
|
||||
|
||||
{/* 관련 장소 관리 모달 */}
|
||||
{managingActivity && (
|
||||
<RelatedPlacesManager
|
||||
activity={managingActivity}
|
||||
onUpdate={updateRelatedPlaces}
|
||||
onClose={() => setManagingActivity(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 일정 편집 모달 */}
|
||||
{editingActivity && (
|
||||
<ActivityEditor
|
||||
activity={editingActivity}
|
||||
onUpdate={updateActivity}
|
||||
onClose={() => setEditingActivity(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Timeline
|
||||
|
||||
|
||||
136
src/components/TravelDashboard.tsx
Normal file
136
src/components/TravelDashboard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { TravelPlan } from '../types'
|
||||
|
||||
interface TravelDashboardProps {
|
||||
travelPlan: TravelPlan
|
||||
}
|
||||
|
||||
const TravelDashboard = ({ travelPlan }: TravelDashboardProps) => {
|
||||
// 진행률 계산
|
||||
const totalActivities = travelPlan.schedule.reduce((sum, day) => sum + day.activities.length, 0)
|
||||
const completedActivities = 0 // TODO: 완료된 활동 수 계산
|
||||
const progressPercentage = totalActivities > 0 ? Math.round((completedActivities / totalActivities) * 100) : 0
|
||||
|
||||
// 예산 계산
|
||||
const totalBudget = Object.values(travelPlan.budget).reduce((sum, amount) => sum + amount, 0)
|
||||
const budgetCategories = [
|
||||
{ name: '숙박', amount: travelPlan.budget.accommodation, color: 'bg-blue-500' },
|
||||
{ name: '식사', amount: travelPlan.budget.food, color: 'bg-green-500' },
|
||||
{ name: '교통', amount: travelPlan.budget.transportation, color: 'bg-yellow-500' },
|
||||
{ name: '쇼핑', amount: travelPlan.budget.shopping, color: 'bg-purple-500' },
|
||||
{ name: '활동', amount: travelPlan.budget.activities, color: 'bg-red-500' },
|
||||
]
|
||||
|
||||
// 여행 일수 계산
|
||||
const startDate = new Date(travelPlan.startDate)
|
||||
const endDate = new Date(travelPlan.endDate)
|
||||
const tripDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1
|
||||
|
||||
// D-Day 계산
|
||||
const today = new Date()
|
||||
const dDay = Math.ceil((startDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* 여행 개요 */}
|
||||
<div className="md:col-span-2">
|
||||
<h2 className="text-xl font-bold text-gray-800 mb-4">📋 여행 개요</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">여행 기간</span>
|
||||
<span className="font-medium">
|
||||
{startDate.getMonth() + 1}월 {startDate.getDate()}일 ~ {endDate.getMonth() + 1}월 {endDate.getDate()}일 ({tripDays}일간)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">D-Day</span>
|
||||
<span className={`font-bold ${dDay > 0 ? 'text-blue-600' : dDay === 0 ? 'text-green-600' : 'text-gray-600'}`}>
|
||||
{dDay > 0 ? `D-${dDay}` : dDay === 0 ? 'D-Day!' : '여행 중'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">총 일정</span>
|
||||
<span className="font-medium">{totalActivities}개 활동</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 진행률 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">📊 계획 진행률</h3>
|
||||
<div className="text-center">
|
||||
<div className="relative w-20 h-20 mx-auto mb-2">
|
||||
<svg className="w-20 h-20 transform -rotate-90" viewBox="0 0 36 36">
|
||||
<path
|
||||
className="text-gray-200"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
fill="none"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
<path
|
||||
className="text-kumamoto-primary"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeDasharray={`${progressPercentage}, 100`}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-gray-800">{progressPercentage}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
{completedActivities}/{totalActivities} 완료
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 예산 요약 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">💰 예산 요약</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="text-center mb-3">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
¥{totalBudget.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">총 예산</div>
|
||||
</div>
|
||||
|
||||
{budgetCategories.map((category) => (
|
||||
category.amount > 0 && (
|
||||
<div key={category.name} className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${category.color}`}></div>
|
||||
<span className="text-sm text-gray-600 flex-1">{category.name}</span>
|
||||
<span className="text-sm font-medium">¥{category.amount.toLocaleString()}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액션 버튼들 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button className="px-4 py-2 bg-kumamoto-primary text-white rounded-lg hover:bg-opacity-90 transition-colors text-sm">
|
||||
📱 모바일로 보기
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
📤 계획 공유하기
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
🌤️ 날씨 확인
|
||||
</button>
|
||||
<button className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors text-sm">
|
||||
💾 백업 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TravelDashboard
|
||||
535
src/components/TravelMap.tsx
Normal file
535
src/components/TravelMap.tsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet'
|
||||
import { Icon } from 'leaflet'
|
||||
import { Attraction, BasePoint } from '../types'
|
||||
import { basePointsAPI } from '../services/api'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
interface TravelMapProps {
|
||||
attractions: Attraction[]
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
display_name: string
|
||||
lat: string
|
||||
lon: string
|
||||
}
|
||||
|
||||
const TravelMap = ({ attractions }: TravelMapProps) => {
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
const [mapError, setMapError] = useState<string | null>(null)
|
||||
const [basePoints, setBasePoints] = useState<BasePoint[]>([])
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
|
||||
// 폼 상태
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation' as BasePoint['type'],
|
||||
memo: ''
|
||||
})
|
||||
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lng: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 클라이언트에서만 실행
|
||||
if (typeof window !== 'undefined') {
|
||||
setIsMounted(true)
|
||||
// API에서 기본 포인트 불러오기
|
||||
loadBasePoints()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadBasePoints = async () => {
|
||||
try {
|
||||
const points = await basePointsAPI.getAll()
|
||||
setBasePoints(points)
|
||||
} catch (error) {
|
||||
console.error('Failed to load base points:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 주소 검색 (Nominatim API)
|
||||
const searchAddress = async (query: string) => {
|
||||
if (!query.trim()) return
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
// 구마모토 지역에 검색 제한
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query + ' 熊本')}&` +
|
||||
`format=json&` +
|
||||
`limit=5&` +
|
||||
`countrycodes=jp&` +
|
||||
`accept-language=ja`
|
||||
)
|
||||
const data = await response.json()
|
||||
setSearchResults(data)
|
||||
} catch (error) {
|
||||
console.error('Address search failed:', error)
|
||||
alert('주소 검색에 실패했습니다.')
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 검색 결과 선택
|
||||
const selectSearchResult = (result: SearchResult) => {
|
||||
setSelectedLocation({
|
||||
lat: parseFloat(result.lat),
|
||||
lng: parseFloat(result.lon)
|
||||
})
|
||||
setFormData({ ...formData, address: result.display_name })
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
// 기본 포인트 추가
|
||||
const addBasePoint = async () => {
|
||||
if (!formData.name || !formData.address || !selectedLocation) {
|
||||
alert('이름과 주소를 입력하고 검색 결과에서 선택해주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const newPoint = await basePointsAPI.create({
|
||||
name: formData.name,
|
||||
address: formData.address,
|
||||
type: formData.type,
|
||||
coordinates: selectedLocation,
|
||||
memo: formData.memo || undefined
|
||||
})
|
||||
|
||||
setBasePoints([...basePoints, newPoint])
|
||||
|
||||
// 폼 초기화
|
||||
setFormData({
|
||||
name: '',
|
||||
address: '',
|
||||
type: 'accommodation',
|
||||
memo: ''
|
||||
})
|
||||
setSelectedLocation(null)
|
||||
setShowAddForm(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to add base point:', error)
|
||||
alert('기본 포인트 추가에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 포인트 삭제
|
||||
const deleteBasePoint = async (id: string) => {
|
||||
if (confirm('이 위치를 삭제하시겠습니까?')) {
|
||||
try {
|
||||
await basePointsAPI.delete(id)
|
||||
setBasePoints(basePoints.filter(p => p.id !== id))
|
||||
} catch (error) {
|
||||
console.error('Failed to delete base point:', error)
|
||||
alert('기본 포인트 삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구마모토 중심 좌표
|
||||
const kumamotoCenter: [number, number] = [32.8031, 130.7079]
|
||||
|
||||
// 타일 서버 URL
|
||||
const tilesUrl = import.meta.env.VITE_MAP_TILES_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
const isCustomTiles = !!import.meta.env.VITE_MAP_TILES_URL
|
||||
|
||||
// 타입별 아이콘 색상
|
||||
const getTypeEmoji = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '🏨'
|
||||
case 'airport': return '✈️'
|
||||
case 'station': return '🚉'
|
||||
case 'parking': return '🅿️'
|
||||
default: return '📍'
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeName = (type: BasePoint['type']) => {
|
||||
switch (type) {
|
||||
case 'accommodation': return '숙소'
|
||||
case 'airport': return '공항'
|
||||
case 'station': return '역/정류장'
|
||||
case 'parking': return '주차장'
|
||||
default: return '기타'
|
||||
}
|
||||
}
|
||||
|
||||
// 마커 아이콘 설정
|
||||
const attractionIcon = new Icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
const basePointIcon = new Icon({
|
||||
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-violet.png',
|
||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
// 서버 사이드에서는 로딩 화면 표시
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kumamoto-blue mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">지도를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 에러 발생 시
|
||||
if (mapError) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ {mapError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMapError(null)
|
||||
window.location.reload()
|
||||
}}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 클라이언트에서 렌더링
|
||||
try {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">🗺️ 구마모토 지도</h2>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="px-4 py-2 bg-kumamoto-primary text-white rounded hover:bg-kumamoto-secondary transition-colors"
|
||||
>
|
||||
{showAddForm ? '닫기' : '+ 기본 정보 추가'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 추가 폼 */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-4">기본 정보 추가 (숙소, 공항, 이동 포인트)</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 호텔 이름, 구마모토 공항 등"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">분류</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as BasePoint['type'] })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
>
|
||||
<option value="accommodation">🏨 숙소</option>
|
||||
<option value="airport">✈️ 공항</option>
|
||||
<option value="station">🚉 역/정류장</option>
|
||||
<option value="parking">🅿️ 주차장</option>
|
||||
<option value="other">📍 기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">주소</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||
placeholder="849-7 Hikimizu, Ozu, Kikuchi District, Kumamoto 869-1234"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
주소를 직접 입력하세요 (영어, 한글, 일본어 모두 가능)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">위치 좌표 설정</label>
|
||||
|
||||
{/* 옵션 1: 간단한 검색으로 좌표 찾기 */}
|
||||
<div className="mb-3 p-3 bg-blue-50 rounded border border-blue-200">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">옵션 1: 간단한 검색으로 좌표 찾기</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="간단한 키워드 입력 (예: 구마모토 공항, 오즈 등)"
|
||||
className="flex-1 px-3 py-2 border rounded text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
searchAddress(e.currentTarget.value)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
const input = e.currentTarget.previousElementSibling as HTMLInputElement
|
||||
searchAddress(input.value)
|
||||
}}
|
||||
disabled={isSearching}
|
||||
className="px-3 py-2 bg-kumamoto-blue text-white rounded hover:bg-opacity-90 disabled:bg-gray-400 text-sm"
|
||||
>
|
||||
{isSearching ? '검색중...' : '검색'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 결과 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 border rounded max-h-40 overflow-y-auto bg-white">
|
||||
{searchResults.map((result, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => selectSearchResult(result)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-100 border-b last:border-b-0 text-xs"
|
||||
>
|
||||
<div className="font-medium text-gray-800">결과 {idx + 1}</div>
|
||||
<div className="text-gray-600 truncate">{result.display_name}</div>
|
||||
<div className="text-gray-400 text-xs">
|
||||
위도: {parseFloat(result.lat).toFixed(4)}, 경도: {parseFloat(result.lon).toFixed(4)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 옵션 2: 좌표 직접 입력 */}
|
||||
<div className="p-3 bg-green-50 rounded border border-green-200">
|
||||
<p className="text-xs font-medium text-gray-700 mb-2">옵션 2: 좌표 직접 입력 (Google Maps에서 확인)</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="위도 (예: 32.8031)"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={selectedLocation?.lat || ''}
|
||||
onChange={(e) => {
|
||||
const lat = parseFloat(e.target.value)
|
||||
if (!isNaN(lat)) {
|
||||
setSelectedLocation({
|
||||
lat,
|
||||
lng: selectedLocation?.lng || 0
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="경도 (예: 130.7079)"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={selectedLocation?.lng || ''}
|
||||
onChange={(e) => {
|
||||
const lng = parseFloat(e.target.value)
|
||||
if (!isNaN(lng)) {
|
||||
setSelectedLocation({
|
||||
lat: selectedLocation?.lat || 0,
|
||||
lng
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 Google Maps에서 장소 우클릭 → 첫 번째 숫자 복사
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLocation && selectedLocation.lat !== 0 && selectedLocation.lng !== 0 && (
|
||||
<p className="mt-2 text-xs text-green-600 font-medium">
|
||||
✓ 위치 선택됨 (위도: {selectedLocation.lat.toFixed(6)}, 경도: {selectedLocation.lng.toFixed(6)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">메모 (선택)</label>
|
||||
<textarea
|
||||
value={formData.memo}
|
||||
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
|
||||
placeholder="추가 정보를 입력하세요"
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addBasePoint}
|
||||
className="w-full px-4 py-2 bg-kumamoto-green text-white rounded hover:bg-opacity-90"
|
||||
>
|
||||
추가하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 저장된 기본 포인트 목록 */}
|
||||
{basePoints.length > 0 && (
|
||||
<div className="mb-4 p-3 bg-purple-50 border border-purple-200 rounded">
|
||||
<h4 className="font-semibold text-gray-800 mb-2 text-sm">저장된 기본 정보</h4>
|
||||
<div className="space-y-2">
|
||||
{basePoints.map((point) => (
|
||||
<div key={point.id} className="flex items-start justify-between bg-white p-2 rounded text-sm">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-gray-800">
|
||||
{getTypeEmoji(point.type)} {point.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{point.address}</div>
|
||||
{point.memo && <div className="text-xs text-gray-400 mt-1">{point.memo}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteBasePoint(point.id)}
|
||||
className="text-red-500 hover:text-red-700 text-xs ml-2"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200">
|
||||
<MapContainer
|
||||
center={kumamotoCenter}
|
||||
zoom={10}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<TileLayer
|
||||
url={tilesUrl}
|
||||
attribution={
|
||||
isCustomTiles
|
||||
? '© 구마모토 여행 지도 | OpenStreetMap contributors'
|
||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}
|
||||
maxZoom={18}
|
||||
/>
|
||||
|
||||
{/* 기본 포인트 마커 (보라색) */}
|
||||
{basePoints.map((point) => (
|
||||
<Marker
|
||||
key={point.id}
|
||||
position={[point.coordinates.lat, point.coordinates.lng]}
|
||||
icon={basePointIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-2 max-w-xs">
|
||||
<h3 className="font-bold text-gray-800 mb-2 text-base">
|
||||
{getTypeEmoji(point.type)} {point.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
<span className="inline-block px-2 py-0.5 bg-purple-100 rounded">
|
||||
{getTypeName(point.type)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
📍 {point.address}
|
||||
</p>
|
||||
{point.memo && (
|
||||
<p className="text-gray-500 text-xs">
|
||||
📝 {point.memo}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* 관광지 마커 (빨간색) */}
|
||||
{attractions.map((attraction) => (
|
||||
attraction.coordinates && (
|
||||
<Marker
|
||||
key={attraction.id}
|
||||
position={[attraction.coordinates.lat, attraction.coordinates.lng]}
|
||||
icon={attractionIcon}
|
||||
>
|
||||
<Popup>
|
||||
<div className="p-2 max-w-xs">
|
||||
<h3 className="font-bold text-gray-800 mb-2 text-base">
|
||||
{attraction.nameKo}
|
||||
</h3>
|
||||
<p className="text-gray-600 text-sm mb-2">
|
||||
{attraction.description}
|
||||
</p>
|
||||
<p className="text-gray-500 text-xs">
|
||||
📍 {attraction.location}
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)
|
||||
))}
|
||||
</MapContainer>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div className="p-3 bg-purple-50 border border-purple-200 rounded text-sm text-purple-800">
|
||||
<strong>🟣 보라색 마커:</strong> 기본 정보 (숙소, 공항, 이동 포인트)
|
||||
</div>
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-800">
|
||||
<strong>🔴 빨간색 마커:</strong> 관광지
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('지도 렌더링 에러:', error)
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-red-50">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-2">⚠️ 지도를 렌더링할 수 없습니다.</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
|
||||
>
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default TravelMap
|
||||
436
src/components/TripSharingManager.tsx
Normal file
436
src/components/TripSharingManager.tsx
Normal file
@@ -0,0 +1,436 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ShareLink, TravelPlan } from '../types'
|
||||
import { tripSharingService, SharePermissions, ShareLinkInfo } from '../services/tripSharing'
|
||||
|
||||
interface TripSharingManagerProps {
|
||||
trip: TravelPlan
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const TripSharingManager = ({ trip, onClose }: TripSharingManagerProps) => {
|
||||
const [shareLinks, setShareLinks] = useState<ShareLink[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newShareLink, setNewShareLink] = useState<ShareLinkInfo | null>(null)
|
||||
|
||||
// 공유 링크 목록 로드
|
||||
useEffect(() => {
|
||||
loadShareLinks()
|
||||
}, [trip.id])
|
||||
|
||||
const loadShareLinks = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const links = await tripSharingService.getShareLinks(trip.id)
|
||||
setShareLinks(links)
|
||||
} catch (error) {
|
||||
console.error('Failed to load share links:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 공유 링크 생성
|
||||
const handleCreateShareLink = async (formData: {
|
||||
permission_type: keyof typeof SharePermissions
|
||||
expires_in_days?: number
|
||||
max_access_count?: number
|
||||
}) => {
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const permissions = SharePermissions[formData.permission_type].permissions
|
||||
|
||||
const result = await tripSharingService.createShareLink({
|
||||
trip_id: trip.id,
|
||||
expires_in_days: formData.expires_in_days,
|
||||
max_access_count: formData.max_access_count,
|
||||
permissions
|
||||
})
|
||||
|
||||
if (result.success && result.shareLink) {
|
||||
setNewShareLink(result.shareLink)
|
||||
setShowCreateForm(false)
|
||||
await loadShareLinks()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('공유 링크 생성 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 공유 링크 비활성화
|
||||
const handleDeactivateLink = async (linkId: string) => {
|
||||
if (!confirm('이 공유 링크를 비활성화하시겠습니까?\n\n비활성화된 링크는 더 이상 사용할 수 없습니다.')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tripSharingService.deactivateShareLink(linkId)
|
||||
if (result.success) {
|
||||
await loadShareLinks()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('공유 링크 비활성화 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
// 클립보드에 복사
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alert('클립보드에 복사되었습니다!')
|
||||
} catch (error) {
|
||||
// 클립보드 API가 지원되지 않는 경우 폴백
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('클립보드에 복사되었습니다!')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const getPermissionLabel = (permissions: ShareLink['permissions']) => {
|
||||
if (permissions.can_edit) return SharePermissions.edit.label
|
||||
if (permissions.can_comment) return SharePermissions.comment.label
|
||||
return SharePermissions.view_only.label
|
||||
}
|
||||
|
||||
const getPermissionColor = (permissions: ShareLink['permissions']) => {
|
||||
if (permissions.can_edit) return 'bg-red-100 text-red-800'
|
||||
if (permissions.can_comment) return 'bg-yellow-100 text-yellow-800'
|
||||
return 'bg-green-100 text-green-800'
|
||||
}
|
||||
|
||||
const isExpired = (link: ShareLink) => {
|
||||
return link.expires_at && new Date(link.expires_at) < new Date()
|
||||
}
|
||||
|
||||
const isAccessLimitReached = (link: ShareLink) => {
|
||||
return link.max_access_count && link.access_count >= link.max_access_count
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-800">🔗 여행 계획 공유</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
"{trip.title}" 여행 계획을 다른 사람들과 공유하세요
|
||||
</p>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 새로 생성된 공유 링크 표시 */}
|
||||
{newShareLink && (
|
||||
<div className="p-6 bg-blue-50 border-b border-blue-200">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-blue-600 text-2xl">🎉</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">공유 링크가 생성되었습니다!</h3>
|
||||
<div className="bg-white p-3 rounded border border-blue-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="flex-1 text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{newShareLink.share_url}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(newShareLink.share_url)}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
권한: {getPermissionLabel(newShareLink.permissions)} |
|
||||
접근 횟수: {newShareLink.access_count}
|
||||
{newShareLink.max_access_count && ` / ${newShareLink.max_access_count}`}
|
||||
{newShareLink.expires_at && ` | 만료: ${formatDate(newShareLink.expires_at)}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewShareLink(null)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공유 링크 생성 버튼 */}
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<button
|
||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{showCreateForm ? '취소' : '➕ 새 공유 링크 만들기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 공유 링크 생성 폼 */}
|
||||
{showCreateForm && (
|
||||
<CreateShareLinkForm
|
||||
onSubmit={handleCreateShareLink}
|
||||
isCreating={isCreating}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 기존 공유 링크 목록 */}
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">
|
||||
기존 공유 링크 ({shareLinks.length}개)
|
||||
</h3>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">공유 링크를 불러오는 중...</p>
|
||||
</div>
|
||||
) : shareLinks.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">🔗</div>
|
||||
<div className="text-gray-600">아직 생성된 공유 링크가 없습니다</div>
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
위의 버튼을 클릭해서 첫 번째 공유 링크를 만들어보세요
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shareLinks.map((link) => {
|
||||
const expired = isExpired(link)
|
||||
const accessLimited = isAccessLimitReached(link)
|
||||
const inactive = !link.is_active || expired || accessLimited
|
||||
|
||||
return (
|
||||
<div
|
||||
key={link.id}
|
||||
className={`border rounded-lg p-4 ${
|
||||
inactive ? 'bg-gray-50 border-gray-300' : 'bg-white border-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${getPermissionColor(link.permissions)}`}>
|
||||
{getPermissionLabel(link.permissions)}
|
||||
</span>
|
||||
{inactive && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||
{!link.is_active ? '비활성화됨' : expired ? '만료됨' : '접근 한도 초과'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className={`flex-1 text-sm px-2 py-1 rounded ${
|
||||
inactive ? 'bg-gray-200 text-gray-500' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{tripSharingService.generateShareUrl(link.share_code)}
|
||||
</code>
|
||||
{!inactive && (
|
||||
<button
|
||||
onClick={() => copyToClipboard(tripSharingService.generateShareUrl(link.share_code))}
|
||||
className="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700"
|
||||
>
|
||||
복사
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>생성일: {formatDate(link.created_at)}</div>
|
||||
<div>
|
||||
접근 횟수: {link.access_count}
|
||||
{link.max_access_count && ` / ${link.max_access_count}`}
|
||||
</div>
|
||||
{link.expires_at && (
|
||||
<div>만료일: {formatDate(link.expires_at)}</div>
|
||||
)}
|
||||
{link.last_accessed && (
|
||||
<div>마지막 접근: {formatDate(link.last_accessed)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{link.is_active && !expired && !accessLimited && (
|
||||
<button
|
||||
onClick={() => handleDeactivateLink(link.id)}
|
||||
className="px-3 py-1 bg-red-100 text-red-700 rounded text-sm hover:bg-red-200"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 공유 링크 생성 폼
|
||||
const CreateShareLinkForm = ({
|
||||
onSubmit,
|
||||
isCreating,
|
||||
onCancel
|
||||
}: {
|
||||
onSubmit: (data: any) => void
|
||||
isCreating: boolean
|
||||
onCancel: () => void
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
permission_type: 'view_only' as keyof typeof SharePermissions,
|
||||
expires_in_days: 7,
|
||||
max_access_count: undefined as number | undefined,
|
||||
has_expiry: true,
|
||||
has_access_limit: false
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({
|
||||
permission_type: formData.permission_type,
|
||||
expires_in_days: formData.has_expiry ? formData.expires_in_days : undefined,
|
||||
max_access_count: formData.has_access_limit ? formData.max_access_count : undefined
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-gray-50 border-b border-gray-200">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
공유 권한
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(SharePermissions).map(([key, permission]) => (
|
||||
<label key={key} className="flex items-start gap-3 p-3 border border-gray-200 rounded cursor-pointer hover:bg-white">
|
||||
<input
|
||||
type="radio"
|
||||
name="permission_type"
|
||||
value={key}
|
||||
checked={formData.permission_type === key}
|
||||
onChange={(e) => setFormData({ ...formData, permission_type: e.target.value as any })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{permission.label}</div>
|
||||
<div className="text-sm text-gray-600">{permission.description}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.has_expiry}
|
||||
onChange={(e) => setFormData({ ...formData, has_expiry: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">만료 기간 설정</span>
|
||||
</label>
|
||||
{formData.has_expiry && (
|
||||
<select
|
||||
value={formData.expires_in_days}
|
||||
onChange={(e) => setFormData({ ...formData, expires_in_days: parseInt(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value={1}>1일</option>
|
||||
<option value={3}>3일</option>
|
||||
<option value={7}>7일</option>
|
||||
<option value={14}>14일</option>
|
||||
<option value={30}>30일</option>
|
||||
<option value={90}>90일</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.has_access_limit}
|
||||
onChange={(e) => setFormData({ ...formData, has_access_limit: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">접근 횟수 제한</span>
|
||||
</label>
|
||||
{formData.has_access_limit && (
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000"
|
||||
value={formData.max_access_count || ''}
|
||||
onChange={(e) => setFormData({ ...formData, max_access_count: parseInt(e.target.value) || undefined })}
|
||||
placeholder="최대 접근 횟수"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
생성 중...
|
||||
</div>
|
||||
) : (
|
||||
'공유 링크 생성'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TripSharingManager
|
||||
303
src/components/UnifiedMap.tsx
Normal file
303
src/components/UnifiedMap.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Attraction, BasePoint } from '../types'
|
||||
import { basePointsAPI } from '../services/api'
|
||||
import GoogleMapComponent from './GoogleMapComponent'
|
||||
import LeafletMapComponent from './LeafletMapComponent'
|
||||
import PlacesSearch from './PlacesSearch'
|
||||
import GoogleAuthManager from './GoogleAuthManager'
|
||||
import { PlaceResult, googlePlacesService } from '../services/googlePlaces'
|
||||
import { SavedPlace, GoogleMyMap } from '../services/googleAuth'
|
||||
import { offlineSupportService, OfflineUtils } from '../services/offlineSupport'
|
||||
import { canUseGoogleMaps } from '../services/googleMapsUsage'
|
||||
|
||||
interface UnifiedMapProps {
|
||||
attractions: Attraction[]
|
||||
basePoints?: BasePoint[]
|
||||
onAddBasePoint?: (basePoint: BasePoint) => void
|
||||
onDeleteBasePoint?: (id: string) => void
|
||||
selectedActivityId?: string
|
||||
currentDaySchedule?: any
|
||||
onActivityClick?: (activity: any) => void
|
||||
getActivityCoords?: (activity: any) => [number, number] | null
|
||||
showControls?: boolean
|
||||
showPlacesSearch?: boolean
|
||||
onAddPlaceToItinerary?: (place: PlaceResult) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
type MapProvider = 'google' | 'leaflet'
|
||||
|
||||
const UnifiedMap = ({
|
||||
attractions,
|
||||
basePoints: propBasePoints,
|
||||
onAddBasePoint,
|
||||
onDeleteBasePoint,
|
||||
selectedActivityId,
|
||||
currentDaySchedule,
|
||||
onActivityClick,
|
||||
getActivityCoords,
|
||||
showControls = true,
|
||||
showPlacesSearch = true,
|
||||
onAddPlaceToItinerary,
|
||||
className = ''
|
||||
}: UnifiedMapProps) => {
|
||||
const [mapProvider, setMapProvider] = useState<MapProvider>('leaflet')
|
||||
const [basePoints, setBasePoints] = useState<BasePoint[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showSearchPanel, setShowSearchPanel] = useState(false)
|
||||
const [showGoogleAuth, setShowGoogleAuth] = useState(false)
|
||||
const [mapInstance, setMapInstance] = useState<google.maps.Map | null>(null)
|
||||
const [isOfflineMode, setIsOfflineMode] = useState(false)
|
||||
const [offlineMessage, setOfflineMessage] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (propBasePoints) {
|
||||
setBasePoints(propBasePoints)
|
||||
setIsLoading(false)
|
||||
} else {
|
||||
loadBasePoints()
|
||||
}
|
||||
|
||||
// 오프라인 모드 확인
|
||||
checkOfflineMode()
|
||||
}, [propBasePoints])
|
||||
|
||||
// 지도 제공자 변경 시 오프라인 모드 재확인
|
||||
useEffect(() => {
|
||||
checkOfflineMode()
|
||||
}, [mapProvider])
|
||||
|
||||
const loadBasePoints = async () => {
|
||||
try {
|
||||
const points = await basePointsAPI.getAll()
|
||||
setBasePoints(points)
|
||||
} catch (error) {
|
||||
console.error('Failed to load base points:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBasePointsUpdate = (newBasePoints: BasePoint[]) => {
|
||||
setBasePoints(newBasePoints)
|
||||
}
|
||||
|
||||
// Google Maps 인스턴스 초기화 시 Places Service 설정
|
||||
const handleMapInit = (map: google.maps.Map) => {
|
||||
setMapInstance(map)
|
||||
if (mapProvider === 'google') {
|
||||
googlePlacesService.initialize(map)
|
||||
}
|
||||
}
|
||||
|
||||
// 장소 선택 핸들러
|
||||
const handlePlaceSelect = (place: PlaceResult) => {
|
||||
// 지도에서 해당 위치로 이동
|
||||
if (mapInstance) {
|
||||
mapInstance.setCenter({ lat: place.geometry.location.lat, lng: place.geometry.location.lng })
|
||||
mapInstance.setZoom(16)
|
||||
}
|
||||
}
|
||||
|
||||
// 일정에 장소 추가
|
||||
const handleAddPlaceToItinerary = (place: PlaceResult) => {
|
||||
if (onAddPlaceToItinerary) {
|
||||
onAddPlaceToItinerary(place)
|
||||
}
|
||||
}
|
||||
|
||||
// 오프라인 모드 확인
|
||||
const checkOfflineMode = () => {
|
||||
if (mapProvider === 'google') {
|
||||
const shouldUseOffline = OfflineUtils.shouldUseOfflineMode()
|
||||
setIsOfflineMode(shouldUseOffline)
|
||||
|
||||
if (shouldUseOffline) {
|
||||
setOfflineMessage(OfflineUtils.getOfflineModeMessage())
|
||||
} else {
|
||||
setOfflineMessage('')
|
||||
}
|
||||
} else {
|
||||
setIsOfflineMode(false)
|
||||
setOfflineMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
// Google 저장된 장소 로드 핸들러
|
||||
const handleSavedPlacesLoad = (places: SavedPlace[]) => {
|
||||
console.log('Google 저장된 장소 로드됨:', places.length, '개')
|
||||
// 필요시 부모 컴포넌트로 전달
|
||||
}
|
||||
|
||||
// Google My Maps 로드 핸들러
|
||||
const handleMyMapsLoad = (maps: GoogleMyMap[]) => {
|
||||
console.log('Google My Maps 로드됨:', maps.length, '개')
|
||||
// 필요시 부모 컴포넌트로 전달
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
{showControls && <h2 className="text-2xl font-bold text-gray-800 mb-6">🗺️ 구마모토 지도</h2>}
|
||||
<div className="w-full h-96 rounded-lg overflow-hidden border border-gray-200 flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kumamoto-blue mx-auto mb-2"></div>
|
||||
<p className="text-gray-500">지도를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={showControls ? `bg-white rounded-lg shadow-md p-6 ${className}` : className}>
|
||||
{showControls && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-800">🗺️ 구마모토 지도</h2>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Google 로그인 토글 */}
|
||||
{mapProvider === 'google' && (
|
||||
<button
|
||||
onClick={() => setShowGoogleAuth(!showGoogleAuth)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
showGoogleAuth
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
}`}
|
||||
>
|
||||
🔐 Google 연동
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Places 검색 토글 */}
|
||||
{showPlacesSearch && mapProvider === 'google' && !isOfflineMode && (
|
||||
<button
|
||||
onClick={() => setShowSearchPanel(!showSearchPanel)}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
showSearchPanel
|
||||
? 'bg-kumamoto-primary text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
🔍 장소 검색
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 지도 제공자 선택 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">지도:</span>
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setMapProvider('leaflet')}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
mapProvider === 'leaflet'
|
||||
? 'bg-white text-kumamoto-primary shadow-sm font-medium'
|
||||
: 'text-gray-600 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
🌍 OSM
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMapProvider('google')}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
mapProvider === 'google'
|
||||
? 'bg-white text-kumamoto-primary shadow-sm font-medium'
|
||||
: 'text-gray-600 hover:text-gray-800'
|
||||
}`}
|
||||
>
|
||||
🗺️ Google {isOfflineMode && '(오프라인)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오프라인 모드 알림 */}
|
||||
{isOfflineMode && offlineMessage && (
|
||||
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-orange-700">
|
||||
<span>📱</span>
|
||||
<span className="text-sm">{offlineMessage}</span>
|
||||
</div>
|
||||
<div className="text-xs text-orange-600 mt-1">
|
||||
캐시된 데이터로 지도를 표시합니다. 일부 기능이 제한될 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Google 로그인 패널 */}
|
||||
{showGoogleAuth && mapProvider === 'google' && (
|
||||
<div className="mb-6">
|
||||
<GoogleAuthManager
|
||||
onSavedPlacesLoad={handleSavedPlacesLoad}
|
||||
onMyMapsLoad={handleMyMapsLoad}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Places 검색 패널 */}
|
||||
{showSearchPanel && showPlacesSearch && mapProvider === 'google' && !isOfflineMode && (
|
||||
<div className="mb-6">
|
||||
<PlacesSearch
|
||||
onPlaceSelect={handlePlaceSelect}
|
||||
onAddToItinerary={onAddPlaceToItinerary ? handleAddPlaceToItinerary : undefined}
|
||||
currentLocation={{ lat: 32.7898, lng: 130.7417 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 제공자별 설명 */}
|
||||
<div className="mb-4 p-3 bg-gray-50 rounded-lg border border-gray-200">
|
||||
{mapProvider === 'leaflet' ? (
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>🌍 오픈소스 맵 (OpenStreetMap)</strong>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
• 무료 사용 • API 키 불필요 • 커뮤니티 기반 지도 데이터 • 항상 사용 가능
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-700">
|
||||
<strong>🗺️ 구글 맵 {isOfflineMode ? '(오프라인 모드)' : ''}</strong>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{isOfflineMode ? (
|
||||
<>• 캐시된 데이터 사용 • 제한된 기능 • 오프라인 지원</>
|
||||
) : (
|
||||
<>• 정확한 지도 데이터 • Google 계정 연동 • 실시간 장소 검색 • 길찾기 기능</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 지도 컴포넌트 렌더링 */}
|
||||
{mapProvider === 'google' ? (
|
||||
<GoogleMapComponent
|
||||
attractions={attractions}
|
||||
basePoints={basePoints}
|
||||
onBasePointsUpdate={onAddBasePoint ? handleBasePointsUpdate : undefined}
|
||||
selectedActivityId={selectedActivityId}
|
||||
currentDaySchedule={currentDaySchedule}
|
||||
onActivityClick={onActivityClick}
|
||||
getActivityCoords={getActivityCoords}
|
||||
onMapInit={handleMapInit}
|
||||
/>
|
||||
) : (
|
||||
<LeafletMapComponent
|
||||
attractions={attractions}
|
||||
basePoints={basePoints}
|
||||
onBasePointsUpdate={onAddBasePoint ? handleBasePointsUpdate : undefined}
|
||||
selectedActivityId={selectedActivityId}
|
||||
currentDaySchedule={currentDaySchedule}
|
||||
onActivityClick={onActivityClick}
|
||||
getActivityCoords={getActivityCoords}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UnifiedMap
|
||||
660
src/components/UserDashboard.tsx
Normal file
660
src/components/UserDashboard.tsx
Normal file
@@ -0,0 +1,660 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { tripManagerService, TripCategories, TripStatus, TripCreateData } from '../services/tripManager'
|
||||
import { TravelPlan } from '../types'
|
||||
import { userAuthService } from '../services/userAuth'
|
||||
import TemplateBrowser from './TemplateBrowser'
|
||||
import TripSharingManager from './TripSharingManager'
|
||||
|
||||
interface UserDashboardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const UserDashboard = ({ className = '' }: UserDashboardProps) => {
|
||||
const [trips, setTrips] = useState<TravelPlan[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [showTemplates, setShowTemplates] = useState(false)
|
||||
const [sharingTrip, setSharingTrip] = useState<TravelPlan | null>(null)
|
||||
const [filter, setFilter] = useState({
|
||||
status: 'all' as 'all' | TravelPlan['status'],
|
||||
category: 'all' as 'all' | TravelPlan['template_category'],
|
||||
search: ''
|
||||
})
|
||||
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
|
||||
useEffect(() => {
|
||||
loadTrips()
|
||||
}, [filter])
|
||||
|
||||
const loadTrips = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const filterParams = {
|
||||
...(filter.status !== 'all' && { status: filter.status }),
|
||||
...(filter.category !== 'all' && { template_category: filter.category }),
|
||||
...(filter.search && { search: filter.search })
|
||||
}
|
||||
|
||||
const result = await tripManagerService.getUserTrips(filterParams)
|
||||
setTrips(result.trips)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '여행 목록을 불러올 수 없습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTrip = async (tripId: string, tripTitle: string) => {
|
||||
if (!confirm(`"${tripTitle}" 여행 계획을 정말 삭제하시겠습니까?\n\n⚠️ 삭제된 계획은 복구할 수 없습니다.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await tripManagerService.deleteTrip(tripId)
|
||||
if (result.success) {
|
||||
await loadTrips()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('여행 계획 삭제 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicateTrip = async (tripId: string, tripTitle: string) => {
|
||||
const newTitle = prompt(`복사할 여행 계획의 제목을 입력하세요:`, `${tripTitle} (복사본)`)
|
||||
if (!newTitle) return
|
||||
|
||||
try {
|
||||
const result = await tripManagerService.duplicateTrip(tripId, newTitle)
|
||||
if (result.success) {
|
||||
await loadTrips()
|
||||
alert(result.message)
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('여행 계획 복사 중 오류가 발생했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string) => {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getDuration = (startDate: Date | string, endDate: Date | string) => {
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
const diffTime = Math.abs(end.getTime() - start.getTime())
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
||||
return `${diffDays}일`
|
||||
}
|
||||
|
||||
const getStatusInfo = (status: TravelPlan['status']) => {
|
||||
return TripStatus[status] || TripStatus.draft
|
||||
}
|
||||
|
||||
const getCategoryInfo = (category?: TravelPlan['template_category']) => {
|
||||
return category ? TripCategories[category] : TripCategories.other
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mr-3"></div>
|
||||
<span>여행 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg shadow-md p-6 ${className}`}>
|
||||
<div className="text-center py-12">
|
||||
<div className="text-red-500 text-4xl mb-4">⚠️</div>
|
||||
<div className="text-red-700">{error}</div>
|
||||
<button
|
||||
onClick={loadTrips}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">
|
||||
🗺️ 내 여행 계획
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{currentUser?.name}님의 여행 계획을 관리하세요
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowTemplates(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||
>
|
||||
🌟 템플릿으로 만들기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
➕ 직접 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-800">
|
||||
{trips.filter(t => t.status === 'active').length}
|
||||
</div>
|
||||
<div className="text-sm text-blue-600">진행 중</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-800">
|
||||
{trips.filter(t => t.status === 'completed').length}
|
||||
</div>
|
||||
<div className="text-sm text-green-600">완료</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg border border-gray-200">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{trips.filter(t => t.status === 'draft').length}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">계획 중</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-lg border border-purple-200">
|
||||
<div className="text-2xl font-bold text-purple-800">
|
||||
{trips.length}
|
||||
</div>
|
||||
<div className="text-sm text-purple-600">전체</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
상태
|
||||
</label>
|
||||
<select
|
||||
value={filter.status}
|
||||
onChange={(e) => setFilter({ ...filter, status: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="draft">계획 중</option>
|
||||
<option value="active">진행 중</option>
|
||||
<option value="completed">완료</option>
|
||||
<option value="cancelled">취소</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
지역
|
||||
</label>
|
||||
<select
|
||||
value={filter.category}
|
||||
onChange={(e) => setFilter({ ...filter, category: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
{Object.entries(TripCategories).map(([key, category]) => (
|
||||
<option key={key} value={key}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
검색
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={filter.search}
|
||||
onChange={(e) => setFilter({ ...filter, search: e.target.value })}
|
||||
placeholder="제목, 설명, 도시명으로 검색..."
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여행 목록 */}
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
{trips.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-4xl mb-4">✈️</div>
|
||||
<div className="text-lg font-medium text-gray-800 mb-2">
|
||||
아직 여행 계획이 없습니다
|
||||
</div>
|
||||
<div className="text-gray-600 mb-4">
|
||||
첫 번째 여행 계획을 만들어보세요!
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowTemplates(true)}
|
||||
className="px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
||||
>
|
||||
🌟 템플릿으로 만들기
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
➕ 직접 만들기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{trips.map((trip) => {
|
||||
const statusInfo = getStatusInfo(trip.status)
|
||||
const categoryInfo = getCategoryInfo(trip.template_category)
|
||||
|
||||
return (
|
||||
<div key={trip.id} className="p-6 hover:bg-gray-50 transition-colors">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-gray-800">
|
||||
{trip.title}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${categoryInfo.color}`}>
|
||||
{categoryInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-600 mb-2">
|
||||
📍 {trip.destination.city}, {trip.destination.country}
|
||||
{trip.destination.region && ` (${trip.destination.region})`}
|
||||
</div>
|
||||
|
||||
{trip.description && (
|
||||
<div className="text-gray-600 mb-2 text-sm">
|
||||
{trip.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>📅 {formatDate(trip.startDate)} - {formatDate(trip.endDate)}</span>
|
||||
<span>⏱️ {getDuration(trip.startDate, trip.endDate)}</span>
|
||||
<span>📝 {trip.schedule.length}일 일정</span>
|
||||
{trip.tags.length > 0 && (
|
||||
<span>🏷️ {trip.tags.join(', ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-4">
|
||||
<Link
|
||||
to={`/plan/${trip.id}`}
|
||||
className="px-3 py-1 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200"
|
||||
>
|
||||
✏️ 편집
|
||||
</Link>
|
||||
<Link
|
||||
to={`/trip/${trip.id}`}
|
||||
className="px-3 py-1 text-sm bg-green-100 text-green-700 rounded hover:bg-green-200"
|
||||
>
|
||||
🗺️ 보기
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSharingTrip(trip)}
|
||||
className="px-3 py-1 text-sm bg-purple-100 text-purple-700 rounded hover:bg-purple-200"
|
||||
>
|
||||
🔗 공유
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicateTrip(trip.id, trip.title)}
|
||||
className="px-3 py-1 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
📋 복사
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTrip(trip.id, trip.title)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 템플릿 브라우저 */}
|
||||
{showTemplates && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<TemplateBrowser
|
||||
onClose={() => setShowTemplates(false)}
|
||||
onTemplateSelect={() => {
|
||||
setShowTemplates(false)
|
||||
loadTrips()
|
||||
}}
|
||||
className="w-full max-w-6xl max-h-[90vh] overflow-y-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공유 관리 모달 */}
|
||||
{sharingTrip && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<TripSharingManager
|
||||
trip={sharingTrip}
|
||||
onClose={() => setSharingTrip(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새 여행 만들기 모달 */}
|
||||
{showCreateForm && (
|
||||
<CreateTripModal
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreateForm(false)
|
||||
loadTrips()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 새 여행 만들기 모달
|
||||
const CreateTripModal = ({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) => {
|
||||
const [formData, setFormData] = useState<TripCreateData>({
|
||||
title: '',
|
||||
description: '',
|
||||
destination: {
|
||||
country: '',
|
||||
city: '',
|
||||
region: ''
|
||||
},
|
||||
startDate: new Date(),
|
||||
endDate: new Date(),
|
||||
template_category: 'other',
|
||||
tags: [],
|
||||
is_public: false
|
||||
})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const result = await tripManagerService.createTrip(formData)
|
||||
if (result.success) {
|
||||
alert(result.message)
|
||||
onSuccess()
|
||||
} else {
|
||||
alert(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
alert('여행 계획 생성 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: [...(formData.tags || []), tagInput.trim()]
|
||||
})
|
||||
setTagInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
tags: formData.tags?.filter(tag => tag !== tagToRemove) || []
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h3 className="text-xl font-semibold mb-6">✈️ 새 여행 계획 만들기</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
여행 제목 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="예: 도쿄 3박 4일 여행"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
국가 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.destination.country}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
destination: { ...formData.destination, country: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="예: 일본"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
도시 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.destination.city}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
destination: { ...formData.destination, city: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="예: 도쿄"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
출발일 *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.startDate.toISOString().split('T')[0]}
|
||||
onChange={(e) => setFormData({ ...formData, startDate: new Date(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
도착일 *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.endDate.toISOString().split('T')[0]}
|
||||
onChange={(e) => setFormData({ ...formData, endDate: new Date(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
min={formData.startDate.toISOString().split('T')[0]}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
지역 카테고리
|
||||
</label>
|
||||
<select
|
||||
value={formData.template_category}
|
||||
onChange={(e) => setFormData({ ...formData, template_category: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{Object.entries(TripCategories).map(([key, category]) => (
|
||||
<option key={key} value={key}>
|
||||
{category.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
지역 (선택사항)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.destination.region}
|
||||
onChange={(e) => setFormData({
|
||||
...formData,
|
||||
destination: { ...formData.destination, region: e.target.value }
|
||||
})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="예: 규슈"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
설명 (선택사항)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
rows={3}
|
||||
placeholder="여행에 대한 간단한 설명을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
태그 (선택사항)
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addTag())}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="태그를 입력하고 Enter를 누르세요"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 text-sm rounded"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="ml-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_public}
|
||||
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
다른 사용자에게 공개 (템플릿으로 활용 가능)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '생성 중...' : '여행 계획 만들기'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserDashboard
|
||||
58
src/components/forms/CoordinateInput.tsx
Normal file
58
src/components/forms/CoordinateInput.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
interface CoordinateInputProps {
|
||||
coordinates: { lat: number; lng: number } | null | undefined
|
||||
onChange: (coords: { lat: number; lng: number } | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const CoordinateInput = ({ coordinates, onChange, className = '' }: CoordinateInputProps) => {
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
좌표 (Google Maps에서 복사)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="위도 (예: 32.8817)"
|
||||
value={coordinates?.lat !== undefined && coordinates?.lat !== 0 ? coordinates.lat : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onChange(null)
|
||||
} else {
|
||||
const lat = parseFloat(value)
|
||||
if (!isNaN(lat)) {
|
||||
onChange({ lat, lng: coordinates?.lng || 0 })
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
placeholder="경도 (예: 131.0126)"
|
||||
value={coordinates?.lng !== undefined && coordinates?.lng !== 0 ? coordinates.lng : ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
if (value === '') {
|
||||
onChange(null)
|
||||
} else {
|
||||
const lng = parseFloat(value)
|
||||
if (!isNaN(lng)) {
|
||||
onChange({ lat: coordinates?.lat || 0, lng })
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 Google Maps에서 장소 우클릭 → 좌표 복사
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoordinateInput
|
||||
103
src/components/forms/ImageUploadField.tsx
Normal file
103
src/components/forms/ImageUploadField.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface ImageUploadFieldProps {
|
||||
images: string[]
|
||||
onChange: (images: string[]) => void
|
||||
maxImages?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ImageUploadField = ({
|
||||
images,
|
||||
onChange,
|
||||
maxImages = 5,
|
||||
className = ''
|
||||
}: ImageUploadFieldProps) => {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setUploading(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
Array.from(files).forEach(file => {
|
||||
formData.append('images', file)
|
||||
})
|
||||
|
||||
const response = await fetch('http://localhost:3000/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('이미지 업로드 실패')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
onChange([...images, ...result.files])
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 오류:', error)
|
||||
alert('이미지 업로드에 실패했습니다.')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageDelete = (index: number) => {
|
||||
const updatedImages = [...images]
|
||||
updatedImages.splice(index, 1)
|
||||
onChange(updatedImages)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
📷 사진 업로드
|
||||
</label>
|
||||
|
||||
{/* 이미지 미리보기 */}
|
||||
{images.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-2 mb-3">
|
||||
{images.map((img, idx) => (
|
||||
<div key={idx} className="relative group">
|
||||
<img
|
||||
src={`http://localhost:3000${img}`}
|
||||
alt={`이미지 ${idx + 1}`}
|
||||
className="w-full h-20 object-cover rounded border border-gray-200"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleImageDelete(idx)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 버튼 */}
|
||||
<label className="cursor-pointer block">
|
||||
<div className="px-4 py-2 bg-purple-50 border-2 border-purple-200 text-purple-700 rounded-lg hover:bg-purple-100 transition-colors text-center text-sm font-medium">
|
||||
{uploading ? '업로드 중...' : `+ 이미지 추가 (최대 ${maxImages}개)`}
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleImageUpload}
|
||||
disabled={uploading}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
💡 Trip 페이지 지도에 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageUploadField
|
||||
177
src/components/forms/LinkManagement.tsx
Normal file
177
src/components/forms/LinkManagement.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from '../../types'
|
||||
|
||||
interface LinkManagementProps {
|
||||
links: Link[]
|
||||
onChange: (links: Link[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LinkManagement = ({ links, onChange, className = '' }: LinkManagementProps) => {
|
||||
const [newLink, setNewLink] = useState('')
|
||||
const [newLinkLabel, setNewLinkLabel] = useState('')
|
||||
const [editingLinkIndex, setEditingLinkIndex] = useState<number | null>(null)
|
||||
|
||||
const handleAddLink = () => {
|
||||
if (!newLink.trim()) return
|
||||
|
||||
const link = {
|
||||
url: newLink.trim(),
|
||||
label: newLinkLabel.trim() || undefined
|
||||
}
|
||||
|
||||
onChange([...links, link])
|
||||
setNewLink('')
|
||||
setNewLinkLabel('')
|
||||
}
|
||||
|
||||
const handleEditLink = (index: number) => {
|
||||
const link = links[index]
|
||||
if (link) {
|
||||
setNewLink(link.url)
|
||||
setNewLinkLabel(link.label || '')
|
||||
setEditingLinkIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateLink = () => {
|
||||
if (editingLinkIndex === null || !newLink.trim()) return
|
||||
|
||||
const updatedLinks = [...links]
|
||||
updatedLinks[editingLinkIndex] = {
|
||||
url: newLink.trim(),
|
||||
label: newLinkLabel.trim() || undefined
|
||||
}
|
||||
|
||||
onChange(updatedLinks)
|
||||
setNewLink('')
|
||||
setNewLinkLabel('')
|
||||
setEditingLinkIndex(null)
|
||||
}
|
||||
|
||||
const handleCancelEditLink = () => {
|
||||
setNewLink('')
|
||||
setNewLinkLabel('')
|
||||
setEditingLinkIndex(null)
|
||||
}
|
||||
|
||||
const handleDeleteLink = (index: number) => {
|
||||
const updatedLinks = [...links]
|
||||
updatedLinks.splice(index, 1)
|
||||
onChange(updatedLinks)
|
||||
if (editingLinkIndex === index) {
|
||||
setEditingLinkIndex(null)
|
||||
setNewLink('')
|
||||
setNewLinkLabel('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
🔗 관련 링크
|
||||
</label>
|
||||
|
||||
{/* 링크 목록 */}
|
||||
{links.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{links.map((link, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 bg-blue-50 p-2 rounded">
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 text-xs text-blue-600 hover:underline truncate"
|
||||
>
|
||||
{link.label || link.url}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleEditLink(idx)}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-700 hover:bg-blue-200 rounded text-xs"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteLink(idx)}
|
||||
className="px-2 py-1 bg-red-100 text-red-700 hover:bg-red-200 rounded text-xs"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 링크 입력/편집 폼 */}
|
||||
<div className="space-y-2">
|
||||
{editingLinkIndex !== null && (
|
||||
<div className="text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded">
|
||||
✏️ 링크 편집 중...
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={newLinkLabel}
|
||||
onChange={(e) => setNewLinkLabel(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
document.getElementById('link-url-input')?.focus()
|
||||
}
|
||||
}}
|
||||
placeholder="링크 설명 (선택, 예: 공식 홈페이지)"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="link-url-input"
|
||||
type="url"
|
||||
value={newLink}
|
||||
onChange={(e) => setNewLink(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
editingLinkIndex !== null ? handleUpdateLink() : handleAddLink()
|
||||
}
|
||||
}}
|
||||
placeholder="https://example.com"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-kumamoto-primary focus:border-transparent text-sm"
|
||||
/>
|
||||
{editingLinkIndex !== null ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateLink}
|
||||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors text-sm font-medium"
|
||||
>
|
||||
수정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelEditLink}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors text-sm font-medium"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLink}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors text-sm font-medium"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
💡 링크 설명을 입력하지 않으면 URL이 그대로 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkManagement
|
||||
139
src/components/forms/LocationSelector.tsx
Normal file
139
src/components/forms/LocationSelector.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BasePoint } from '../../types'
|
||||
import { basePointsAPI } from '../../services/api'
|
||||
import CoordinateInput from './CoordinateInput'
|
||||
|
||||
interface LocationSelectorProps {
|
||||
location?: string
|
||||
coordinates?: { lat: number; lng: number } | null
|
||||
onLocationChange: (location: string) => void
|
||||
onCoordinatesChange: (coords: { lat: number; lng: number } | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const LocationSelector = ({
|
||||
location,
|
||||
coordinates,
|
||||
onLocationChange,
|
||||
onCoordinatesChange,
|
||||
className = ''
|
||||
}: LocationSelectorProps) => {
|
||||
const [basePoints, setBasePoints] = useState<BasePoint[]>([])
|
||||
const [showLocationInput, setShowLocationInput] = useState<'saved' | 'new' | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBasePoints()
|
||||
}, [])
|
||||
|
||||
const loadBasePoints = async () => {
|
||||
try {
|
||||
const points = await basePointsAPI.getAll()
|
||||
setBasePoints(points)
|
||||
} catch (error) {
|
||||
console.error('Failed to load base points:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const selectBasePoint = (point: BasePoint) => {
|
||||
onLocationChange(point.address)
|
||||
onCoordinatesChange({
|
||||
lat: point.coordinates.lat,
|
||||
lng: point.coordinates.lng,
|
||||
})
|
||||
setShowLocationInput(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
장소 및 위치 정보 (지도 마커 표시용)
|
||||
</label>
|
||||
|
||||
{/* 저장된 주소 불러오기 또는 직접 입력 선택 */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationInput(showLocationInput === 'saved' ? null : 'saved')}
|
||||
className={`flex-1 px-3 py-2 rounded text-sm ${
|
||||
showLocationInput === 'saved'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-purple-100 text-purple-700 hover:bg-purple-200'
|
||||
}`}
|
||||
>
|
||||
📍 저장된 주소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationInput(showLocationInput === 'new' ? null : 'new')}
|
||||
className={`flex-1 px-3 py-2 rounded text-sm ${
|
||||
showLocationInput === 'new'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
}`}
|
||||
>
|
||||
➕ 직접 입력
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 저장된 주소 목록 */}
|
||||
{showLocationInput === 'saved' && basePoints.length > 0 && (
|
||||
<div className="mb-3 max-h-40 overflow-y-auto border rounded bg-white">
|
||||
{basePoints.map((point) => (
|
||||
<button
|
||||
key={point.id}
|
||||
type="button"
|
||||
onClick={() => selectBasePoint(point)}
|
||||
className="w-full text-left px-3 py-2 hover:bg-gray-100 border-b last:border-b-0"
|
||||
>
|
||||
<div className="font-medium text-sm text-gray-800">
|
||||
{point.type === 'accommodation' ? '🏨' :
|
||||
point.type === 'airport' ? '✈️' :
|
||||
point.type === 'station' ? '🚉' :
|
||||
point.type === 'parking' ? '🅿️' : '📍'} {point.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 truncate">{point.address}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLocationInput === 'saved' && basePoints.length === 0 && (
|
||||
<p className="text-sm text-gray-500 italic mb-3">
|
||||
저장된 주소가 없습니다. 구마모토 지도에서 기본 정보를 먼저 추가해주세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 직접 입력 */}
|
||||
{showLocationInput === 'new' && (
|
||||
<div className="space-y-2 mb-3">
|
||||
<input
|
||||
type="text"
|
||||
value={location || ''}
|
||||
onChange={(e) => onLocationChange(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
placeholder="주소를 입력하세요"
|
||||
/>
|
||||
<CoordinateInput
|
||||
coordinates={coordinates}
|
||||
onChange={onCoordinatesChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 현재 선택된 정보 표시 */}
|
||||
{(location || coordinates) && (
|
||||
<div className="p-2 bg-green-50 border border-green-200 rounded text-xs">
|
||||
{location && <div>📍 {location}</div>}
|
||||
{coordinates && coordinates.lat !== 0 && coordinates.lng !== 0 && (
|
||||
<div className="text-green-700">
|
||||
✓ 좌표: {coordinates.lat.toFixed(4)}, {coordinates.lng.toFixed(4)}
|
||||
<div className="text-green-600 mt-1">→ Trip 페이지 지도에 마커로 표시됩니다</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocationSelector
|
||||
232
src/constants/defaultData.ts
Normal file
232
src/constants/defaultData.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { TravelPlan } from '../types'
|
||||
|
||||
/**
|
||||
* 기본 여행 계획 데이터 (샘플 구마모토 여행)
|
||||
* 새로운 사용자를 위한 템플릿
|
||||
*/
|
||||
export const defaultTravelPlan: TravelPlan = {
|
||||
id: 'legacy_kumamoto_trip',
|
||||
title: '구마모토 3박 4일 여행',
|
||||
description: '아소산과 구마모토성을 중심으로 한 규슈 여행',
|
||||
destination: {
|
||||
country: '일본',
|
||||
city: '구마모토',
|
||||
region: '규슈',
|
||||
coordinates: { lat: 32.7898, lng: 130.7417 }
|
||||
},
|
||||
startDate: new Date('2026-02-17'),
|
||||
endDate: new Date('2026-02-20'),
|
||||
schedule: [
|
||||
{
|
||||
date: new Date('2026-02-18'),
|
||||
activities: [
|
||||
{
|
||||
id: '1',
|
||||
time: '09:00',
|
||||
title: '렌트카 픽업',
|
||||
description: '렌트카로 이동',
|
||||
type: 'transport',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
time: '10:00',
|
||||
title: '기쿠치협곡',
|
||||
description: '렌트카로 이동',
|
||||
location: '기쿠치시',
|
||||
type: 'attraction',
|
||||
relatedPlaces: [
|
||||
{
|
||||
id: 'rp-2-1',
|
||||
name: '기쿠치 온천',
|
||||
description: '기쿠치협곡 인근의 유명 온천 지역. 피로 회복에 좋음',
|
||||
address: '熊本県菊池市隈府',
|
||||
coordinates: { lat: 32.9785, lng: 130.8145 },
|
||||
memo: '협곡 방문 후 온천 추천'
|
||||
},
|
||||
{
|
||||
id: 'rp-2-2',
|
||||
name: '기쿠치 공원',
|
||||
description: '벚꽃 명소로 유명한 공원. 약 3,000그루의 벚나무',
|
||||
address: '熊本県菊池市隈府',
|
||||
coordinates: { lat: 32.9795, lng: 130.8115 },
|
||||
memo: '봄철 벚꽃 시즌 추천'
|
||||
},
|
||||
{
|
||||
id: 'rp-2-3',
|
||||
name: '기쿠치 시립 천문대',
|
||||
description: '맑은 날 별 관측이 가능한 천문대',
|
||||
address: '熊本県菊池市旭志',
|
||||
coordinates: { lat: 32.9523, lng: 130.8789 },
|
||||
memo: '저녁 시간대 방문 가능'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
time: '12:00',
|
||||
title: '쿠사센리',
|
||||
description: '렌트카로 이동',
|
||||
location: '아소시',
|
||||
type: 'attraction',
|
||||
relatedPlaces: [
|
||||
{
|
||||
id: 'rp-3-1',
|
||||
name: '아소 화산 박물관',
|
||||
description: '아소산의 화산 활동과 역사를 전시하는 박물관',
|
||||
address: '熊本県阿蘇市赤水',
|
||||
coordinates: { lat: 32.8817, lng: 131.0126 },
|
||||
memo: '쿠사센리 바로 옆에 위치'
|
||||
},
|
||||
{
|
||||
id: 'rp-3-2',
|
||||
name: '고마야마',
|
||||
description: '쿠사센리 초원에서 볼 수 있는 작은 화산 언덕',
|
||||
address: '熊本県阿蘇市',
|
||||
coordinates: { lat: 32.8856, lng: 131.0105 },
|
||||
memo: '승마 체험 가능'
|
||||
},
|
||||
{
|
||||
id: 'rp-3-3',
|
||||
name: '다카다케 등산로 입구',
|
||||
description: '아소 오악 중 하나인 다카다케 등산로 시작점',
|
||||
address: '熊本県阿蘇市',
|
||||
coordinates: { lat: 32.8923, lng: 131.0234 },
|
||||
memo: '등산 시간: 왕복 약 3-4시간'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
time: '14:00',
|
||||
title: '아소산',
|
||||
description: '렌트카로 이동',
|
||||
location: '아소시',
|
||||
type: 'attraction',
|
||||
relatedPlaces: [
|
||||
{
|
||||
id: 'rp-4-1',
|
||||
name: '나카다케 분화구',
|
||||
description: '아소산의 활화산 분화구. 에메랄드빛 화구호',
|
||||
address: '熊本県阿蘇市黒川',
|
||||
coordinates: { lat: 32.8847, lng: 131.0897 },
|
||||
memo: '화산 활동 상황에 따라 접근 제한 가능'
|
||||
},
|
||||
{
|
||||
id: 'rp-4-2',
|
||||
name: '아소산 로프웨이',
|
||||
description: '분화구까지 이동하는 로프웨이 (운영 중단 시 셔틀버스 이용)',
|
||||
address: '熊本県阿蘇市黒川',
|
||||
coordinates: { lat: 32.8835, lng: 131.0785 },
|
||||
memo: '날씨와 화산 활동 상황 확인 필요'
|
||||
},
|
||||
{
|
||||
id: 'rp-4-3',
|
||||
name: '우치노마키 온천',
|
||||
description: '아소산 북쪽 기슭의 온천 마을',
|
||||
address: '熊本県阿蘇市内牧',
|
||||
coordinates: { lat: 32.9112, lng: 131.0523 },
|
||||
memo: '등산 후 휴식 추천'
|
||||
},
|
||||
{
|
||||
id: 'rp-4-4',
|
||||
name: '아소 신사',
|
||||
description: '2,500년 역사의 신사. 2016년 지진 피해 복구 중',
|
||||
address: '熊本県阿蘇市一の宮町宮地',
|
||||
coordinates: { lat: 32.9478, lng: 131.0267 },
|
||||
memo: '아소 지역의 대표 신사'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
time: '16:00',
|
||||
title: '사라카와수원',
|
||||
description: '렌트카로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
relatedPlaces: [
|
||||
{
|
||||
id: 'rp-5-1',
|
||||
name: '남아소 수원 공원',
|
||||
description: '맑은 지하수가 솟아나는 자연 공원',
|
||||
address: '熊本県阿蘇郡南阿蘇村白川',
|
||||
coordinates: { lat: 32.8345, lng: 131.0456 },
|
||||
memo: '수원 산책로 및 물놀이 가능'
|
||||
},
|
||||
{
|
||||
id: 'rp-5-2',
|
||||
name: '미후네 마치 공룡 박물관',
|
||||
description: '화석 발굴 체험이 가능한 공룡 박물관',
|
||||
address: '熊本県上益城郡御船町',
|
||||
coordinates: { lat: 32.7123, lng: 130.8034 },
|
||||
memo: '어린이 동반 추천'
|
||||
},
|
||||
{
|
||||
id: 'rp-5-3',
|
||||
name: '에코파크 수원의 숲',
|
||||
description: '산책로와 자연 학습 시설이 있는 생태 공원',
|
||||
address: '熊本県阿蘇郡南阿蘇村',
|
||||
coordinates: { lat: 32.8267, lng: 131.0389 },
|
||||
memo: '가족 단위 산책 코스'
|
||||
}
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
date: new Date('2026-02-19'),
|
||||
activities: [
|
||||
{
|
||||
id: '6',
|
||||
time: '09:00',
|
||||
title: '구마모토성',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시 주오구',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
time: '11:30',
|
||||
title: '사쿠라노바바',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
time: '14:00',
|
||||
title: '스이젠지조주엔',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시 주오구',
|
||||
type: 'attraction',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
time: '16:00',
|
||||
title: '시모토리아케이드',
|
||||
description: '대중교통으로 이동',
|
||||
location: '구마모토시',
|
||||
type: 'attraction',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0,
|
||||
},
|
||||
checklist: [],
|
||||
// 새로운 필드들
|
||||
user_id: 'system',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
is_public: true,
|
||||
is_template: true,
|
||||
template_category: 'japan',
|
||||
tags: ['온천', '자연', '역사', '맛집'],
|
||||
status: 'completed'
|
||||
}
|
||||
590
src/data/tripTemplates.ts
Normal file
590
src/data/tripTemplates.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
// 도시별 여행 템플릿 데이터
|
||||
import { TravelPlan, Activity, DaySchedule } from '../types'
|
||||
|
||||
export interface TripTemplate {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
destination: {
|
||||
country: string
|
||||
city: string
|
||||
region?: string
|
||||
coordinates: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
category: 'japan' | 'korea' | 'asia' | 'europe' | 'america' | 'other'
|
||||
duration: number // 추천 일수
|
||||
season: string[] // 추천 계절
|
||||
tags: string[]
|
||||
difficulty: 'easy' | 'medium' | 'hard'
|
||||
budget_range: {
|
||||
min: number
|
||||
max: number
|
||||
currency: 'KRW' | 'JPY' | 'USD'
|
||||
}
|
||||
highlights: string[] // 주요 볼거리
|
||||
thumbnail: string
|
||||
sample_schedule: DaySchedule[]
|
||||
tips: string[]
|
||||
best_time: string
|
||||
transportation: string[]
|
||||
}
|
||||
|
||||
// 구마모토 템플릿
|
||||
export const kumamotoTemplate: TripTemplate = {
|
||||
id: 'kumamoto_3d4n',
|
||||
title: '구마모토 3박 4일 완벽 가이드',
|
||||
description: '구마모토성부터 아소산까지, 규슈의 숨은 보석을 만나는 여행',
|
||||
destination: {
|
||||
country: '일본',
|
||||
city: '구마모토',
|
||||
region: '규슈',
|
||||
coordinates: { lat: 32.7898, lng: 130.7417 }
|
||||
},
|
||||
category: 'japan',
|
||||
duration: 4,
|
||||
season: ['봄', '가을'],
|
||||
tags: ['온천', '자연', '역사', '맛집', '구마몬'],
|
||||
difficulty: 'easy',
|
||||
budget_range: {
|
||||
min: 800000,
|
||||
max: 1200000,
|
||||
currency: 'KRW'
|
||||
},
|
||||
highlights: [
|
||||
'구마모토성 - 일본 3대 명성 중 하나',
|
||||
'아소산 - 세계 최대급 칼데라',
|
||||
'구로카와 온천 - 정통 온천 마을',
|
||||
'구마모토 라멘 - 마늘과 돈코츠의 조화'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&q=80',
|
||||
sample_schedule: [
|
||||
{
|
||||
date: new Date('2024-02-17'),
|
||||
activities: [
|
||||
{
|
||||
id: 'day1_1',
|
||||
time: '09:00',
|
||||
title: '후쿠오카 공항 도착',
|
||||
description: '후쿠오카 공항에서 구마모토로 이동',
|
||||
location: '후쿠오카 공항',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 33.5859, lng: 130.4507 }
|
||||
},
|
||||
{
|
||||
id: 'day1_2',
|
||||
time: '12:00',
|
||||
title: '구마모토성 관람',
|
||||
description: '일본 3대 명성 중 하나인 구마모토성 탐방',
|
||||
location: '구마모토성',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.8064, lng: 130.7056 }
|
||||
},
|
||||
{
|
||||
id: 'day1_3',
|
||||
time: '15:00',
|
||||
title: '사쿠라노바바 조사이엔',
|
||||
description: '구마모토의 역사와 문화를 체험할 수 있는 복합시설',
|
||||
location: '사쿠라노바바 조사이엔',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.8070, lng: 130.7060 }
|
||||
},
|
||||
{
|
||||
id: 'day1_4',
|
||||
time: '18:00',
|
||||
title: '구마모토 라멘 저녁식사',
|
||||
description: '마늘과 돈코츠 스프의 진한 맛',
|
||||
location: '라멘 요코초',
|
||||
type: 'food',
|
||||
coordinates: { lat: 32.7898, lng: 130.7417 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: new Date('2024-02-18'),
|
||||
activities: [
|
||||
{
|
||||
id: 'day2_1',
|
||||
time: '08:00',
|
||||
title: '렌터카 픽업',
|
||||
description: '아소 지역 관광을 위한 렌터카 대여',
|
||||
location: '구마모토역 렌터카',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 32.789827, lng: 130.741667 }
|
||||
},
|
||||
{
|
||||
id: 'day2_2',
|
||||
time: '10:00',
|
||||
title: '기쿠치 협곡',
|
||||
description: '에메랄드빛 계곡과 폭포의 절경',
|
||||
location: '기쿠치 협곡',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.985, lng: 130.856 }
|
||||
},
|
||||
{
|
||||
id: 'day2_3',
|
||||
time: '13:00',
|
||||
title: '쿠사센리 초원',
|
||||
description: '아소산 기슭의 광활한 초원',
|
||||
location: '쿠사센리',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.8856, lng: 131.0105 }
|
||||
},
|
||||
{
|
||||
id: 'day2_4',
|
||||
time: '15:30',
|
||||
title: '아소산 화구 견학',
|
||||
description: '활화산의 웅장한 화구를 직접 체험',
|
||||
location: '아소산',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.8847, lng: 131.0897 }
|
||||
},
|
||||
{
|
||||
id: 'day2_5',
|
||||
time: '17:00',
|
||||
title: '구로카와 온천 체크인',
|
||||
description: '전통 온천 료칸에서의 힐링 타임',
|
||||
location: '구로카와 온천',
|
||||
type: 'accommodation',
|
||||
coordinates: { lat: 33.0564, lng: 131.1175 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: new Date('2024-02-19'),
|
||||
activities: [
|
||||
{
|
||||
id: 'day3_1',
|
||||
time: '09:00',
|
||||
title: '온천 료칸 조식',
|
||||
description: '전통 일식 조식과 노천온천',
|
||||
location: '구로카와 온천 료칸',
|
||||
type: 'food',
|
||||
coordinates: { lat: 33.0564, lng: 131.1175 }
|
||||
},
|
||||
{
|
||||
id: 'day3_2',
|
||||
time: '11:00',
|
||||
title: '시라카와 수원',
|
||||
description: '일본 명수 100선의 맑은 용천수',
|
||||
location: '시라카와 수원',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.885833, lng: 130.879444 }
|
||||
},
|
||||
{
|
||||
id: 'day3_3',
|
||||
time: '14:00',
|
||||
title: '스이젠지 조주엔',
|
||||
description: '미니어처 후지산이 있는 일본 정원',
|
||||
location: '스이젠지 조주엔',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 32.784722, lng: 130.743889 }
|
||||
},
|
||||
{
|
||||
id: 'day3_4',
|
||||
time: '16:00',
|
||||
title: '시모토리 아케이드 쇼핑',
|
||||
description: '구마모토 최대 번화가에서 쇼핑과 간식',
|
||||
location: '시모토리 아케이드',
|
||||
type: 'other',
|
||||
coordinates: { lat: 32.799167, lng: 130.711667 }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
date: new Date('2024-02-20'),
|
||||
activities: [
|
||||
{
|
||||
id: 'day4_1',
|
||||
time: '10:00',
|
||||
title: '구마몬 스퀘어',
|
||||
description: '구마모토 마스코트 구마몬의 공식 매장',
|
||||
location: '구마몬 스퀘어',
|
||||
type: 'other',
|
||||
coordinates: { lat: 32.7898, lng: 130.7417 }
|
||||
},
|
||||
{
|
||||
id: 'day4_2',
|
||||
time: '12:00',
|
||||
title: '마지막 구마모토 라멘',
|
||||
description: '여행의 마무리를 장식하는 라멘',
|
||||
location: '구마모토역 라멘',
|
||||
type: 'food',
|
||||
coordinates: { lat: 32.789827, lng: 130.741667 }
|
||||
},
|
||||
{
|
||||
id: 'day4_3',
|
||||
time: '14:00',
|
||||
title: '후쿠오카 공항 출발',
|
||||
description: '구마모토에서 후쿠오카 공항으로 이동',
|
||||
location: '후쿠오카 공항',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 33.5859, lng: 130.4507 }
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
tips: [
|
||||
'렌터카 예약은 미리 해두세요 (특히 성수기)',
|
||||
'아소산 화구는 날씨에 따라 출입이 제한될 수 있습니다',
|
||||
'구로카와 온천은 료칸 예약이 필수입니다',
|
||||
'구마모토 라멘은 마늘이 많이 들어가니 참고하세요',
|
||||
'JR 규슈 패스를 활용하면 교통비를 절약할 수 있습니다'
|
||||
],
|
||||
best_time: '3월-5월 (벚꽃), 9월-11월 (단풍)',
|
||||
transportation: ['렌터카', 'JR 규슈선', '고속버스']
|
||||
}
|
||||
|
||||
// 도쿄 템플릿
|
||||
export const tokyoTemplate: TripTemplate = {
|
||||
id: 'tokyo_4d5n',
|
||||
title: '도쿄 4박 5일 완전정복',
|
||||
description: '전통과 현대가 공존하는 일본의 수도 도쿄 완전 가이드',
|
||||
destination: {
|
||||
country: '일본',
|
||||
city: '도쿄',
|
||||
region: '간토',
|
||||
coordinates: { lat: 35.6762, lng: 139.6503 }
|
||||
},
|
||||
category: 'japan',
|
||||
duration: 5,
|
||||
season: ['봄', '가을', '겨울'],
|
||||
tags: ['도시', '쇼핑', '문화', '음식', '현대'],
|
||||
difficulty: 'medium',
|
||||
budget_range: {
|
||||
min: 1500000,
|
||||
max: 2500000,
|
||||
currency: 'KRW'
|
||||
},
|
||||
highlights: [
|
||||
'센소지 - 도쿄 최고(最古)의 사찰',
|
||||
'시부야 스크램블 교차로 - 세계에서 가장 바쁜 교차로',
|
||||
'츠키지 시장 - 신선한 해산물의 천국',
|
||||
'도쿄 스카이트리 - 634m 높이의 랜드마크'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1540959733332-eab4deabeeaf?w=400&q=80',
|
||||
sample_schedule: [
|
||||
{
|
||||
date: new Date('2024-03-15'),
|
||||
activities: [
|
||||
{
|
||||
id: 'tokyo_day1_1',
|
||||
time: '09:00',
|
||||
title: '나리타 공항 도착',
|
||||
description: '나리타 익스프레스로 도쿄역까지 이동',
|
||||
location: '나리타 공항',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 35.7720, lng: 140.3929 }
|
||||
},
|
||||
{
|
||||
id: 'tokyo_day1_2',
|
||||
time: '12:00',
|
||||
title: '아사쿠사 센소지',
|
||||
description: '도쿄에서 가장 오래된 사찰 탐방',
|
||||
location: '센소지',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 35.7148, lng: 139.7967 }
|
||||
},
|
||||
{
|
||||
id: 'tokyo_day1_3',
|
||||
time: '15:00',
|
||||
title: '도쿄 스카이트리',
|
||||
description: '634m 높이에서 바라보는 도쿄 전경',
|
||||
location: '도쿄 스카이트리',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 35.7101, lng: 139.8107 }
|
||||
},
|
||||
{
|
||||
id: 'tokyo_day1_4',
|
||||
time: '18:00',
|
||||
title: '스시 저녁식사',
|
||||
description: '긴자의 정통 스시 오마카세',
|
||||
location: '긴자',
|
||||
type: 'food',
|
||||
coordinates: { lat: 35.6717, lng: 139.7640 }
|
||||
}
|
||||
]
|
||||
}
|
||||
// 추가 일정들...
|
||||
],
|
||||
tips: [
|
||||
'JR 패스를 미리 구매하면 교통비를 크게 절약할 수 있습니다',
|
||||
'러시아워(7-9시, 17-19시)에는 지하철이 매우 혼잡합니다',
|
||||
'현금 사용이 많으니 충분한 엔화를 준비하세요',
|
||||
'구글 번역 앱을 설치해두면 도움이 됩니다'
|
||||
],
|
||||
best_time: '3월-5월 (벚꽃), 9월-11월 (단풍)',
|
||||
transportation: ['JR 야마노테선', '지하철', '택시']
|
||||
}
|
||||
|
||||
// 오사카 템플릿
|
||||
export const osakaTemplate: TripTemplate = {
|
||||
id: 'osaka_3d4n',
|
||||
title: '오사카 3박 4일 미식여행',
|
||||
description: '일본의 부엌 오사카에서 즐기는 최고의 먹거리 여행',
|
||||
destination: {
|
||||
country: '일본',
|
||||
city: '오사카',
|
||||
region: '간사이',
|
||||
coordinates: { lat: 34.6937, lng: 135.5023 }
|
||||
},
|
||||
category: 'japan',
|
||||
duration: 4,
|
||||
season: ['봄', '가을'],
|
||||
tags: ['미식', '성', '쇼핑', '엔터테인먼트'],
|
||||
difficulty: 'easy',
|
||||
budget_range: {
|
||||
min: 1000000,
|
||||
max: 1800000,
|
||||
currency: 'KRW'
|
||||
},
|
||||
highlights: [
|
||||
'오사카성 - 도요토미 히데요시의 거성',
|
||||
'도톤보리 - 네온사인과 먹거리의 거리',
|
||||
'유니버설 스튜디오 재팬 - 해리포터 월드',
|
||||
'구로몬 시장 - 오사카의 부엌'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1590559899731-a382839e5549?w=400&q=80',
|
||||
sample_schedule: [
|
||||
{
|
||||
date: new Date('2024-04-10'),
|
||||
activities: [
|
||||
{
|
||||
id: 'osaka_day1_1',
|
||||
time: '10:00',
|
||||
title: '간사이 공항 도착',
|
||||
description: '간사이 공항에서 오사카 시내로 이동',
|
||||
location: '간사이 공항',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 34.4347, lng: 135.2441 }
|
||||
},
|
||||
{
|
||||
id: 'osaka_day1_2',
|
||||
time: '13:00',
|
||||
title: '오사카성 관람',
|
||||
description: '일본의 3대 명성 중 하나인 오사카성',
|
||||
location: '오사카성',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 34.6873, lng: 135.5262 }
|
||||
},
|
||||
{
|
||||
id: 'osaka_day1_3',
|
||||
time: '16:00',
|
||||
title: '도톤보리 거리',
|
||||
description: '오사카의 상징적인 네온사인 거리',
|
||||
location: '도톤보리',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 34.6686, lng: 135.5023 }
|
||||
},
|
||||
{
|
||||
id: 'osaka_day1_4',
|
||||
time: '18:00',
|
||||
title: '타코야키 & 오코노미야키',
|
||||
description: '오사카 대표 먹거리 체험',
|
||||
location: '도톤보리 먹거리 골목',
|
||||
type: 'food',
|
||||
coordinates: { lat: 34.6686, lng: 135.5023 }
|
||||
}
|
||||
]
|
||||
}
|
||||
// 추가 일정들...
|
||||
],
|
||||
tips: [
|
||||
'오사카 어메이징 패스로 관광지와 교통을 한번에 해결하세요',
|
||||
'타코야키는 뜨거우니 조심해서 드세요',
|
||||
'유니버설 스튜디오는 미리 티켓을 예약하세요',
|
||||
'간사이벤(오사카 사투리)을 몇 개 배워가면 재미있어요'
|
||||
],
|
||||
best_time: '3월-5월 (벚꽃), 9월-11월 (단풍)',
|
||||
transportation: ['JR 오사카환상선', '지하철', '한큐선', '한신선']
|
||||
}
|
||||
|
||||
// 서울 템플릿
|
||||
export const seoulTemplate: TripTemplate = {
|
||||
id: 'seoul_3d4n',
|
||||
title: '서울 3박 4일 완벽가이드',
|
||||
description: '전통과 현대가 어우러진 대한민국의 수도 서울 탐방',
|
||||
destination: {
|
||||
country: '한국',
|
||||
city: '서울',
|
||||
region: '수도권',
|
||||
coordinates: { lat: 37.5665, lng: 126.9780 }
|
||||
},
|
||||
category: 'korea',
|
||||
duration: 4,
|
||||
season: ['봄', '가을'],
|
||||
tags: ['궁궐', '한식', '쇼핑', 'K-문화', '야경'],
|
||||
difficulty: 'easy',
|
||||
budget_range: {
|
||||
min: 400000,
|
||||
max: 800000,
|
||||
currency: 'KRW'
|
||||
},
|
||||
highlights: [
|
||||
'경복궁 - 조선왕조의 정궁',
|
||||
'명동 - 쇼핑과 먹거리의 중심지',
|
||||
'한강 - 서울 시민들의 휴식처',
|
||||
'N서울타워 - 서울의 랜드마크'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&q=80',
|
||||
sample_schedule: [
|
||||
{
|
||||
date: new Date('2024-05-01'),
|
||||
activities: [
|
||||
{
|
||||
id: 'seoul_day1_1',
|
||||
time: '09:00',
|
||||
title: '경복궁 관람',
|
||||
description: '조선왕조 정궁에서 수문장 교대식 관람',
|
||||
location: '경복궁',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 37.5796, lng: 126.9770 }
|
||||
},
|
||||
{
|
||||
id: 'seoul_day1_2',
|
||||
time: '12:00',
|
||||
title: '북촌 한옥마을',
|
||||
description: '전통 한옥이 보존된 아름다운 마을',
|
||||
location: '북촌 한옥마을',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 37.5814, lng: 126.9835 }
|
||||
},
|
||||
{
|
||||
id: 'seoul_day1_3',
|
||||
time: '15:00',
|
||||
title: '인사동 문화거리',
|
||||
description: '전통 문화와 예술이 살아있는 거리',
|
||||
location: '인사동',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 37.5715, lng: 126.9854 }
|
||||
},
|
||||
{
|
||||
id: 'seoul_day1_4',
|
||||
time: '18:00',
|
||||
title: '한정식 저녁',
|
||||
description: '정통 한국 전통 요리 체험',
|
||||
location: '인사동 한정식집',
|
||||
type: 'food',
|
||||
coordinates: { lat: 37.5715, lng: 126.9854 }
|
||||
}
|
||||
]
|
||||
}
|
||||
// 추가 일정들...
|
||||
],
|
||||
tips: [
|
||||
'T-money 카드로 지하철과 버스를 편리하게 이용하세요',
|
||||
'궁궐 관람시 한복을 입으면 입장료가 무료입니다',
|
||||
'명동과 홍대는 밤늦게까지 활기찬 곳입니다',
|
||||
'한강 공원에서 치킨과 맥주를 즐겨보세요'
|
||||
],
|
||||
best_time: '4월-6월 (봄), 9월-11월 (가을)',
|
||||
transportation: ['지하철', '버스', '택시', '따릉이']
|
||||
}
|
||||
|
||||
// 제주도 템플릿
|
||||
export const jejuTemplate: TripTemplate = {
|
||||
id: 'jeju_3d4n',
|
||||
title: '제주도 3박 4일 자연여행',
|
||||
description: '한국의 하와이 제주도에서 만나는 아름다운 자연',
|
||||
destination: {
|
||||
country: '한국',
|
||||
city: '제주',
|
||||
region: '제주특별자치도',
|
||||
coordinates: { lat: 33.4996, lng: 126.5312 }
|
||||
},
|
||||
category: 'korea',
|
||||
duration: 4,
|
||||
season: ['봄', '여름', '가을'],
|
||||
tags: ['자연', '해변', '한라산', '감귤', '돌하르방'],
|
||||
difficulty: 'medium',
|
||||
budget_range: {
|
||||
min: 600000,
|
||||
max: 1200000,
|
||||
currency: 'KRW'
|
||||
},
|
||||
highlights: [
|
||||
'한라산 - 대한민국 최고봉',
|
||||
'성산일출봉 - 유네스코 세계자연유산',
|
||||
'우도 - 제주의 작은 섬',
|
||||
'협재해수욕장 - 에메랄드빛 바다'
|
||||
],
|
||||
thumbnail: 'https://images.unsplash.com/photo-1578662996442-48f60103fc96?w=400&q=80',
|
||||
sample_schedule: [
|
||||
{
|
||||
date: new Date('2024-06-15'),
|
||||
activities: [
|
||||
{
|
||||
id: 'jeju_day1_1',
|
||||
time: '10:00',
|
||||
title: '제주공항 도착',
|
||||
description: '렌터카 픽업 후 여행 시작',
|
||||
location: '제주국제공항',
|
||||
type: 'transport',
|
||||
coordinates: { lat: 33.5067, lng: 126.4930 }
|
||||
},
|
||||
{
|
||||
id: 'jeju_day1_2',
|
||||
time: '12:00',
|
||||
title: '성산일출봉',
|
||||
description: '유네스코 세계자연유산 등반',
|
||||
location: '성산일출봉',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 33.4584, lng: 126.9424 }
|
||||
},
|
||||
{
|
||||
id: 'jeju_day1_3',
|
||||
time: '15:00',
|
||||
title: '우도 관광',
|
||||
description: '제주의 작은 섬 우도에서 자전거 여행',
|
||||
location: '우도',
|
||||
type: 'attraction',
|
||||
coordinates: { lat: 33.5009, lng: 126.9505 }
|
||||
},
|
||||
{
|
||||
id: 'jeju_day1_4',
|
||||
time: '18:00',
|
||||
title: '해산물 저녁',
|
||||
description: '신선한 제주 바다의 맛',
|
||||
location: '성산포 해산물 식당',
|
||||
type: 'food',
|
||||
coordinates: { lat: 33.4584, lng: 126.9424 }
|
||||
}
|
||||
]
|
||||
}
|
||||
// 추가 일정들...
|
||||
],
|
||||
tips: [
|
||||
'렌터카는 필수입니다 (대중교통이 불편함)',
|
||||
'날씨 변화가 심하니 여벌 옷을 준비하세요',
|
||||
'한라산 등반시 등산화와 충분한 물을 준비하세요',
|
||||
'감귤과 흑돼지는 꼭 맛보세요'
|
||||
],
|
||||
best_time: '4월-6월 (봄), 9월-11월 (가을)',
|
||||
transportation: ['렌터카', '관광버스', '택시']
|
||||
}
|
||||
|
||||
// 전체 템플릿 목록
|
||||
export const tripTemplates: TripTemplate[] = [
|
||||
kumamotoTemplate,
|
||||
tokyoTemplate,
|
||||
osakaTemplate,
|
||||
seoulTemplate,
|
||||
jejuTemplate
|
||||
]
|
||||
|
||||
// 카테고리별 템플릿 필터링
|
||||
export const getTemplatesByCategory = (category: string): TripTemplate[] => {
|
||||
return tripTemplates.filter(template => template.category === category)
|
||||
}
|
||||
|
||||
// 템플릿 ID로 조회
|
||||
export const getTemplateById = (id: string): TripTemplate | undefined => {
|
||||
return tripTemplates.find(template => template.id === id)
|
||||
}
|
||||
|
||||
// 추천 템플릿 (인기순)
|
||||
export const getRecommendedTemplates = (): TripTemplate[] => {
|
||||
return [kumamotoTemplate, tokyoTemplate, seoulTemplate, jejuTemplate, osakaTemplate]
|
||||
}
|
||||
12
src/env.d.ts
vendored
Normal file
12
src/env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GOOGLE_OAUTH_CLIENT_ID: string
|
||||
readonly MODE: string
|
||||
readonly DEV: boolean
|
||||
readonly PROD: boolean
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -11,3 +11,14 @@ body {
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 스크롤바 숨기기 유틸리티 */
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
278
src/pages/PlanPage.tsx
Normal file
278
src/pages/PlanPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import Header from '../components/Header'
|
||||
import Timeline from '../components/Timeline'
|
||||
import Attractions, { attractions } from '../components/Attractions'
|
||||
import UnifiedMap from '../components/UnifiedMap'
|
||||
import Budget from '../components/Budget'
|
||||
import Checklist from '../components/Checklist'
|
||||
import TravelDashboard from '../components/TravelDashboard'
|
||||
import TabNavigation from '../components/TabNavigation'
|
||||
import { TravelPlan, Activity } from '../types'
|
||||
import { tripManagerService } from '../services/tripManager'
|
||||
import { offlineSupportService } from '../services/offlineSupport'
|
||||
|
||||
// PC용 일정 수립 페이지 - 편집 기능 중심
|
||||
function PlanPage() {
|
||||
const { tripId } = useParams<{ tripId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [travelPlan, setTravelPlan] = useState<TravelPlan | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 여행 계획 로드
|
||||
useEffect(() => {
|
||||
async function loadTravelPlan() {
|
||||
if (!tripId) {
|
||||
setError('여행 ID가 제공되지 않았습니다')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = await tripManagerService.getTrip(tripId)
|
||||
if (plan) {
|
||||
setTravelPlan(plan)
|
||||
} else {
|
||||
setError('여행 계획을 찾을 수 없습니다')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load travel plan:', err)
|
||||
setError('여행 계획을 불러오는 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadTravelPlan()
|
||||
}, [tripId])
|
||||
|
||||
// 여행 계획 자동 저장
|
||||
useEffect(() => {
|
||||
if (travelPlan && !isLoading) {
|
||||
const saveTrip = async () => {
|
||||
try {
|
||||
await tripManagerService.updateTrip(travelPlan.id, travelPlan)
|
||||
await offlineSupportService.cacheTravelPlan(travelPlan)
|
||||
} catch (error) {
|
||||
console.error('Failed to save travel plan:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(saveTrip, 1000) // 1초 후 저장
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [travelPlan, isLoading])
|
||||
|
||||
// Places 검색 결과에서 일정에 추가하는 핸들러
|
||||
const handleAddPlaceToItinerary = async (place: {
|
||||
name: string
|
||||
address: string
|
||||
lat: number
|
||||
lng: number
|
||||
}) => {
|
||||
if (!travelPlan) return
|
||||
|
||||
try {
|
||||
// 현재 날짜의 스케줄을 찾거나 새로 생성
|
||||
const today = new Date()
|
||||
const currentDayScheduleIndex = travelPlan.schedule.findIndex(
|
||||
(day) =>
|
||||
new Date(day.date).toDateString() === today.toDateString()
|
||||
)
|
||||
|
||||
let updatedPlan: TravelPlan
|
||||
const newActivity: Activity = {
|
||||
id: `activity-${Date.now()}`,
|
||||
time: '12:00', // 기본 시간, 사용자가 수정 가능
|
||||
title: place.name,
|
||||
description: place.address,
|
||||
location: place.address,
|
||||
type: 'attraction',
|
||||
coordinates: { lat: place.lat, lng: place.lng },
|
||||
}
|
||||
|
||||
if (currentDayScheduleIndex !== -1) {
|
||||
// 기존 날짜에 추가
|
||||
const updatedSchedule = travelPlan.schedule.map((day, index) =>
|
||||
index === currentDayScheduleIndex
|
||||
? {
|
||||
...day,
|
||||
activities: [...day.activities, newActivity].sort((a, b) =>
|
||||
a.time.localeCompare(b.time)
|
||||
),
|
||||
}
|
||||
: day
|
||||
)
|
||||
updatedPlan = { ...travelPlan, schedule: updatedSchedule }
|
||||
} else {
|
||||
// 새 날짜 생성 (오늘 날짜)
|
||||
const newDaySchedule = {
|
||||
date: today,
|
||||
activities: [newActivity],
|
||||
}
|
||||
updatedPlan = {
|
||||
...travelPlan,
|
||||
schedule: [...travelPlan.schedule, newDaySchedule].sort(
|
||||
(a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
setTravelPlan(updatedPlan)
|
||||
alert(`"${place.name}"이(가) 일정에 추가되었습니다!`)
|
||||
} catch (error) {
|
||||
console.error('Failed to add place to itinerary:', error)
|
||||
alert('일정 추가에 실패했습니다')
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 데이터로 리셋
|
||||
const resetToDefault = async () => {
|
||||
if (!travelPlan) return
|
||||
|
||||
if (confirm('모든 여행 계획을 초기화하시겠습니까?\n\n⚠️ 이 작업은 되돌릴 수 없습니다.')) {
|
||||
try {
|
||||
const resetPlan: TravelPlan = {
|
||||
...travelPlan,
|
||||
schedule: [],
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0,
|
||||
},
|
||||
checklist: [],
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
await tripManagerService.updateTrip(travelPlan.id, resetPlan)
|
||||
setTravelPlan(resetPlan)
|
||||
alert('여행 계획이 초기화되었습니다')
|
||||
} catch (error) {
|
||||
console.error('Failed to reset:', error)
|
||||
alert('초기화에 실패했습니다')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-kumamoto-light flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-kumamoto-primary mx-auto mb-4"></div>
|
||||
<div className="text-lg text-gray-600">여행 계획을 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !travelPlan) {
|
||||
return (
|
||||
<div className="min-h-screen bg-kumamoto-light flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-medium text-gray-800 mb-2">
|
||||
{error || '여행 계획을 찾을 수 없습니다'}
|
||||
</div>
|
||||
<div className="text-gray-600 mb-4">
|
||||
다시 시도하거나 메인 페이지로 돌아가세요
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
메인으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-kumamoto-light">
|
||||
<Header />
|
||||
|
||||
{/* 뒤로가기 및 모바일 모드 버튼 */}
|
||||
<div className="bg-white border-b border-gray-200 px-4 py-3">
|
||||
<div className="max-w-7xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center gap-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
← 여행 목록으로 돌아가기
|
||||
</Link>
|
||||
<div className="text-gray-300">|</div>
|
||||
<h1 className="text-xl font-semibold text-gray-800">
|
||||
{travelPlan.title}
|
||||
</h1>
|
||||
<span className="text-sm text-gray-500">
|
||||
📍 {travelPlan.destination.city}, {travelPlan.destination.country}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to={`/trip/${travelPlan.id}`}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
🗺️ 여행 모드
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<TravelDashboard travelPlan={travelPlan} onReset={resetToDefault} />
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6">
|
||||
{/* 지도 영역 (50%) */}
|
||||
<div className="xl:col-span-6">
|
||||
<UnifiedMap
|
||||
attractions={attractions}
|
||||
onAddPlaceToItinerary={handleAddPlaceToItinerary}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 일정 영역 (30%) */}
|
||||
<div className="xl:col-span-4">
|
||||
<div className="bg-white rounded-lg shadow-md">
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-bold text-gray-800">
|
||||
🗓️ 여행 일정
|
||||
</h2>
|
||||
</div>
|
||||
<Timeline
|
||||
travelPlan={travelPlan}
|
||||
setTravelPlan={setTravelPlan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사이드바 (20%) */}
|
||||
<div className="xl:col-span-2">
|
||||
<TabNavigation
|
||||
attractions={attractions}
|
||||
travelPlan={travelPlan}
|
||||
setTravelPlan={setTravelPlan}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PlanPage
|
||||
332
src/pages/TripPage.tsx
Normal file
332
src/pages/TripPage.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { TravelPlan, Activity } from '../types'
|
||||
import { tripManagerService } from '../services/tripManager'
|
||||
import UnifiedMap from '../components/UnifiedMap'
|
||||
import { format, eachDayOfInterval } from 'date-fns'
|
||||
|
||||
// 모바일용 여행 페이지 - 전체 화면 지도
|
||||
function TripPage() {
|
||||
const { tripId } = useParams<{ tripId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [travelPlan, setTravelPlan] = useState<TravelPlan | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [currentDay, setCurrentDay] = useState(0)
|
||||
const [showSidebar, setShowSidebar] = useState(true)
|
||||
const [selectedActivityId, setSelectedActivityId] = useState<string | null>(null)
|
||||
|
||||
// 여행 계획 로드
|
||||
useEffect(() => {
|
||||
async function loadTravelPlan() {
|
||||
if (!tripId) {
|
||||
setError('여행 ID가 제공되지 않았습니다')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = await tripManagerService.getTrip(tripId)
|
||||
if (plan) {
|
||||
setTravelPlan(plan)
|
||||
|
||||
// 현재 날짜에 해당하는 일차 찾기
|
||||
const today = new Date()
|
||||
const days = eachDayOfInterval({
|
||||
start: new Date(plan.startDate),
|
||||
end: new Date(plan.endDate),
|
||||
})
|
||||
|
||||
const todayIndex = days.findIndex(day =>
|
||||
format(day, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
if (todayIndex !== -1) {
|
||||
setCurrentDay(todayIndex)
|
||||
}
|
||||
} else {
|
||||
setError('여행 계획을 찾을 수 없습니다')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load travel plan:', err)
|
||||
setError('여행 계획을 불러오는 중 오류가 발생했습니다')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadTravelPlan()
|
||||
}, [tripId])
|
||||
|
||||
// 날짜 변경시 선택 해제
|
||||
useEffect(() => {
|
||||
setSelectedActivityId(null)
|
||||
}, [currentDay])
|
||||
|
||||
// 활동에서 좌표 가져오기
|
||||
const getActivityCoords = (activity: Activity): [number, number] | null => {
|
||||
if (activity.coordinates) {
|
||||
return [activity.coordinates.lat, activity.coordinates.lng]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 활동 클릭 핸들러
|
||||
const handleActivityClick = (activity: Activity) => {
|
||||
setSelectedActivityId(activity.id)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-lg text-gray-600">여행 계획을 불러오는 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !travelPlan) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
|
||||
<div className="text-center p-6">
|
||||
<div className="text-4xl mb-4">⚠️</div>
|
||||
<div className="text-lg font-medium text-gray-800 mb-2">
|
||||
{error || '여행 계획을 찾을 수 없습니다'}
|
||||
</div>
|
||||
<div className="text-gray-600 mb-4">
|
||||
다시 시도하거나 메인 페이지로 돌아가세요
|
||||
</div>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<Link
|
||||
to="/"
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
|
||||
>
|
||||
메인으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: new Date(travelPlan.startDate),
|
||||
end: new Date(travelPlan.endDate),
|
||||
})
|
||||
|
||||
const currentDaySchedule = travelPlan.schedule.find(
|
||||
(daySchedule) =>
|
||||
format(daySchedule.date, 'yyyy-MM-dd') === format(days[currentDay], 'yyyy-MM-dd')
|
||||
)
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
return format(date, 'M월 d일 (E)', { locale: undefined })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-gray-100">
|
||||
{/* 상단 헤더 */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 p-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
← 목록
|
||||
</Link>
|
||||
<div className="text-gray-300">|</div>
|
||||
<h1 className="text-lg font-semibold text-gray-800">
|
||||
{travelPlan.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/plan/${travelPlan.id}`}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
✏️ 편집
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setShowSidebar(!showSidebar)}
|
||||
className="px-3 py-1 bg-gray-600 text-white rounded text-sm hover:bg-gray-700 lg:hidden"
|
||||
>
|
||||
{showSidebar ? '지도만' : '일정'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜 선택 탭 */}
|
||||
<div className="flex gap-2 mt-3 overflow-x-auto pb-2">
|
||||
{days.map((day, index) => (
|
||||
<button
|
||||
key={day.toISOString()}
|
||||
onClick={() => setCurrentDay(index)}
|
||||
className={`px-4 py-2 rounded-lg whitespace-nowrap text-sm transition-colors ${
|
||||
currentDay === index
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{formatDate(day)}</div>
|
||||
<div className="text-xs opacity-75">
|
||||
{index + 1}일차
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 콘텐츠 */}
|
||||
<div className="flex-1 flex relative">
|
||||
{/* 지도 영역 */}
|
||||
<div className={`flex-1 transition-all duration-300 ${showSidebar ? 'lg:mr-80' : ''}`}>
|
||||
<UnifiedMap
|
||||
attractions={[]}
|
||||
selectedActivityId={selectedActivityId}
|
||||
currentDaySchedule={currentDaySchedule}
|
||||
onActivityClick={handleActivityClick}
|
||||
getActivityCoords={getActivityCoords}
|
||||
showControls={false}
|
||||
className="h-full rounded-none shadow-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽 사이드바 (일정 정보) */}
|
||||
{showSidebar && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-80 bg-white shadow-lg border-l border-gray-200 overflow-y-auto lg:relative lg:w-80">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-800">
|
||||
{formatDate(days[currentDay])} 일정
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowSidebar(false)}
|
||||
className="text-gray-400 hover:text-gray-600 lg:hidden"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentDaySchedule && currentDaySchedule.activities.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{currentDaySchedule.activities.map((activity) => (
|
||||
<div
|
||||
key={activity.id}
|
||||
onClick={() => handleActivityClick(activity)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
selectedActivityId === activity.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-sm font-medium text-blue-600 mt-0.5">
|
||||
{activity.time}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-gray-800 mb-1">
|
||||
{activity.title}
|
||||
</h3>
|
||||
{activity.location && (
|
||||
<div className="text-sm text-gray-600 mb-1">
|
||||
📍 {activity.location}
|
||||
</div>
|
||||
)}
|
||||
{activity.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{activity.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<span className={`inline-block px-2 py-1 text-xs rounded-full ${
|
||||
activity.type === 'attraction' ? 'bg-green-100 text-green-800' :
|
||||
activity.type === 'food' ? 'bg-orange-100 text-orange-800' :
|
||||
activity.type === 'accommodation' ? 'bg-purple-100 text-purple-800' :
|
||||
activity.type === 'transport' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{activity.type === 'attraction' ? '관광' :
|
||||
activity.type === 'food' ? '식사' :
|
||||
activity.type === 'accommodation' ? '숙박' :
|
||||
activity.type === 'transport' ? '이동' : '기타'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">📅</div>
|
||||
<div className="text-gray-600">
|
||||
이 날짜에 계획된 일정이 없습니다
|
||||
</div>
|
||||
<Link
|
||||
to={`/plan/${travelPlan.id}`}
|
||||
className="inline-block mt-3 px-4 py-2 bg-blue-600 text-white rounded text-sm hover:bg-blue-700"
|
||||
>
|
||||
일정 추가하기
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 여행 정보 요약 */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<h3 className="font-medium text-gray-800 mb-3">여행 정보</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">목적지</span>
|
||||
<span className="font-medium">
|
||||
{travelPlan.destination.city}, {travelPlan.destination.country}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">기간</span>
|
||||
<span className="font-medium">
|
||||
{format(new Date(travelPlan.startDate), 'M/d')} - {format(new Date(travelPlan.endDate), 'M/d')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">총 일정</span>
|
||||
<span className="font-medium">
|
||||
{travelPlan.schedule.reduce((total, day) => total + day.activities.length, 0)}개
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{travelPlan.tags.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-sm text-gray-600 mb-2">태그</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{travelPlan.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-block px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TripPage
|
||||
98
src/services/api.ts
Normal file
98
src/services/api.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { TravelPlan, BasePoint } from '../types'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
|
||||
// 여행 계획 API
|
||||
export const travelPlanAPI = {
|
||||
// 여행 계획 조회
|
||||
async get(): Promise<TravelPlan | null> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/travel-plans`)
|
||||
if (!response.ok) throw new Error('Failed to fetch travel plan')
|
||||
const data = await response.json()
|
||||
|
||||
if (!data) return null
|
||||
|
||||
// Date 객체로 변환
|
||||
return {
|
||||
...data,
|
||||
startDate: new Date(data.startDate),
|
||||
endDate: new Date(data.endDate),
|
||||
schedule: data.schedule.map((day: any) => ({
|
||||
...day,
|
||||
date: new Date(day.date),
|
||||
})),
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching travel plan:', error)
|
||||
// API 서버가 없으면 null 반환 (기본 데이터 사용)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
// 여행 계획 저장
|
||||
async save(travelPlan: TravelPlan): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/travel-plans`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(travelPlan),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to save travel plan')
|
||||
} catch (error) {
|
||||
console.error('Error saving travel plan:', error)
|
||||
// API 서버가 없으면 무시 (로컬 상태만 사용)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// 기본 포인트 API
|
||||
export const basePointsAPI = {
|
||||
// 모든 기본 포인트 조회
|
||||
async getAll(): Promise<BasePoint[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/base-points`)
|
||||
if (!response.ok) throw new Error('Failed to fetch base points')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error fetching base points:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 기본 포인트 추가
|
||||
async create(basePoint: Omit<BasePoint, 'id'>): Promise<BasePoint> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/base-points`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(basePoint),
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create base point')
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Error creating base point:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// 기본 포인트 삭제
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/base-points/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete base point')
|
||||
} catch (error) {
|
||||
console.error('Error deleting base point:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}
|
||||
171
src/services/apiKeyManager.ts
Normal file
171
src/services/apiKeyManager.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Google Maps API 키 관리 서비스
|
||||
*/
|
||||
|
||||
interface ApiKeyData {
|
||||
googleMapsApiKey: string | null
|
||||
lastUpdated: string
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
class ApiKeyManager {
|
||||
private storageKey = 'google_maps_api_key_data'
|
||||
|
||||
/**
|
||||
* API 키 데이터 가져오기
|
||||
*/
|
||||
private getApiKeyData(): ApiKeyData {
|
||||
const stored = localStorage.getItem(this.storageKey)
|
||||
if (!stored) {
|
||||
return {
|
||||
googleMapsApiKey: null,
|
||||
lastUpdated: '',
|
||||
isValid: false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return {
|
||||
googleMapsApiKey: null,
|
||||
lastUpdated: '',
|
||||
isValid: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 데이터 저장
|
||||
*/
|
||||
private saveApiKeyData(data: ApiKeyData): void {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps API 키 가져오기
|
||||
* 우선순위: 로컬스토리지 > 환경변수
|
||||
*/
|
||||
public getGoogleMapsApiKey(): string | null {
|
||||
const data = this.getApiKeyData()
|
||||
|
||||
// 로컬스토리지에 저장된 키가 있으면 사용
|
||||
if (data.googleMapsApiKey) {
|
||||
return data.googleMapsApiKey
|
||||
}
|
||||
|
||||
// 환경변수에서 가져오기
|
||||
return import.meta.env.VITE_GOOGLE_MAPS_API_KEY || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps API 키 설정
|
||||
*/
|
||||
public setGoogleMapsApiKey(apiKey: string): void {
|
||||
const data: ApiKeyData = {
|
||||
googleMapsApiKey: apiKey.trim(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
isValid: false // 유효성은 별도로 검증
|
||||
}
|
||||
|
||||
this.saveApiKeyData(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 삭제
|
||||
*/
|
||||
public clearGoogleMapsApiKey(): void {
|
||||
localStorage.removeItem(this.storageKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 유효성 검증
|
||||
*/
|
||||
public async validateGoogleMapsApiKey(apiKey?: string): Promise<{ isValid: boolean; error?: string }> {
|
||||
const keyToValidate = apiKey || this.getGoogleMapsApiKey()
|
||||
|
||||
if (!keyToValidate) {
|
||||
return { isValid: false, error: 'API 키가 설정되지 않았습니다.' }
|
||||
}
|
||||
|
||||
try {
|
||||
// Google Maps JavaScript API 유효성 검증
|
||||
// 간단한 Geocoding API 호출로 테스트
|
||||
const response = await fetch(
|
||||
`https://maps.googleapis.com/maps/api/geocode/json?address=Tokyo&key=${keyToValidate}`
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
return { isValid: false, error: `HTTP ${response.status}: ${response.statusText}` }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.status === 'OK') {
|
||||
// 유효성 검증 성공 시 저장
|
||||
if (apiKey) {
|
||||
const apiKeyData = this.getApiKeyData()
|
||||
apiKeyData.isValid = true
|
||||
apiKeyData.lastUpdated = new Date().toISOString()
|
||||
this.saveApiKeyData(apiKeyData)
|
||||
}
|
||||
return { isValid: true }
|
||||
} else if (data.status === 'REQUEST_DENIED') {
|
||||
return { isValid: false, error: 'API 키가 유효하지 않거나 권한이 없습니다.' }
|
||||
} else if (data.status === 'OVER_QUERY_LIMIT') {
|
||||
return { isValid: false, error: 'API 사용량 한도를 초과했습니다.' }
|
||||
} else {
|
||||
return { isValid: false, error: `API 오류: ${data.status}` }
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `네트워크 오류: ${error instanceof Error ? error.message : '알 수 없는 오류'}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 정보 가져오기
|
||||
*/
|
||||
public getApiKeyInfo(): {
|
||||
hasApiKey: boolean
|
||||
isFromStorage: boolean
|
||||
isFromEnv: boolean
|
||||
lastUpdated: string
|
||||
isValid: boolean
|
||||
} {
|
||||
const data = this.getApiKeyData()
|
||||
const envKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY
|
||||
|
||||
return {
|
||||
hasApiKey: !!(data.googleMapsApiKey || envKey),
|
||||
isFromStorage: !!data.googleMapsApiKey,
|
||||
isFromEnv: !!envKey,
|
||||
lastUpdated: data.lastUpdated,
|
||||
isValid: data.isValid
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API 키 마스킹 (보안을 위해 일부만 표시)
|
||||
*/
|
||||
public maskApiKey(apiKey?: string): string {
|
||||
const key = apiKey || this.getGoogleMapsApiKey()
|
||||
if (!key) return ''
|
||||
|
||||
if (key.length <= 8) return key
|
||||
return `${key.substring(0, 4)}...${key.substring(key.length - 4)}`
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const apiKeyManager = new ApiKeyManager()
|
||||
|
||||
// 편의 함수들
|
||||
export const getGoogleMapsApiKey = () => apiKeyManager.getGoogleMapsApiKey()
|
||||
export const setGoogleMapsApiKey = (key: string) => apiKeyManager.setGoogleMapsApiKey(key)
|
||||
export const validateGoogleMapsApiKey = (key?: string) => apiKeyManager.validateGoogleMapsApiKey(key)
|
||||
export const clearGoogleMapsApiKey = () => apiKeyManager.clearGoogleMapsApiKey()
|
||||
export const getApiKeyInfo = () => apiKeyManager.getApiKeyInfo()
|
||||
export const maskApiKey = (key?: string) => apiKeyManager.maskApiKey(key)
|
||||
319
src/services/googleAuth.ts
Normal file
319
src/services/googleAuth.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Google OAuth 및 사용자 데이터 서비스
|
||||
export interface GoogleUser {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
picture: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export interface SavedPlace {
|
||||
place_id: string
|
||||
name: string
|
||||
formatted_address: string
|
||||
geometry: {
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
rating?: number
|
||||
user_ratings_total?: number
|
||||
types: string[]
|
||||
photos?: google.maps.places.PlacePhoto[]
|
||||
saved_lists: string[] // 어떤 리스트에 저장되었는지
|
||||
user_rating?: number // 사용자가 준 평점
|
||||
user_review?: string // 사용자 리뷰
|
||||
visited?: boolean // 방문 여부
|
||||
}
|
||||
|
||||
export interface GoogleMyMap {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
places: SavedPlace[]
|
||||
created_date: string
|
||||
updated_date: string
|
||||
}
|
||||
|
||||
class GoogleAuthService {
|
||||
private gapi: any = null
|
||||
private auth2: any = null
|
||||
private isInitialized = false
|
||||
|
||||
// Google API 초기화
|
||||
async initialize(clientId: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
if (typeof window === 'undefined') {
|
||||
reject(new Error('Google Auth는 브라우저에서만 사용 가능합니다'))
|
||||
return
|
||||
}
|
||||
|
||||
// Google API 스크립트 로드
|
||||
if (!window.gapi) {
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://apis.google.com/js/api.js'
|
||||
script.onload = () => this.loadGapi(clientId, resolve, reject)
|
||||
script.onerror = () => reject(new Error('Google API 스크립트 로드 실패'))
|
||||
document.head.appendChild(script)
|
||||
} else {
|
||||
this.loadGapi(clientId, resolve, reject)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private loadGapi(clientId: string, resolve: () => void, reject: (error: Error) => void) {
|
||||
window.gapi.load('auth2', () => {
|
||||
window.gapi.auth2.init({
|
||||
client_id: clientId,
|
||||
scope: [
|
||||
'profile',
|
||||
'email',
|
||||
'https://www.googleapis.com/auth/maps.readonly', // Google Maps 데이터 읽기
|
||||
'https://www.googleapis.com/auth/mymaps.readonly' // My Maps 읽기
|
||||
].join(' ')
|
||||
}).then(() => {
|
||||
this.gapi = window.gapi
|
||||
this.auth2 = window.gapi.auth2.getAuthInstance()
|
||||
this.isInitialized = true
|
||||
resolve()
|
||||
}).catch((error: any) => {
|
||||
reject(new Error(`Google Auth 초기화 실패: ${error.error || error}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 로그인
|
||||
async signIn(): Promise<GoogleUser> {
|
||||
if (!this.isInitialized) {
|
||||
throw new Error('Google Auth가 초기화되지 않았습니다')
|
||||
}
|
||||
|
||||
try {
|
||||
const authResult = await this.auth2.signIn()
|
||||
const profile = authResult.getBasicProfile()
|
||||
|
||||
const user: GoogleUser = {
|
||||
id: profile.getId(),
|
||||
email: profile.getEmail(),
|
||||
name: profile.getName(),
|
||||
picture: profile.getImageUrl(),
|
||||
locale: profile.getLocale() || 'ko'
|
||||
}
|
||||
|
||||
// 로컬 스토리지에 사용자 정보 저장
|
||||
localStorage.setItem('google_user', JSON.stringify(user))
|
||||
|
||||
return user
|
||||
} catch (error) {
|
||||
throw new Error(`로그인 실패: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
async signOut(): Promise<void> {
|
||||
if (!this.isInitialized) return
|
||||
|
||||
try {
|
||||
await this.auth2.signOut()
|
||||
localStorage.removeItem('google_user')
|
||||
} catch (error) {
|
||||
console.error('로그아웃 실패:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 로그인된 사용자 정보
|
||||
getCurrentUser(): GoogleUser | null {
|
||||
const userStr = localStorage.getItem('google_user')
|
||||
return userStr ? JSON.parse(userStr) : null
|
||||
}
|
||||
|
||||
// 로그인 상태 확인
|
||||
isSignedIn(): boolean {
|
||||
return this.isInitialized && this.auth2?.isSignedIn.get() && this.getCurrentUser() !== null
|
||||
}
|
||||
|
||||
// 저장된 장소 가져오기 (Google My Maps API 사용)
|
||||
async getSavedPlaces(): Promise<SavedPlace[]> {
|
||||
if (!this.isSignedIn()) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
try {
|
||||
// Google My Maps API 호출
|
||||
const response = await this.gapi.client.request({
|
||||
path: 'https://www.googleapis.com/mymaps/v1/maps',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
const savedPlaces: SavedPlace[] = []
|
||||
|
||||
// My Maps에서 장소 추출
|
||||
if (response.result && response.result.maps) {
|
||||
for (const map of response.result.maps) {
|
||||
const mapPlaces = await this.getPlacesFromMap(map.id)
|
||||
savedPlaces.push(...mapPlaces)
|
||||
}
|
||||
}
|
||||
|
||||
return savedPlaces
|
||||
} catch (error) {
|
||||
console.error('저장된 장소 가져오기 실패:', error)
|
||||
// 폴백: 로컬 스토리지에서 이전에 저장된 데이터 사용
|
||||
return this.getCachedSavedPlaces()
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 My Map에서 장소들 가져오기
|
||||
private async getPlacesFromMap(mapId: string): Promise<SavedPlace[]> {
|
||||
try {
|
||||
const response = await this.gapi.client.request({
|
||||
path: `https://www.googleapis.com/mymaps/v1/maps/${mapId}/features`,
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
const places: SavedPlace[] = []
|
||||
|
||||
if (response.result && response.result.features) {
|
||||
for (const feature of response.result.features) {
|
||||
if (feature.geometry && feature.geometry.location) {
|
||||
const place: SavedPlace = {
|
||||
place_id: feature.properties?.place_id || `custom_${Date.now()}`,
|
||||
name: feature.properties?.name || 'Unknown Place',
|
||||
formatted_address: feature.properties?.address || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: feature.geometry.location.latitude,
|
||||
lng: feature.geometry.location.longitude
|
||||
}
|
||||
},
|
||||
types: feature.properties?.types || [],
|
||||
saved_lists: [mapId],
|
||||
user_rating: feature.properties?.rating,
|
||||
user_review: feature.properties?.description
|
||||
}
|
||||
places.push(place)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return places
|
||||
} catch (error) {
|
||||
console.error(`Map ${mapId}에서 장소 가져오기 실패:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// My Maps 목록 가져오기
|
||||
async getMyMaps(): Promise<GoogleMyMap[]> {
|
||||
if (!this.isSignedIn()) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.gapi.client.request({
|
||||
path: 'https://www.googleapis.com/mymaps/v1/maps',
|
||||
method: 'GET'
|
||||
})
|
||||
|
||||
const myMaps: GoogleMyMap[] = []
|
||||
|
||||
if (response.result && response.result.maps) {
|
||||
for (const map of response.result.maps) {
|
||||
const places = await this.getPlacesFromMap(map.id)
|
||||
|
||||
const myMap: GoogleMyMap = {
|
||||
id: map.id,
|
||||
title: map.title || 'Untitled Map',
|
||||
description: map.description,
|
||||
places: places,
|
||||
created_date: map.createTime,
|
||||
updated_date: map.updateTime
|
||||
}
|
||||
|
||||
myMaps.push(myMap)
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 캐시에 저장
|
||||
localStorage.setItem('google_my_maps', JSON.stringify(myMaps))
|
||||
|
||||
return myMaps
|
||||
} catch (error) {
|
||||
console.error('My Maps 가져오기 실패:', error)
|
||||
// 폴백: 캐시된 데이터 사용
|
||||
return this.getCachedMyMaps()
|
||||
}
|
||||
}
|
||||
|
||||
// 캐시된 저장된 장소 가져오기
|
||||
private getCachedSavedPlaces(): SavedPlace[] {
|
||||
const cached = localStorage.getItem('google_saved_places')
|
||||
return cached ? JSON.parse(cached) : []
|
||||
}
|
||||
|
||||
// 캐시된 My Maps 가져오기
|
||||
private getCachedMyMaps(): GoogleMyMap[] {
|
||||
const cached = localStorage.getItem('google_my_maps')
|
||||
return cached ? JSON.parse(cached) : []
|
||||
}
|
||||
|
||||
// 저장된 장소를 일정에 추가하기 쉬운 형태로 변환
|
||||
convertToItineraryFormat(savedPlace: SavedPlace) {
|
||||
return {
|
||||
id: `saved_${savedPlace.place_id}`,
|
||||
title: savedPlace.name,
|
||||
location: savedPlace.formatted_address,
|
||||
description: savedPlace.user_review || `Google Maps에서 가져온 장소 (평점: ${savedPlace.user_rating || savedPlace.rating || 'N/A'})`,
|
||||
type: this.inferActivityType(savedPlace.types),
|
||||
coordinates: {
|
||||
lat: savedPlace.geometry.location.lat,
|
||||
lng: savedPlace.geometry.location.lng
|
||||
},
|
||||
time: '09:00', // 기본 시간
|
||||
source: 'google_maps'
|
||||
}
|
||||
}
|
||||
|
||||
// 장소 타입에서 활동 타입 추론
|
||||
private inferActivityType(types: string[]): 'attraction' | 'food' | 'accommodation' | 'transport' | 'other' {
|
||||
if (types.includes('restaurant') || types.includes('food') || types.includes('meal_takeaway')) {
|
||||
return 'food'
|
||||
}
|
||||
if (types.includes('lodging')) {
|
||||
return 'accommodation'
|
||||
}
|
||||
if (types.includes('tourist_attraction') || types.includes('museum') || types.includes('park')) {
|
||||
return 'attraction'
|
||||
}
|
||||
if (types.includes('transit_station') || types.includes('airport')) {
|
||||
return 'transport'
|
||||
}
|
||||
return 'other'
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const googleAuthService = new GoogleAuthService()
|
||||
|
||||
// Google OAuth 설정
|
||||
export const GoogleAuthConfig = {
|
||||
// 실제 사용시에는 환경변수로 관리
|
||||
CLIENT_ID: import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID || '',
|
||||
|
||||
// 필요한 권한 범위
|
||||
SCOPES: [
|
||||
'profile',
|
||||
'email',
|
||||
'https://www.googleapis.com/auth/maps.readonly',
|
||||
'https://www.googleapis.com/auth/mymaps.readonly'
|
||||
],
|
||||
|
||||
// 지원하는 기능들
|
||||
FEATURES: {
|
||||
MY_MAPS: true, // Google My Maps 지원
|
||||
SAVED_PLACES: true, // 저장된 장소 지원
|
||||
REVIEWS: false, // 리뷰 데이터 (제한적)
|
||||
LOCATION_HISTORY: false // 위치 기록 (프라이버시 이슈)
|
||||
}
|
||||
} as const
|
||||
313
src/services/googleDirections.ts
Normal file
313
src/services/googleDirections.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
// Google Directions API 서비스
|
||||
export interface RouteWaypoint {
|
||||
location: string | { lat: number; lng: number }
|
||||
stopover?: boolean
|
||||
}
|
||||
|
||||
export interface RouteOptions {
|
||||
origin: string | { lat: number; lng: number }
|
||||
destination: string | { lat: number; lng: number }
|
||||
waypoints?: RouteWaypoint[]
|
||||
travelMode?: google.maps.TravelMode
|
||||
optimizeWaypoints?: boolean
|
||||
avoidHighways?: boolean
|
||||
avoidTolls?: boolean
|
||||
region?: string
|
||||
}
|
||||
|
||||
export interface RouteResult {
|
||||
routes: google.maps.DirectionsRoute[]
|
||||
optimizedOrder?: number[]
|
||||
totalDistance: string
|
||||
totalDuration: string
|
||||
legs: {
|
||||
distance: string
|
||||
duration: string
|
||||
startAddress: string
|
||||
endAddress: string
|
||||
}[]
|
||||
}
|
||||
|
||||
export interface OptimizedItinerary {
|
||||
originalActivities: any[]
|
||||
optimizedActivities: any[]
|
||||
routeResult: RouteResult
|
||||
timeSaved: string
|
||||
distanceSaved: string
|
||||
}
|
||||
|
||||
class GoogleDirectionsService {
|
||||
private directionsService: google.maps.DirectionsService | null = null
|
||||
private directionsRenderer: google.maps.DirectionsRenderer | null = null
|
||||
|
||||
// Directions Service 초기화
|
||||
initialize() {
|
||||
if (typeof google !== 'undefined' && google.maps) {
|
||||
this.directionsService = new google.maps.DirectionsService()
|
||||
this.directionsRenderer = new google.maps.DirectionsRenderer({
|
||||
suppressMarkers: false,
|
||||
draggable: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 경로 계산
|
||||
async calculateRoute(options: RouteOptions): Promise<RouteResult> {
|
||||
if (!this.directionsService) {
|
||||
throw new Error('Directions service not initialized')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request: google.maps.DirectionsRequest = {
|
||||
origin: options.origin,
|
||||
destination: options.destination,
|
||||
waypoints: options.waypoints || [],
|
||||
travelMode: options.travelMode || google.maps.TravelMode.DRIVING,
|
||||
optimizeWaypoints: options.optimizeWaypoints || false,
|
||||
avoidHighways: options.avoidHighways || false,
|
||||
avoidTolls: options.avoidTolls || false,
|
||||
region: options.region || 'JP'
|
||||
}
|
||||
|
||||
this.directionsService!.route(request, (result, status) => {
|
||||
if (status === google.maps.DirectionsStatus.OK && result) {
|
||||
const route = result.routes[0]
|
||||
const legs = route.legs.map(leg => ({
|
||||
distance: leg.distance?.text || '',
|
||||
duration: leg.duration?.text || '',
|
||||
startAddress: leg.start_address,
|
||||
endAddress: leg.end_address
|
||||
}))
|
||||
|
||||
// 총 거리와 시간 계산
|
||||
const totalDistance = legs.reduce((total, leg) => {
|
||||
const distance = parseFloat(leg.distance.replace(/[^\d.]/g, '')) || 0
|
||||
return total + distance
|
||||
}, 0)
|
||||
|
||||
const totalDuration = legs.reduce((total, leg) => {
|
||||
const duration = this.parseDuration(leg.duration)
|
||||
return total + duration
|
||||
}, 0)
|
||||
|
||||
const routeResult: RouteResult = {
|
||||
routes: result.routes,
|
||||
optimizedOrder: result.routes[0].waypoint_order,
|
||||
totalDistance: `${totalDistance.toFixed(1)} km`,
|
||||
totalDuration: this.formatDuration(totalDuration),
|
||||
legs
|
||||
}
|
||||
|
||||
resolve(routeResult)
|
||||
} else {
|
||||
reject(new Error(`Directions request failed: ${status}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 일정 최적화
|
||||
async optimizeItinerary(activities: any[], travelMode: google.maps.TravelMode = google.maps.TravelMode.DRIVING): Promise<OptimizedItinerary> {
|
||||
if (activities.length < 2) {
|
||||
throw new Error('최소 2개 이상의 활동이 필요합니다')
|
||||
}
|
||||
|
||||
// 좌표가 있는 활동만 필터링
|
||||
const activitiesWithCoords = activities.filter(activity =>
|
||||
activity.coordinates && activity.coordinates.lat && activity.coordinates.lng
|
||||
)
|
||||
|
||||
if (activitiesWithCoords.length < 2) {
|
||||
throw new Error('좌표 정보가 있는 활동이 최소 2개 필요합니다')
|
||||
}
|
||||
|
||||
const origin = activitiesWithCoords[0].coordinates
|
||||
const destination = activitiesWithCoords[activitiesWithCoords.length - 1].coordinates
|
||||
const waypoints = activitiesWithCoords.slice(1, -1).map(activity => ({
|
||||
location: activity.coordinates,
|
||||
stopover: true
|
||||
}))
|
||||
|
||||
try {
|
||||
// 최적화된 경로 계산
|
||||
const optimizedRoute = await this.calculateRoute({
|
||||
origin,
|
||||
destination,
|
||||
waypoints,
|
||||
travelMode,
|
||||
optimizeWaypoints: true
|
||||
})
|
||||
|
||||
// 최적화된 순서로 활동 재배열
|
||||
const optimizedActivities = this.reorderActivities(activitiesWithCoords, optimizedRoute.optimizedOrder || [])
|
||||
|
||||
// 원래 경로와 비교를 위한 계산
|
||||
const originalRoute = await this.calculateRoute({
|
||||
origin,
|
||||
destination,
|
||||
waypoints,
|
||||
travelMode,
|
||||
optimizeWaypoints: false
|
||||
})
|
||||
|
||||
const timeSaved = this.calculateTimeDifference(originalRoute.totalDuration, optimizedRoute.totalDuration)
|
||||
const distanceSaved = this.calculateDistanceDifference(originalRoute.totalDistance, optimizedRoute.totalDistance)
|
||||
|
||||
return {
|
||||
originalActivities: activitiesWithCoords,
|
||||
optimizedActivities,
|
||||
routeResult: optimizedRoute,
|
||||
timeSaved,
|
||||
distanceSaved
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`경로 최적화 실패: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 활동 재배열
|
||||
private reorderActivities(activities: any[], waypointOrder: number[]): any[] {
|
||||
if (!waypointOrder || waypointOrder.length === 0) {
|
||||
return activities
|
||||
}
|
||||
|
||||
const reordered = [activities[0]] // 시작점은 그대로
|
||||
|
||||
// 중간 지점들을 최적화된 순서로 배열
|
||||
waypointOrder.forEach(index => {
|
||||
if (activities[index + 1]) {
|
||||
reordered.push(activities[index + 1])
|
||||
}
|
||||
})
|
||||
|
||||
// 마지막 지점 추가
|
||||
if (activities.length > 1) {
|
||||
reordered.push(activities[activities.length - 1])
|
||||
}
|
||||
|
||||
return reordered
|
||||
}
|
||||
|
||||
// 시간 파싱 (예: "1시간 30분" → 90분)
|
||||
private parseDuration(durationText: string): number {
|
||||
const hours = durationText.match(/(\d+)\s*시간/)?.[1] || '0'
|
||||
const minutes = durationText.match(/(\d+)\s*분/)?.[1] || '0'
|
||||
return parseInt(hours) * 60 + parseInt(minutes)
|
||||
}
|
||||
|
||||
// 시간 포맷팅 (90분 → "1시간 30분")
|
||||
private formatDuration(minutes: number): string {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const mins = minutes % 60
|
||||
|
||||
if (hours > 0 && mins > 0) {
|
||||
return `${hours}시간 ${mins}분`
|
||||
} else if (hours > 0) {
|
||||
return `${hours}시간`
|
||||
} else {
|
||||
return `${mins}분`
|
||||
}
|
||||
}
|
||||
|
||||
// 시간 차이 계산
|
||||
private calculateTimeDifference(original: string, optimized: string): string {
|
||||
const originalMinutes = this.parseDuration(original)
|
||||
const optimizedMinutes = this.parseDuration(optimized)
|
||||
const difference = originalMinutes - optimizedMinutes
|
||||
|
||||
if (difference > 0) {
|
||||
return `${this.formatDuration(difference)} 단축`
|
||||
} else if (difference < 0) {
|
||||
return `${this.formatDuration(Math.abs(difference))} 증가`
|
||||
} else {
|
||||
return '변화 없음'
|
||||
}
|
||||
}
|
||||
|
||||
// 거리 차이 계산
|
||||
private calculateDistanceDifference(original: string, optimized: string): string {
|
||||
const originalKm = parseFloat(original.replace(/[^\d.]/g, '')) || 0
|
||||
const optimizedKm = parseFloat(optimized.replace(/[^\d.]/g, '')) || 0
|
||||
const difference = originalKm - optimizedKm
|
||||
|
||||
if (difference > 0) {
|
||||
return `${difference.toFixed(1)}km 단축`
|
||||
} else if (difference < 0) {
|
||||
return `${Math.abs(difference).toFixed(1)}km 증가`
|
||||
} else {
|
||||
return '변화 없음'
|
||||
}
|
||||
}
|
||||
|
||||
// 지도에 경로 표시
|
||||
displayRoute(map: google.maps.Map, routeResult: RouteResult) {
|
||||
if (!this.directionsRenderer) {
|
||||
this.directionsRenderer = new google.maps.DirectionsRenderer({
|
||||
suppressMarkers: false,
|
||||
draggable: false
|
||||
})
|
||||
}
|
||||
|
||||
this.directionsRenderer.setMap(map)
|
||||
this.directionsRenderer.setDirections({
|
||||
routes: routeResult.routes,
|
||||
request: {} as google.maps.DirectionsRequest
|
||||
} as google.maps.DirectionsResult)
|
||||
}
|
||||
|
||||
// 경로 숨기기
|
||||
hideRoute() {
|
||||
if (this.directionsRenderer) {
|
||||
this.directionsRenderer.setMap(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const googleDirectionsService = new GoogleDirectionsService()
|
||||
|
||||
// 여행 모드 옵션
|
||||
export const TravelModeOptions = {
|
||||
DRIVING: {
|
||||
mode: 'DRIVING',
|
||||
label: '🚗 자동차',
|
||||
description: '렌터카 또는 자가용'
|
||||
},
|
||||
TRANSIT: {
|
||||
mode: 'TRANSIT',
|
||||
label: '🚌 대중교통',
|
||||
description: '버스, 지하철, 기차'
|
||||
},
|
||||
WALKING: {
|
||||
mode: 'WALKING',
|
||||
label: '🚶 도보',
|
||||
description: '걸어서 이동'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 구마모토 특화 경로 옵션
|
||||
export const KumamotoRoutePresets = {
|
||||
// 시내 관광 (도보 중심)
|
||||
cityTour: {
|
||||
travelMode: 'WALKING',
|
||||
avoidHighways: true,
|
||||
avoidTolls: true,
|
||||
description: '구마모토 시내 도보 관광'
|
||||
},
|
||||
|
||||
// 렌터카 여행
|
||||
carTour: {
|
||||
travelMode: 'DRIVING',
|
||||
avoidTolls: false,
|
||||
avoidHighways: false,
|
||||
description: '렌터카로 구마모토 전역 여행'
|
||||
},
|
||||
|
||||
// 대중교통 이용
|
||||
publicTransport: {
|
||||
travelMode: 'TRANSIT',
|
||||
avoidHighways: true,
|
||||
avoidTolls: true,
|
||||
description: '버스/전차로 구마모토 여행'
|
||||
}
|
||||
} as const
|
||||
257
src/services/googleMapsUsage.ts
Normal file
257
src/services/googleMapsUsage.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Google Maps API 사용량 추적 및 제한 서비스
|
||||
*
|
||||
* Google Maps JavaScript API 무료 한도:
|
||||
* - Maps JavaScript API: 월 28,000 요청
|
||||
* - 일일 약 933 요청 (28,000 / 30일)
|
||||
* - 시간당 약 39 요청 (933 / 24시간)
|
||||
*/
|
||||
|
||||
interface UsageData {
|
||||
daily: number
|
||||
monthly: number
|
||||
lastReset: string
|
||||
dailyLimit: number
|
||||
monthlyLimit: number
|
||||
}
|
||||
|
||||
class GoogleMapsUsageTracker {
|
||||
private storageKey = 'google_maps_usage'
|
||||
|
||||
// 무료 한도 설정 (안전 마진 20% 적용)
|
||||
private readonly DAILY_LIMIT = 750 // 933의 80%
|
||||
private readonly MONTHLY_LIMIT = 22400 // 28,000의 80%
|
||||
private readonly HOURLY_LIMIT = 30 // 39의 80%
|
||||
|
||||
private hourlyUsage: { [hour: string]: number } = {}
|
||||
|
||||
constructor() {
|
||||
this.initializeUsage()
|
||||
this.setupDailyReset()
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 데이터 초기화
|
||||
*/
|
||||
private initializeUsage(): void {
|
||||
const stored = localStorage.getItem(this.storageKey)
|
||||
if (!stored) {
|
||||
this.resetUsage()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용량 데이터 가져오기
|
||||
*/
|
||||
private getUsageData(): UsageData {
|
||||
const stored = localStorage.getItem(this.storageKey)
|
||||
if (!stored) {
|
||||
return this.getDefaultUsageData()
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return this.getDefaultUsageData()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 사용량 데이터
|
||||
*/
|
||||
private getDefaultUsageData(): UsageData {
|
||||
return {
|
||||
daily: 0,
|
||||
monthly: 0,
|
||||
lastReset: new Date().toISOString().split('T')[0], // YYYY-MM-DD
|
||||
dailyLimit: this.DAILY_LIMIT,
|
||||
monthlyLimit: this.MONTHLY_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 데이터 저장
|
||||
*/
|
||||
private saveUsageData(data: UsageData): void {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(data))
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 초기화
|
||||
*/
|
||||
private resetUsage(): void {
|
||||
const defaultData = this.getDefaultUsageData()
|
||||
this.saveUsageData(defaultData)
|
||||
}
|
||||
|
||||
/**
|
||||
* 일일 리셋 체크 및 실행
|
||||
*/
|
||||
private setupDailyReset(): void {
|
||||
const data = this.getUsageData()
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
if (data.lastReset !== today) {
|
||||
// 새로운 날이면 일일 사용량 리셋
|
||||
data.daily = 0
|
||||
data.lastReset = today
|
||||
|
||||
// 새로운 달이면 월간 사용량도 리셋
|
||||
const lastResetMonth = new Date(data.lastReset).getMonth()
|
||||
const currentMonth = new Date().getMonth()
|
||||
|
||||
if (lastResetMonth !== currentMonth) {
|
||||
data.monthly = 0
|
||||
}
|
||||
|
||||
this.saveUsageData(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간당 사용량 체크
|
||||
*/
|
||||
private checkHourlyLimit(): boolean {
|
||||
const currentHour = new Date().toISOString().slice(0, 13) // YYYY-MM-DDTHH
|
||||
const hourlyCount = this.hourlyUsage[currentHour] || 0
|
||||
|
||||
return hourlyCount < this.HOURLY_LIMIT
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간당 사용량 증가
|
||||
*/
|
||||
private incrementHourlyUsage(): void {
|
||||
const currentHour = new Date().toISOString().slice(0, 13)
|
||||
this.hourlyUsage[currentHour] = (this.hourlyUsage[currentHour] || 0) + 1
|
||||
|
||||
// 오래된 시간 데이터 정리 (24시간 이전)
|
||||
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 13)
|
||||
Object.keys(this.hourlyUsage).forEach(hour => {
|
||||
if (hour < cutoff) {
|
||||
delete this.hourlyUsage[hour]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* API 사용 가능 여부 체크
|
||||
*/
|
||||
public canUseAPI(): { allowed: boolean; reason?: string; usage?: any } {
|
||||
this.setupDailyReset()
|
||||
|
||||
const data = this.getUsageData()
|
||||
const usage = {
|
||||
daily: { current: data.daily, limit: data.dailyLimit },
|
||||
monthly: { current: data.monthly, limit: data.monthlyLimit },
|
||||
hourly: {
|
||||
current: this.hourlyUsage[new Date().toISOString().slice(0, 13)] || 0,
|
||||
limit: this.HOURLY_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
// 월간 한도 체크
|
||||
if (data.monthly >= data.monthlyLimit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `월간 사용량 한도 초과 (${data.monthly}/${data.monthlyLimit})`,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
// 일일 한도 체크
|
||||
if (data.daily >= data.dailyLimit) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `일일 사용량 한도 초과 (${data.daily}/${data.dailyLimit})`,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
// 시간당 한도 체크
|
||||
if (!this.checkHourlyLimit()) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `시간당 사용량 한도 초과 (${usage.hourly.current}/${this.HOURLY_LIMIT})`,
|
||||
usage
|
||||
}
|
||||
}
|
||||
|
||||
return { allowed: true, usage }
|
||||
}
|
||||
|
||||
/**
|
||||
* API 사용량 증가
|
||||
*/
|
||||
public incrementUsage(): void {
|
||||
const data = this.getUsageData()
|
||||
data.daily += 1
|
||||
data.monthly += 1
|
||||
this.saveUsageData(data)
|
||||
this.incrementHourlyUsage()
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용량 정보 가져오기
|
||||
*/
|
||||
public getUsageInfo(): any {
|
||||
this.setupDailyReset()
|
||||
const data = this.getUsageData()
|
||||
|
||||
return {
|
||||
daily: {
|
||||
current: data.daily,
|
||||
limit: data.dailyLimit,
|
||||
percentage: Math.round((data.daily / data.dailyLimit) * 100)
|
||||
},
|
||||
monthly: {
|
||||
current: data.monthly,
|
||||
limit: data.monthlyLimit,
|
||||
percentage: Math.round((data.monthly / data.monthlyLimit) * 100)
|
||||
},
|
||||
hourly: {
|
||||
current: this.hourlyUsage[new Date().toISOString().slice(0, 13)] || 0,
|
||||
limit: this.HOURLY_LIMIT,
|
||||
percentage: Math.round(((this.hourlyUsage[new Date().toISOString().slice(0, 13)] || 0) / this.HOURLY_LIMIT) * 100)
|
||||
},
|
||||
lastReset: data.lastReset
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용량 리셋 (관리자용)
|
||||
*/
|
||||
public resetUsageData(): void {
|
||||
this.resetUsage()
|
||||
this.hourlyUsage = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 경고 레벨 체크
|
||||
*/
|
||||
public getWarningLevel(): 'safe' | 'warning' | 'danger' | 'blocked' {
|
||||
const { allowed, usage } = this.canUseAPI()
|
||||
|
||||
if (!allowed) return 'blocked'
|
||||
|
||||
const maxPercentage = Math.max(
|
||||
usage.daily.current / usage.daily.limit,
|
||||
usage.monthly.current / usage.monthly.limit,
|
||||
usage.hourly.current / usage.hourly.limit
|
||||
) * 100
|
||||
|
||||
if (maxPercentage >= 90) return 'danger'
|
||||
if (maxPercentage >= 70) return 'warning'
|
||||
return 'safe'
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const googleMapsUsage = new GoogleMapsUsageTracker()
|
||||
|
||||
// 편의 함수들
|
||||
export const canUseGoogleMaps = () => googleMapsUsage.canUseAPI()
|
||||
export const incrementGoogleMapsUsage = () => googleMapsUsage.incrementUsage()
|
||||
export const getGoogleMapsUsage = () => googleMapsUsage.getUsageInfo()
|
||||
export const getGoogleMapsWarningLevel = () => googleMapsUsage.getWarningLevel()
|
||||
export const resetGoogleMapsUsage = () => googleMapsUsage.resetUsageData()
|
||||
267
src/services/googlePlaces.ts
Normal file
267
src/services/googlePlaces.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
// Google Places API 서비스
|
||||
export interface PlaceResult {
|
||||
place_id: string
|
||||
name: string
|
||||
formatted_address: string
|
||||
geometry: {
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
rating?: number
|
||||
price_level?: number
|
||||
types: string[]
|
||||
photos?: google.maps.places.PlacePhoto[]
|
||||
opening_hours?: {
|
||||
open_now: boolean
|
||||
weekday_text: string[]
|
||||
}
|
||||
website?: string
|
||||
formatted_phone_number?: string
|
||||
reviews?: google.maps.places.PlaceReview[]
|
||||
}
|
||||
|
||||
export interface PlaceSearchOptions {
|
||||
query: string
|
||||
location?: { lat: number; lng: number }
|
||||
radius?: number
|
||||
type?: string[]
|
||||
minRating?: number
|
||||
maxPriceLevel?: number
|
||||
}
|
||||
|
||||
class GooglePlacesService {
|
||||
private placesService: google.maps.places.PlacesService | null = null
|
||||
private map: google.maps.Map | null = null
|
||||
|
||||
// Places Service 초기화
|
||||
initialize(map: google.maps.Map) {
|
||||
this.map = map
|
||||
this.placesService = new google.maps.places.PlacesService(map)
|
||||
}
|
||||
|
||||
// 텍스트 검색 (일반 검색)
|
||||
async textSearch(options: PlaceSearchOptions): Promise<PlaceResult[]> {
|
||||
if (!this.placesService) {
|
||||
throw new Error('Places service not initialized')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request: google.maps.places.TextSearchRequest = {
|
||||
query: options.query,
|
||||
location: options.location ?
|
||||
new google.maps.LatLng(options.location.lat, options.location.lng) :
|
||||
undefined,
|
||||
radius: options.radius || 5000,
|
||||
type: options.type?.[0] as any
|
||||
}
|
||||
|
||||
this.placesService!.textSearch(request, (results, status) => {
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && results) {
|
||||
const filteredResults = results
|
||||
.filter(place => {
|
||||
if (options.minRating && (!place.rating || place.rating < options.minRating)) {
|
||||
return false
|
||||
}
|
||||
if (options.maxPriceLevel && place.price_level && place.price_level > options.maxPriceLevel) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(place => ({
|
||||
place_id: place.place_id!,
|
||||
name: place.name!,
|
||||
formatted_address: place.formatted_address!,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: place.geometry!.location!.lat(),
|
||||
lng: place.geometry!.location!.lng()
|
||||
}
|
||||
},
|
||||
rating: place.rating,
|
||||
price_level: place.price_level,
|
||||
types: place.types || [],
|
||||
photos: place.photos,
|
||||
opening_hours: place.opening_hours
|
||||
}))
|
||||
|
||||
resolve(filteredResults)
|
||||
} else {
|
||||
reject(new Error(`Places search failed: ${status}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 주변 검색 (Nearby Search)
|
||||
async nearbySearch(options: PlaceSearchOptions): Promise<PlaceResult[]> {
|
||||
if (!this.placesService || !options.location) {
|
||||
throw new Error('Places service not initialized or location not provided')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request: google.maps.places.PlaceSearchRequest = {
|
||||
location: new google.maps.LatLng(options.location!.lat, options.location!.lng),
|
||||
radius: options.radius || 1000,
|
||||
type: options.type?.[0] as any,
|
||||
keyword: options.query
|
||||
}
|
||||
|
||||
this.placesService!.nearbySearch(request, (results, status) => {
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && results) {
|
||||
const filteredResults = results
|
||||
.filter(place => {
|
||||
if (options.minRating && (!place.rating || place.rating < options.minRating)) {
|
||||
return false
|
||||
}
|
||||
if (options.maxPriceLevel && place.price_level && place.price_level > options.maxPriceLevel) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(place => ({
|
||||
place_id: place.place_id!,
|
||||
name: place.name!,
|
||||
formatted_address: place.formatted_address || place.vicinity || '',
|
||||
geometry: {
|
||||
location: {
|
||||
lat: place.geometry!.location!.lat(),
|
||||
lng: place.geometry!.location!.lng()
|
||||
}
|
||||
},
|
||||
rating: place.rating,
|
||||
price_level: place.price_level,
|
||||
types: place.types || [],
|
||||
photos: place.photos,
|
||||
opening_hours: place.opening_hours
|
||||
}))
|
||||
|
||||
resolve(filteredResults)
|
||||
} else {
|
||||
reject(new Error(`Nearby search failed: ${status}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 장소 상세 정보 가져오기
|
||||
async getPlaceDetails(placeId: string): Promise<PlaceResult | null> {
|
||||
if (!this.placesService) {
|
||||
throw new Error('Places service not initialized')
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const request: google.maps.places.PlaceDetailsRequest = {
|
||||
placeId: placeId,
|
||||
fields: [
|
||||
'place_id', 'name', 'formatted_address', 'geometry',
|
||||
'rating', 'price_level', 'types', 'photos',
|
||||
'opening_hours', 'website', 'formatted_phone_number', 'reviews'
|
||||
]
|
||||
}
|
||||
|
||||
this.placesService!.getDetails(request, (place, status) => {
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && place) {
|
||||
const result: PlaceResult = {
|
||||
place_id: place.place_id!,
|
||||
name: place.name!,
|
||||
formatted_address: place.formatted_address!,
|
||||
geometry: {
|
||||
location: {
|
||||
lat: place.geometry!.location!.lat(),
|
||||
lng: place.geometry!.location!.lng()
|
||||
}
|
||||
},
|
||||
rating: place.rating,
|
||||
price_level: place.price_level,
|
||||
types: place.types || [],
|
||||
photos: place.photos,
|
||||
opening_hours: place.opening_hours,
|
||||
website: place.website,
|
||||
formatted_phone_number: place.formatted_phone_number,
|
||||
reviews: place.reviews
|
||||
}
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(new Error(`Place details failed: ${status}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 자동완성 검색
|
||||
async getAutocompletePredictions(input: string, location?: { lat: number; lng: number }): Promise<google.maps.places.AutocompletePrediction[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const service = new google.maps.places.AutocompleteService()
|
||||
|
||||
const request: google.maps.places.AutocompletionRequest = {
|
||||
input: input,
|
||||
location: location ?
|
||||
new google.maps.LatLng(location.lat, location.lng) :
|
||||
undefined,
|
||||
radius: 10000,
|
||||
componentRestrictions: { country: 'jp' }, // 일본으로 제한
|
||||
types: ['establishment'] // 사업체만
|
||||
}
|
||||
|
||||
service.getPlacePredictions(request, (predictions, status) => {
|
||||
if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
|
||||
resolve(predictions)
|
||||
} else {
|
||||
resolve([])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const googlePlacesService = new GooglePlacesService()
|
||||
|
||||
// 구마모토 특화 검색 프리셋
|
||||
export const KumamotoSearchPresets = {
|
||||
// 관광지
|
||||
attractions: {
|
||||
query: "구마모토 관광지",
|
||||
type: ["tourist_attraction"],
|
||||
minRating: 3.5
|
||||
},
|
||||
|
||||
// 맛집
|
||||
restaurants: {
|
||||
query: "구마모토 맛집",
|
||||
type: ["restaurant"],
|
||||
minRating: 4.0,
|
||||
maxPriceLevel: 3
|
||||
},
|
||||
|
||||
// 라멘집
|
||||
ramen: {
|
||||
query: "구마모토 라멘",
|
||||
type: ["restaurant"],
|
||||
minRating: 4.0,
|
||||
maxPriceLevel: 2
|
||||
},
|
||||
|
||||
// 온천
|
||||
onsen: {
|
||||
query: "구마모토 온천",
|
||||
type: ["spa"],
|
||||
minRating: 4.0
|
||||
},
|
||||
|
||||
// 쇼핑
|
||||
shopping: {
|
||||
query: "구마모토 쇼핑",
|
||||
type: ["shopping_mall", "store"],
|
||||
minRating: 3.5
|
||||
},
|
||||
|
||||
// 숙박
|
||||
accommodation: {
|
||||
query: "구마모토 호텔",
|
||||
type: ["lodging"],
|
||||
minRating: 3.5
|
||||
}
|
||||
}
|
||||
304
src/services/initialSetup.ts
Normal file
304
src/services/initialSetup.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
// 서버 초기 설정 및 관리자 계정 생성 서비스
|
||||
export interface InitialSetupData {
|
||||
admin_name: string
|
||||
admin_email: string
|
||||
admin_password: string
|
||||
site_name: string
|
||||
site_description?: string
|
||||
default_language: 'ko' | 'en' | 'ja'
|
||||
default_currency: 'KRW' | 'JPY' | 'USD'
|
||||
}
|
||||
|
||||
export interface SetupStatus {
|
||||
is_setup_required: boolean
|
||||
setup_step: 'initial' | 'admin_account' | 'site_config' | 'complete'
|
||||
has_admin: boolean
|
||||
total_users: number
|
||||
version: string
|
||||
}
|
||||
|
||||
import { apiUrl } from '../utils/env'
|
||||
|
||||
class InitialSetupService {
|
||||
private readonly API_BASE = `${apiUrl}/api/setup`
|
||||
private readonly SETUP_KEY = 'travel_planner_setup_status'
|
||||
|
||||
// 초기 설정 필요 여부 확인
|
||||
async checkSetupStatus(): Promise<SetupStatus> {
|
||||
// 개발 환경에서는 바로 로컬 스토리지 사용 (API 호출 건너뛰기)
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalSetupStatus()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/status`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Setup status check failed:', error)
|
||||
|
||||
// 서버 연결 실패시 초기 설정 필요로 간주
|
||||
return {
|
||||
is_setup_required: true,
|
||||
setup_step: 'initial',
|
||||
has_admin: false,
|
||||
total_users: 0,
|
||||
version: '1.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 초기 설정 실행
|
||||
async performInitialSetup(setupData: InitialSetupData): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/initialize`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(setupData)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
// 설정 완료 상태 저장
|
||||
this.markSetupComplete()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Initial setup failed:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 설정
|
||||
if (import.meta.env.DEV) {
|
||||
return this.performLocalSetup(setupData)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '초기 설정 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터베이스 연결 테스트
|
||||
async testDatabaseConnection(): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/test-db`)
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: '데이터베이스 연결을 확인할 수 없습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 환경 변수 확인
|
||||
async checkEnvironment(): Promise<{
|
||||
database_url: boolean
|
||||
jwt_secret: boolean
|
||||
google_maps_key: boolean
|
||||
email_config: boolean
|
||||
}> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/check-env`)
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
return {
|
||||
database_url: false,
|
||||
jwt_secret: false,
|
||||
google_maps_key: false,
|
||||
email_config: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 완료 표시
|
||||
private markSetupComplete(): void {
|
||||
const setupStatus = {
|
||||
is_complete: true,
|
||||
completed_at: new Date().toISOString(),
|
||||
version: '1.0.0'
|
||||
}
|
||||
localStorage.setItem(this.SETUP_KEY, JSON.stringify(setupStatus))
|
||||
}
|
||||
|
||||
// 로컬 설정 상태 확인 (개발용)
|
||||
private getLocalSetupStatus(): SetupStatus {
|
||||
const users = this.getStoredUsers()
|
||||
const hasAdmin = users.some(u => u.role === 'admin')
|
||||
|
||||
try {
|
||||
const setupStatus = localStorage.getItem(this.SETUP_KEY)
|
||||
|
||||
if (setupStatus) {
|
||||
const status = JSON.parse(setupStatus)
|
||||
if (status.is_complete) {
|
||||
return {
|
||||
is_setup_required: false,
|
||||
setup_step: 'complete',
|
||||
has_admin: hasAdmin,
|
||||
total_users: users.length,
|
||||
version: status.version || '2.0.0'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse setup status:', error)
|
||||
// 파싱 에러 시 로컬 스토리지 초기화
|
||||
localStorage.removeItem(this.SETUP_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
is_setup_required: !hasAdmin,
|
||||
setup_step: hasAdmin ? 'complete' : 'initial',
|
||||
has_admin: hasAdmin,
|
||||
total_users: users.length,
|
||||
version: '2.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 초기 설정 (개발용)
|
||||
private performLocalSetup(setupData: InitialSetupData): { success: boolean; message: string } {
|
||||
try {
|
||||
const users = this.getStoredUsers()
|
||||
|
||||
// 이미 관리자가 있는지 확인
|
||||
if (users.some(u => u.role === 'admin')) {
|
||||
return {
|
||||
success: false,
|
||||
message: '이미 관리자 계정이 존재합니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 계정 생성
|
||||
const adminUser = {
|
||||
id: `admin_${Date.now()}`,
|
||||
email: setupData.admin_email,
|
||||
name: setupData.admin_name,
|
||||
role: 'admin' as const,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_active: true,
|
||||
preferences: {
|
||||
default_currency: setupData.default_currency,
|
||||
language: setupData.default_language,
|
||||
theme: 'light' as const,
|
||||
map_provider: 'google' as const
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 목록에 추가
|
||||
users.push(adminUser)
|
||||
localStorage.setItem('dev_users', JSON.stringify(users))
|
||||
|
||||
// 비밀번호 저장
|
||||
const passwords = this.getStoredPasswords()
|
||||
passwords[setupData.admin_email] = setupData.admin_password
|
||||
localStorage.setItem('dev_passwords', JSON.stringify(passwords))
|
||||
|
||||
// 사이트 설정 저장
|
||||
const siteConfig = {
|
||||
site_name: setupData.site_name,
|
||||
site_description: setupData.site_description,
|
||||
default_language: setupData.default_language,
|
||||
default_currency: setupData.default_currency,
|
||||
setup_completed_at: new Date().toISOString()
|
||||
}
|
||||
localStorage.setItem('site_config', JSON.stringify(siteConfig))
|
||||
|
||||
// 설정 완료 표시
|
||||
this.markSetupComplete()
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '초기 설정이 완료되었습니다'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Local setup failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '로컬 설정 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 사이트 설정 가져오기
|
||||
getSiteConfig() {
|
||||
try {
|
||||
const config = localStorage.getItem('site_config')
|
||||
return config ? JSON.parse(config) : {
|
||||
site_name: 'Travel Planner',
|
||||
site_description: '스마트한 여행 계획 도구',
|
||||
default_language: 'ko',
|
||||
default_currency: 'KRW'
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
site_name: 'Travel Planner',
|
||||
site_description: '스마트한 여행 계획 도구',
|
||||
default_language: 'ko',
|
||||
default_currency: 'KRW'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 설정 초기화 (개발용)
|
||||
resetSetup(): void {
|
||||
localStorage.removeItem(this.SETUP_KEY)
|
||||
localStorage.removeItem('site_config')
|
||||
localStorage.removeItem('dev_users')
|
||||
localStorage.removeItem('dev_passwords')
|
||||
console.log('설정이 초기화되었습니다')
|
||||
}
|
||||
|
||||
// 개발용 헬퍼 메서드들
|
||||
private getStoredUsers() {
|
||||
try {
|
||||
const stored = localStorage.getItem('dev_users')
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private getStoredPasswords() {
|
||||
try {
|
||||
const stored = localStorage.getItem('dev_passwords')
|
||||
return stored ? JSON.parse(stored) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const initialSetupService = new InitialSetupService()
|
||||
|
||||
// 설정 상태 확인 유틸리티
|
||||
export const SetupUtils = {
|
||||
// 초기 설정이 필요한지 확인
|
||||
async isSetupRequired(): Promise<boolean> {
|
||||
const status = await initialSetupService.checkSetupStatus()
|
||||
return status.is_setup_required
|
||||
},
|
||||
|
||||
// 관리자 계정 존재 여부 확인
|
||||
async hasAdminAccount(): Promise<boolean> {
|
||||
const status = await initialSetupService.checkSetupStatus()
|
||||
return status.has_admin
|
||||
},
|
||||
|
||||
// 설정 단계 확인
|
||||
async getCurrentSetupStep(): Promise<string> {
|
||||
const status = await initialSetupService.checkSetupStatus()
|
||||
return status.setup_step
|
||||
}
|
||||
} as const
|
||||
381
src/services/offlineSupport.ts
Normal file
381
src/services/offlineSupport.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
// 오프라인 지원 및 데이터 캐싱 서비스
|
||||
import { TravelPlan, Activity } from '../types'
|
||||
|
||||
export interface CachedActivityData {
|
||||
id: string
|
||||
title: string
|
||||
location: string
|
||||
coordinates?: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
description?: string
|
||||
time: string
|
||||
type: Activity['type']
|
||||
cached_at: string
|
||||
source: 'google_places' | 'google_maps' | 'manual' | 'imported'
|
||||
offline_data: {
|
||||
// 오프라인에서도 표시할 수 있는 기본 정보
|
||||
address: string
|
||||
category: string
|
||||
estimated_duration?: string
|
||||
notes?: string
|
||||
// 이미지는 base64로 저장 (작은 썸네일만)
|
||||
thumbnail?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface OfflineMapData {
|
||||
// 오프라인 지도용 기본 데이터
|
||||
center: { lat: number; lng: number }
|
||||
zoom: number
|
||||
activities: CachedActivityData[]
|
||||
routes?: {
|
||||
// 미리 계산된 경로 정보
|
||||
day: string
|
||||
waypoints: { lat: number; lng: number; name: string }[]
|
||||
distance: string
|
||||
duration: string
|
||||
instructions: string[]
|
||||
}[]
|
||||
cached_at: string
|
||||
}
|
||||
|
||||
class OfflineSupportService {
|
||||
private readonly CACHE_KEY = 'kumamoto_offline_data'
|
||||
private readonly ACTIVITY_CACHE_KEY = 'kumamoto_cached_activities'
|
||||
private readonly MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1000 // 7일
|
||||
|
||||
// 여행 계획을 오프라인용으로 캐시
|
||||
async cacheTravelPlan(travelPlan: TravelPlan): Promise<void> {
|
||||
try {
|
||||
const offlineData: OfflineMapData = {
|
||||
center: { lat: 32.7898, lng: 130.7417 }, // 구마모토 중심
|
||||
zoom: 11,
|
||||
activities: [],
|
||||
routes: [],
|
||||
cached_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 모든 활동을 오프라인 데이터로 변환
|
||||
for (const daySchedule of travelPlan.schedule) {
|
||||
for (const activity of daySchedule.activities) {
|
||||
const cachedActivity = await this.cacheActivity(activity)
|
||||
offlineData.activities.push(cachedActivity)
|
||||
}
|
||||
|
||||
// 하루 경로 정보도 캐시 (Google Directions API 결과가 있다면)
|
||||
const routeData = await this.cacheRouteForDay(daySchedule.date, daySchedule.activities)
|
||||
if (routeData) {
|
||||
offlineData.routes!.push(routeData)
|
||||
}
|
||||
}
|
||||
|
||||
// 로컬 스토리지에 저장
|
||||
localStorage.setItem(this.CACHE_KEY, JSON.stringify(offlineData))
|
||||
|
||||
console.log('여행 계획이 오프라인용으로 캐시되었습니다')
|
||||
} catch (error) {
|
||||
console.error('오프라인 캐시 저장 실패:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 개별 활동을 캐시
|
||||
private async cacheActivity(activity: Activity): Promise<CachedActivityData> {
|
||||
const cachedActivity: CachedActivityData = {
|
||||
id: activity.id,
|
||||
title: activity.title,
|
||||
location: activity.location || '',
|
||||
coordinates: activity.coordinates,
|
||||
description: activity.description,
|
||||
time: activity.time,
|
||||
type: activity.type,
|
||||
cached_at: new Date().toISOString(),
|
||||
source: 'manual', // 기본값
|
||||
offline_data: {
|
||||
address: activity.location || '',
|
||||
category: this.getCategoryName(activity.type),
|
||||
estimated_duration: this.estimateDuration(activity.type),
|
||||
notes: activity.description
|
||||
}
|
||||
}
|
||||
|
||||
// Google Places에서 가져온 데이터라면 추가 정보 캐시
|
||||
if (activity.coordinates && typeof window !== 'undefined' && window.google) {
|
||||
try {
|
||||
const placeDetails = await this.getPlaceDetailsForCache(activity.coordinates)
|
||||
if (placeDetails) {
|
||||
cachedActivity.offline_data.address = placeDetails.formatted_address || cachedActivity.offline_data.address
|
||||
cachedActivity.source = 'google_places'
|
||||
|
||||
// 작은 썸네일 이미지 캐시 (선택적)
|
||||
if (placeDetails.photos && placeDetails.photos.length > 0) {
|
||||
try {
|
||||
const thumbnail = await this.createThumbnail(placeDetails.photos[0])
|
||||
cachedActivity.offline_data.thumbnail = thumbnail
|
||||
} catch (error) {
|
||||
console.log('썸네일 생성 실패:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('장소 상세 정보 캐시 실패:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return cachedActivity
|
||||
}
|
||||
|
||||
// 하루 경로 정보 캐시
|
||||
private async cacheRouteForDay(date: Date, activities: Activity[]) {
|
||||
try {
|
||||
// 좌표가 있는 활동들만 필터링
|
||||
const activitiesWithCoords = activities.filter(a => a.coordinates)
|
||||
|
||||
if (activitiesWithCoords.length < 2) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Google Directions API가 사용 가능하다면 경로 정보 가져오기
|
||||
if (typeof window !== 'undefined' && window.google && window.google.maps) {
|
||||
// 이미 계산된 경로가 있는지 확인
|
||||
const existingRoute = this.getExistingRoute(date, activitiesWithCoords)
|
||||
if (existingRoute) {
|
||||
return existingRoute
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 경로 정보 생성 (직선 거리 기반 추정)
|
||||
const waypoints = activitiesWithCoords.map(activity => ({
|
||||
lat: activity.coordinates!.lat,
|
||||
lng: activity.coordinates!.lng,
|
||||
name: activity.title
|
||||
}))
|
||||
|
||||
const estimatedDistance = this.calculateTotalDistance(waypoints)
|
||||
const estimatedDuration = this.estimateRouteTime(waypoints, 'driving')
|
||||
|
||||
return {
|
||||
day: date.toISOString().split('T')[0],
|
||||
waypoints,
|
||||
distance: `${estimatedDistance.toFixed(1)}km`,
|
||||
duration: estimatedDuration,
|
||||
instructions: this.generateBasicInstructions(waypoints)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('경로 캐시 실패:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 오프라인 데이터 로드
|
||||
getOfflineData(): OfflineMapData | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(this.CACHE_KEY)
|
||||
if (!cached) return null
|
||||
|
||||
const data: OfflineMapData = JSON.parse(cached)
|
||||
|
||||
// 캐시가 너무 오래되었는지 확인
|
||||
const cacheAge = Date.now() - new Date(data.cached_at).getTime()
|
||||
if (cacheAge > this.MAX_CACHE_AGE) {
|
||||
console.log('오프라인 캐시가 만료되었습니다')
|
||||
return null
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('오프라인 데이터 로드 실패:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 오프라인 모드에서 활동 표시용 데이터 생성
|
||||
generateOfflineActivities(): CachedActivityData[] {
|
||||
const offlineData = this.getOfflineData()
|
||||
return offlineData?.activities || []
|
||||
}
|
||||
|
||||
// 오프라인 모드에서 지도 마커 데이터 생성
|
||||
generateOfflineMarkers() {
|
||||
const activities = this.generateOfflineActivities()
|
||||
|
||||
return activities
|
||||
.filter(activity => activity.coordinates)
|
||||
.map(activity => ({
|
||||
id: activity.id,
|
||||
position: activity.coordinates!,
|
||||
title: activity.title,
|
||||
description: activity.offline_data.address,
|
||||
category: activity.offline_data.category,
|
||||
time: activity.time,
|
||||
type: activity.type
|
||||
}))
|
||||
}
|
||||
|
||||
// 카테고리 이름 변환
|
||||
private getCategoryName(type: Activity['type']): string {
|
||||
const categoryMap = {
|
||||
'attraction': '관광지',
|
||||
'food': '맛집',
|
||||
'accommodation': '숙박',
|
||||
'transport': '교통',
|
||||
'other': '기타'
|
||||
}
|
||||
return categoryMap[type] || '기타'
|
||||
}
|
||||
|
||||
// 예상 소요 시간 추정
|
||||
private estimateDuration(type: Activity['type']): string {
|
||||
const durationMap = {
|
||||
'attraction': '1-2시간',
|
||||
'food': '1시간',
|
||||
'accommodation': '체크인/아웃',
|
||||
'transport': '30분',
|
||||
'other': '30분'
|
||||
}
|
||||
return durationMap[type] || '30분'
|
||||
}
|
||||
|
||||
// 직선 거리 계산 (Haversine formula)
|
||||
private calculateDistance(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const R = 6371 // 지구 반지름 (km)
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLng = (lng2 - lng1) * Math.PI / 180
|
||||
const a =
|
||||
Math.sin(dLat/2) * Math.sin(dLat/2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLng/2) * Math.sin(dLng/2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
// 총 거리 계산
|
||||
private calculateTotalDistance(waypoints: { lat: number; lng: number }[]): number {
|
||||
let totalDistance = 0
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const current = waypoints[i]
|
||||
const next = waypoints[i + 1]
|
||||
totalDistance += this.calculateDistance(current.lat, current.lng, next.lat, next.lng)
|
||||
}
|
||||
return totalDistance
|
||||
}
|
||||
|
||||
// 경로 시간 추정
|
||||
private estimateRouteTime(waypoints: { lat: number; lng: number }[], mode: 'driving' | 'walking' | 'transit'): string {
|
||||
const totalDistance = this.calculateTotalDistance(waypoints)
|
||||
|
||||
// 이동 수단별 평균 속도 (km/h)
|
||||
const speedMap = {
|
||||
'driving': 40, // 시내 주행 고려
|
||||
'walking': 4, // 보행 속도
|
||||
'transit': 25 // 대중교통 (환승 시간 포함)
|
||||
}
|
||||
|
||||
const speed = speedMap[mode]
|
||||
const hours = totalDistance / speed
|
||||
const minutes = Math.round(hours * 60)
|
||||
|
||||
if (minutes >= 60) {
|
||||
const h = Math.floor(minutes / 60)
|
||||
const m = minutes % 60
|
||||
return m > 0 ? `${h}시간 ${m}분` : `${h}시간`
|
||||
} else {
|
||||
return `${minutes}분`
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 길찾기 안내 생성
|
||||
private generateBasicInstructions(waypoints: { lat: number; lng: number; name: string }[]): string[] {
|
||||
const instructions: string[] = []
|
||||
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const current = waypoints[i]
|
||||
const next = waypoints[i + 1]
|
||||
const distance = this.calculateDistance(current.lat, current.lng, next.lat, next.lng)
|
||||
|
||||
instructions.push(`${current.name}에서 ${next.name}까지 약 ${distance.toFixed(1)}km`)
|
||||
}
|
||||
|
||||
return instructions
|
||||
}
|
||||
|
||||
// 기존 경로 정보 확인 (임시)
|
||||
private getExistingRoute(date: Date, activities: Activity[]) {
|
||||
// 실제로는 이전에 계산된 Google Directions 결과를 확인
|
||||
return null
|
||||
}
|
||||
|
||||
// Google Places 상세 정보 가져오기 (캐시용)
|
||||
private async getPlaceDetailsForCache(coordinates: { lat: number; lng: number }) {
|
||||
// Google Places API를 사용해 상세 정보 가져오기
|
||||
// 실제 구현에서는 googlePlacesService 사용
|
||||
return null
|
||||
}
|
||||
|
||||
// 썸네일 이미지 생성
|
||||
private async createThumbnail(photo: google.maps.places.PlacePhoto): Promise<string> {
|
||||
// 작은 썸네일 이미지를 base64로 변환
|
||||
// 실제 구현에서는 Canvas API 사용
|
||||
return ''
|
||||
}
|
||||
|
||||
// 캐시 정리
|
||||
clearCache(): void {
|
||||
localStorage.removeItem(this.CACHE_KEY)
|
||||
localStorage.removeItem(this.ACTIVITY_CACHE_KEY)
|
||||
console.log('오프라인 캐시가 정리되었습니다')
|
||||
}
|
||||
|
||||
// 캐시 상태 확인
|
||||
getCacheStatus() {
|
||||
const offlineData = this.getOfflineData()
|
||||
|
||||
return {
|
||||
hasCachedData: !!offlineData,
|
||||
cachedActivities: offlineData?.activities.length || 0,
|
||||
cachedRoutes: offlineData?.routes?.length || 0,
|
||||
cacheAge: offlineData ? Date.now() - new Date(offlineData.cached_at).getTime() : 0,
|
||||
isExpired: offlineData ? Date.now() - new Date(offlineData.cached_at).getTime() > this.MAX_CACHE_AGE : true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const offlineSupportService = new OfflineSupportService()
|
||||
|
||||
// 오프라인 지원 유틸리티
|
||||
export const OfflineUtils = {
|
||||
// API 사용 가능 여부 확인
|
||||
isGoogleMapsAvailable(): boolean {
|
||||
return typeof window !== 'undefined' &&
|
||||
window.google &&
|
||||
window.google.maps &&
|
||||
!this.isApiLimitExceeded()
|
||||
},
|
||||
|
||||
// API 한도 초과 여부 확인
|
||||
isApiLimitExceeded(): boolean {
|
||||
// googleMapsUsage 서비스와 연동
|
||||
try {
|
||||
const { canUseGoogleMaps } = require('../services/googleMapsUsage')
|
||||
return !canUseGoogleMaps()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 오프라인 모드 권장 여부
|
||||
shouldUseOfflineMode(): boolean {
|
||||
return !this.isGoogleMapsAvailable() || this.isApiLimitExceeded()
|
||||
},
|
||||
|
||||
// 사용자에게 오프라인 모드 안내
|
||||
getOfflineModeMessage(): string {
|
||||
if (this.isApiLimitExceeded()) {
|
||||
return '🚫 Google Maps API 한도에 도달했습니다. 오프라인 모드로 전환합니다.'
|
||||
} else {
|
||||
return '📱 오프라인 모드: 캐시된 데이터로 지도를 표시합니다.'
|
||||
}
|
||||
}
|
||||
} as const
|
||||
517
src/services/tripManager.ts
Normal file
517
src/services/tripManager.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
// 여행 계획 관리 서비스
|
||||
import { TravelPlan } from '../types'
|
||||
import { userAuthService } from './userAuth'
|
||||
|
||||
export interface TripCreateData {
|
||||
title: string
|
||||
description?: string
|
||||
destination: {
|
||||
country: string
|
||||
city: string
|
||||
region?: string
|
||||
coordinates?: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
template_category?: TravelPlan['template_category']
|
||||
tags?: string[]
|
||||
is_public?: boolean
|
||||
}
|
||||
|
||||
export interface TripFilter {
|
||||
user_id?: string
|
||||
status?: TravelPlan['status']
|
||||
template_category?: TravelPlan['template_category']
|
||||
country?: string
|
||||
is_template?: boolean
|
||||
is_public?: boolean
|
||||
search?: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface TripListResponse {
|
||||
trips: TravelPlan[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
class TripManagerService {
|
||||
private readonly API_BASE = '/api/trips'
|
||||
private readonly STORAGE_KEY = 'user_trips'
|
||||
|
||||
// 사용자의 여행 목록 조회
|
||||
async getUserTrips(filter?: TripFilter): Promise<TripListResponse> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
throw new Error('로그인이 필요합니다')
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
params.append(key, value.join(','))
|
||||
} else {
|
||||
params.append(key, value.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.API_BASE}?${params}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trips:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 스토리지 사용
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalUserTrips(currentUser.id, filter)
|
||||
}
|
||||
|
||||
return { trips: [], total: 0, page: 1, limit: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자용: 모든 여행 목록 조회
|
||||
async getAllTrips(filter?: TripFilter): Promise<TripListResponse> {
|
||||
if (!userAuthService.isAdmin()) {
|
||||
throw new Error('관리자 권한이 필요합니다')
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filter) {
|
||||
Object.entries(filter).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
if (Array.isArray(value)) {
|
||||
params.append(key, value.join(','))
|
||||
} else {
|
||||
params.append(key, value.toString())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.API_BASE}/admin/all?${params}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all trips:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 스토리지 사용
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getAllLocalTrips(filter)
|
||||
}
|
||||
|
||||
return { trips: [], total: 0, page: 1, limit: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
// 새 여행 계획 생성
|
||||
async createTrip(tripData: TripCreateData): Promise<{ success: boolean; trip?: TravelPlan; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(this.API_BASE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...userAuthService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(tripData)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to create trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 생성
|
||||
if (import.meta.env.DEV) {
|
||||
return this.createLocalTrip(currentUser.id, tripData)
|
||||
}
|
||||
|
||||
return { success: false, message: '여행 계획 생성 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 계획 수정
|
||||
async updateTrip(tripId: string, updates: Partial<TravelPlan>): Promise<{ success: boolean; trip?: TravelPlan; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/${tripId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...userAuthService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to update trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 수정
|
||||
if (import.meta.env.DEV) {
|
||||
return this.updateLocalTrip(tripId, updates)
|
||||
}
|
||||
|
||||
return { success: false, message: '여행 계획 수정 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 계획 삭제
|
||||
async deleteTrip(tripId: string): Promise<{ success: boolean; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/${tripId}`, {
|
||||
method: 'DELETE',
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to delete trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 삭제
|
||||
if (import.meta.env.DEV) {
|
||||
return this.deleteLocalTrip(tripId)
|
||||
}
|
||||
|
||||
return { success: false, message: '여행 계획 삭제 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 계획 복사
|
||||
async duplicateTrip(tripId: string, newTitle?: string): Promise<{ success: boolean; trip?: TravelPlan; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/${tripId}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...userAuthService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({ title: newTitle })
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to duplicate trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 복사
|
||||
if (import.meta.env.DEV) {
|
||||
return this.duplicateLocalTrip(tripId, newTitle)
|
||||
}
|
||||
|
||||
return { success: false, message: '여행 계획 복사 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 여행 계획 조회
|
||||
async getTrip(tripId: string): Promise<TravelPlan | null> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/${tripId}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
return result.trip
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 조회
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalTrip(tripId)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 개발용 로컬 스토리지 메서드들
|
||||
private getStoredTrips(): TravelPlan[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private saveStoredTrips(trips: TravelPlan[]): void {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(trips))
|
||||
}
|
||||
|
||||
private getLocalUserTrips(userId: string, filter?: TripFilter): TripListResponse {
|
||||
let trips = this.getStoredTrips().filter(trip => trip.user_id === userId)
|
||||
|
||||
// 필터 적용
|
||||
if (filter) {
|
||||
if (filter.status) {
|
||||
trips = trips.filter(trip => trip.status === filter.status)
|
||||
}
|
||||
if (filter.template_category) {
|
||||
trips = trips.filter(trip => trip.template_category === filter.template_category)
|
||||
}
|
||||
if (filter.country) {
|
||||
trips = trips.filter(trip => trip.destination.country === filter.country)
|
||||
}
|
||||
if (filter.is_template !== undefined) {
|
||||
trips = trips.filter(trip => trip.is_template === filter.is_template)
|
||||
}
|
||||
if (filter.search) {
|
||||
const searchLower = filter.search.toLowerCase()
|
||||
trips = trips.filter(trip =>
|
||||
trip.title.toLowerCase().includes(searchLower) ||
|
||||
trip.description?.toLowerCase().includes(searchLower) ||
|
||||
trip.destination.city.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
if (filter.tags && filter.tags.length > 0) {
|
||||
trips = trips.filter(trip =>
|
||||
filter.tags!.some(tag => trip.tags.includes(tag))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 최신순 정렬
|
||||
trips.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
|
||||
return {
|
||||
trips,
|
||||
total: trips.length,
|
||||
page: 1,
|
||||
limit: trips.length
|
||||
}
|
||||
}
|
||||
|
||||
private getAllLocalTrips(filter?: TripFilter): TripListResponse {
|
||||
let trips = this.getStoredTrips()
|
||||
|
||||
// 필터 적용 (위와 동일한 로직)
|
||||
if (filter) {
|
||||
if (filter.user_id) {
|
||||
trips = trips.filter(trip => trip.user_id === filter.user_id)
|
||||
}
|
||||
// ... 다른 필터들도 동일하게 적용
|
||||
}
|
||||
|
||||
trips.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime())
|
||||
|
||||
return {
|
||||
trips,
|
||||
total: trips.length,
|
||||
page: 1,
|
||||
limit: trips.length
|
||||
}
|
||||
}
|
||||
|
||||
private createLocalTrip(userId: string, tripData: TripCreateData): { success: boolean; trip?: TravelPlan; message?: string } {
|
||||
try {
|
||||
const trips = this.getStoredTrips()
|
||||
|
||||
const newTrip: TravelPlan = {
|
||||
id: `trip_${Date.now()}`,
|
||||
title: tripData.title,
|
||||
description: tripData.description,
|
||||
destination: tripData.destination,
|
||||
startDate: tripData.startDate,
|
||||
endDate: tripData.endDate,
|
||||
schedule: [],
|
||||
budget: {
|
||||
total: 0,
|
||||
accommodation: 0,
|
||||
food: 0,
|
||||
transportation: 0,
|
||||
shopping: 0,
|
||||
activities: 0
|
||||
},
|
||||
checklist: [],
|
||||
user_id: userId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_public: tripData.is_public || false,
|
||||
is_template: false,
|
||||
template_category: tripData.template_category,
|
||||
tags: tripData.tags || [],
|
||||
status: 'draft'
|
||||
}
|
||||
|
||||
trips.push(newTrip)
|
||||
this.saveStoredTrips(trips)
|
||||
|
||||
return { success: true, trip: newTrip, message: '여행 계획이 생성되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '여행 계획 생성 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private updateLocalTrip(tripId: string, updates: Partial<TravelPlan>): { success: boolean; trip?: TravelPlan; message?: string } {
|
||||
try {
|
||||
const trips = this.getStoredTrips()
|
||||
const tripIndex = trips.findIndex(trip => trip.id === tripId)
|
||||
|
||||
if (tripIndex === -1) {
|
||||
return { success: false, message: '여행 계획을 찾을 수 없습니다' }
|
||||
}
|
||||
|
||||
trips[tripIndex] = {
|
||||
...trips[tripIndex],
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
this.saveStoredTrips(trips)
|
||||
|
||||
return { success: true, trip: trips[tripIndex], message: '여행 계획이 수정되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '여행 계획 수정 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private deleteLocalTrip(tripId: string): { success: boolean; message?: string } {
|
||||
try {
|
||||
const trips = this.getStoredTrips()
|
||||
const filteredTrips = trips.filter(trip => trip.id !== tripId)
|
||||
|
||||
if (trips.length === filteredTrips.length) {
|
||||
return { success: false, message: '여행 계획을 찾을 수 없습니다' }
|
||||
}
|
||||
|
||||
this.saveStoredTrips(filteredTrips)
|
||||
return { success: true, message: '여행 계획이 삭제되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '여행 계획 삭제 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private duplicateLocalTrip(tripId: string, newTitle?: string): { success: boolean; trip?: TravelPlan; message?: string } {
|
||||
try {
|
||||
const trips = this.getStoredTrips()
|
||||
const originalTrip = trips.find(trip => trip.id === tripId)
|
||||
|
||||
if (!originalTrip) {
|
||||
return { success: false, message: '원본 여행 계획을 찾을 수 없습니다' }
|
||||
}
|
||||
|
||||
const duplicatedTrip: TravelPlan = {
|
||||
...originalTrip,
|
||||
id: `trip_${Date.now()}`,
|
||||
title: newTitle || `${originalTrip.title} (복사본)`,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
status: 'draft'
|
||||
}
|
||||
|
||||
trips.push(duplicatedTrip)
|
||||
this.saveStoredTrips(trips)
|
||||
|
||||
return { success: true, trip: duplicatedTrip, message: '여행 계획이 복사되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '여행 계획 복사 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private getLocalTrip(tripId: string): TravelPlan | null {
|
||||
const trips = this.getStoredTrips()
|
||||
return trips.find(trip => trip.id === tripId) || null
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const tripManagerService = new TripManagerService()
|
||||
|
||||
// 여행지 카테고리 정보
|
||||
export const TripCategories = {
|
||||
japan: {
|
||||
label: '🇯🇵 일본',
|
||||
description: '일본 여행 (도쿄, 오사카, 교토, 구마모토 등)',
|
||||
color: 'bg-red-100 text-red-800'
|
||||
},
|
||||
korea: {
|
||||
label: '🇰🇷 한국',
|
||||
description: '국내 여행 (서울, 부산, 제주도 등)',
|
||||
color: 'bg-blue-100 text-blue-800'
|
||||
},
|
||||
asia: {
|
||||
label: '🌏 아시아',
|
||||
description: '아시아 여행 (중국, 태국, 베트남 등)',
|
||||
color: 'bg-green-100 text-green-800'
|
||||
},
|
||||
europe: {
|
||||
label: '🇪🇺 유럽',
|
||||
description: '유럽 여행 (프랑스, 이탈리아, 독일 등)',
|
||||
color: 'bg-purple-100 text-purple-800'
|
||||
},
|
||||
america: {
|
||||
label: '🌎 아메리카',
|
||||
description: '아메리카 여행 (미국, 캐나다, 브라질 등)',
|
||||
color: 'bg-yellow-100 text-yellow-800'
|
||||
},
|
||||
other: {
|
||||
label: '🌍 기타',
|
||||
description: '기타 지역 여행',
|
||||
color: 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
} as const
|
||||
|
||||
// 여행 상태 정보
|
||||
export const TripStatus = {
|
||||
draft: {
|
||||
label: '📝 계획 중',
|
||||
description: '여행 계획을 작성 중입니다',
|
||||
color: 'bg-gray-100 text-gray-800'
|
||||
},
|
||||
active: {
|
||||
label: '✈️ 여행 중',
|
||||
description: '현재 여행을 진행 중입니다',
|
||||
color: 'bg-blue-100 text-blue-800'
|
||||
},
|
||||
completed: {
|
||||
label: '✅ 완료',
|
||||
description: '여행이 완료되었습니다',
|
||||
color: 'bg-green-100 text-green-800'
|
||||
},
|
||||
cancelled: {
|
||||
label: '❌ 취소',
|
||||
description: '여행이 취소되었습니다',
|
||||
color: 'bg-red-100 text-red-800'
|
||||
}
|
||||
} as const
|
||||
395
src/services/tripSharing.ts
Normal file
395
src/services/tripSharing.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
// 여행 일정 공유 서비스
|
||||
import { ShareLink, TripComment, CollaborativeTrip, TravelPlan } from '../types'
|
||||
import { userAuthService } from './userAuth'
|
||||
import { tripManagerService } from './tripManager'
|
||||
|
||||
export interface CreateShareLinkOptions {
|
||||
trip_id: string
|
||||
expires_in_days?: number
|
||||
max_access_count?: number
|
||||
permissions: {
|
||||
can_view: boolean
|
||||
can_edit: boolean
|
||||
can_comment: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShareLinkInfo {
|
||||
share_code: string
|
||||
share_url: string
|
||||
expires_at?: string
|
||||
permissions: ShareLink['permissions']
|
||||
access_count: number
|
||||
max_access_count?: number
|
||||
}
|
||||
|
||||
class TripSharingService {
|
||||
private readonly API_BASE = '/api/sharing'
|
||||
private readonly STORAGE_KEY = 'shared_trips'
|
||||
private readonly COMMENTS_KEY = 'trip_comments'
|
||||
|
||||
// 공유 링크 생성
|
||||
async createShareLink(options: CreateShareLinkOptions): Promise<{ success: boolean; shareLink?: ShareLinkInfo; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/create`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...userAuthService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(options)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to create share link:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 생성
|
||||
if (import.meta.env.DEV) {
|
||||
return this.createLocalShareLink(currentUser.id, options)
|
||||
}
|
||||
|
||||
return { success: false, message: '공유 링크 생성 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 공유 링크로 여행 계획 조회
|
||||
async getTripByShareCode(shareCode: string): Promise<{ success: boolean; trip?: CollaborativeTrip; message?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/trip/${shareCode}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to get shared trip:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 조회
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalSharedTrip(shareCode)
|
||||
}
|
||||
|
||||
return { success: false, message: '공유된 여행 계획을 불러올 수 없습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 여행 계획의 공유 링크 목록 조회
|
||||
async getShareLinks(tripId: string): Promise<ShareLink[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/links/${tripId}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
return result.shareLinks || []
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('Failed to get share links:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 조회
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalShareLinks(tripId)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 공유 링크 비활성화
|
||||
async deactivateShareLink(linkId: string): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/deactivate/${linkId}`, {
|
||||
method: 'POST',
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to deactivate share link:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 처리
|
||||
if (import.meta.env.DEV) {
|
||||
return this.deactivateLocalShareLink(linkId)
|
||||
}
|
||||
|
||||
return { success: false, message: '공유 링크 비활성화 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 댓글 추가
|
||||
async addComment(tripId: string, content: string): Promise<{ success: boolean; comment?: TripComment; message?: string }> {
|
||||
const currentUser = userAuthService.getCurrentUser()
|
||||
if (!currentUser) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...userAuthService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify({ trip_id: tripId, content })
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to add comment:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 추가
|
||||
if (import.meta.env.DEV) {
|
||||
return this.addLocalComment(currentUser, tripId, content)
|
||||
}
|
||||
|
||||
return { success: false, message: '댓글 추가 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
// 댓글 목록 조회
|
||||
async getComments(tripId: string): Promise<TripComment[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/comments/${tripId}`, {
|
||||
headers: userAuthService.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
return result.comments || []
|
||||
}
|
||||
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('Failed to get comments:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 조회
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getLocalComments(tripId)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 공유 URL 생성
|
||||
generateShareUrl(shareCode: string): string {
|
||||
const baseUrl = window.location.origin
|
||||
return `${baseUrl}/shared/${shareCode}`
|
||||
}
|
||||
|
||||
// 공유 코드 생성 (8자리 랜덤)
|
||||
private generateShareCode(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 개발용 로컬 스토리지 메서드들
|
||||
private getStoredShareLinks(): ShareLink[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private saveStoredShareLinks(shareLinks: ShareLink[]): void {
|
||||
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(shareLinks))
|
||||
}
|
||||
|
||||
private getStoredComments(): TripComment[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.COMMENTS_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private saveStoredComments(comments: TripComment[]): void {
|
||||
localStorage.setItem(this.COMMENTS_KEY, JSON.stringify(comments))
|
||||
}
|
||||
|
||||
private createLocalShareLink(userId: string, options: CreateShareLinkOptions): { success: boolean; shareLink?: ShareLinkInfo; message?: string } {
|
||||
try {
|
||||
const shareLinks = this.getStoredShareLinks()
|
||||
const shareCode = this.generateShareCode()
|
||||
|
||||
const expiresAt = options.expires_in_days
|
||||
? new Date(Date.now() + options.expires_in_days * 24 * 60 * 60 * 1000).toISOString()
|
||||
: undefined
|
||||
|
||||
const newShareLink: ShareLink = {
|
||||
id: `share_${Date.now()}`,
|
||||
trip_id: options.trip_id,
|
||||
created_by: userId,
|
||||
share_code: shareCode,
|
||||
expires_at: expiresAt,
|
||||
is_active: true,
|
||||
access_count: 0,
|
||||
max_access_count: options.max_access_count,
|
||||
permissions: options.permissions,
|
||||
created_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
shareLinks.push(newShareLink)
|
||||
this.saveStoredShareLinks(shareLinks)
|
||||
|
||||
const shareUrl = this.generateShareUrl(shareCode)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
shareLink: {
|
||||
share_code: shareCode,
|
||||
share_url: shareUrl,
|
||||
expires_at: expiresAt,
|
||||
permissions: options.permissions,
|
||||
access_count: 0,
|
||||
max_access_count: options.max_access_count
|
||||
},
|
||||
message: '공유 링크가 생성되었습니다'
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, message: '공유 링크 생성 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private async getLocalSharedTrip(shareCode: string): Promise<{ success: boolean; trip?: CollaborativeTrip; message?: string }> {
|
||||
try {
|
||||
const shareLinks = this.getStoredShareLinks()
|
||||
const shareLink = shareLinks.find(link =>
|
||||
link.share_code === shareCode &&
|
||||
link.is_active &&
|
||||
(!link.expires_at || new Date(link.expires_at) > new Date()) &&
|
||||
(!link.max_access_count || link.access_count < link.max_access_count)
|
||||
)
|
||||
|
||||
if (!shareLink) {
|
||||
return { success: false, message: '유효하지 않거나 만료된 공유 링크입니다' }
|
||||
}
|
||||
|
||||
// 접근 횟수 증가
|
||||
shareLink.access_count += 1
|
||||
shareLink.last_accessed = new Date().toISOString()
|
||||
this.saveStoredShareLinks(shareLinks)
|
||||
|
||||
// 여행 계획 조회
|
||||
const trip = await tripManagerService.getTrip(shareLink.trip_id)
|
||||
if (!trip) {
|
||||
return { success: false, message: '여행 계획을 찾을 수 없습니다' }
|
||||
}
|
||||
|
||||
// 댓글 조회
|
||||
const comments = this.getLocalComments(shareLink.trip_id)
|
||||
|
||||
const collaborativeTrip: CollaborativeTrip = {
|
||||
trip,
|
||||
share_link: shareLink,
|
||||
comments,
|
||||
collaborators: [
|
||||
{
|
||||
user_id: trip.user_id,
|
||||
user_name: '여행 계획 작성자',
|
||||
role: 'owner',
|
||||
joined_at: trip.created_at
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return { success: true, trip: collaborativeTrip }
|
||||
} catch (error) {
|
||||
return { success: false, message: '공유된 여행 계획을 불러올 수 없습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private getLocalShareLinks(tripId: string): ShareLink[] {
|
||||
const shareLinks = this.getStoredShareLinks()
|
||||
return shareLinks.filter(link => link.trip_id === tripId)
|
||||
}
|
||||
|
||||
private deactivateLocalShareLink(linkId: string): { success: boolean; message?: string } {
|
||||
try {
|
||||
const shareLinks = this.getStoredShareLinks()
|
||||
const linkIndex = shareLinks.findIndex(link => link.id === linkId)
|
||||
|
||||
if (linkIndex === -1) {
|
||||
return { success: false, message: '공유 링크를 찾을 수 없습니다' }
|
||||
}
|
||||
|
||||
shareLinks[linkIndex].is_active = false
|
||||
this.saveStoredShareLinks(shareLinks)
|
||||
|
||||
return { success: true, message: '공유 링크가 비활성화되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '공유 링크 비활성화 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private addLocalComment(user: any, tripId: string, content: string): { success: boolean; comment?: TripComment; message?: string } {
|
||||
try {
|
||||
const comments = this.getStoredComments()
|
||||
|
||||
const newComment: TripComment = {
|
||||
id: `comment_${Date.now()}`,
|
||||
trip_id: tripId,
|
||||
user_id: user.id,
|
||||
user_name: user.name,
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
is_edited: false
|
||||
}
|
||||
|
||||
comments.push(newComment)
|
||||
this.saveStoredComments(comments)
|
||||
|
||||
return { success: true, comment: newComment, message: '댓글이 추가되었습니다' }
|
||||
} catch (error) {
|
||||
return { success: false, message: '댓글 추가 중 오류가 발생했습니다' }
|
||||
}
|
||||
}
|
||||
|
||||
private getLocalComments(tripId: string): TripComment[] {
|
||||
const comments = this.getStoredComments()
|
||||
return comments
|
||||
.filter(comment => comment.trip_id === tripId)
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const tripSharingService = new TripSharingService()
|
||||
|
||||
// 공유 권한 정보
|
||||
export const SharePermissions = {
|
||||
view_only: {
|
||||
label: '보기 전용',
|
||||
description: '여행 계획을 볼 수만 있습니다',
|
||||
permissions: { can_view: true, can_edit: false, can_comment: false }
|
||||
},
|
||||
comment: {
|
||||
label: '댓글 가능',
|
||||
description: '여행 계획을 보고 댓글을 달 수 있습니다',
|
||||
permissions: { can_view: true, can_edit: false, can_comment: true }
|
||||
},
|
||||
edit: {
|
||||
label: '편집 가능',
|
||||
description: '여행 계획을 보고 편집할 수 있습니다',
|
||||
permissions: { can_view: true, can_edit: true, can_comment: true }
|
||||
}
|
||||
} as const
|
||||
673
src/services/userAuth.ts
Normal file
673
src/services/userAuth.ts
Normal file
@@ -0,0 +1,673 @@
|
||||
// 사용자 인증 및 계정 관리 서비스
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
name: string
|
||||
avatar?: string
|
||||
role: 'admin' | 'user'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by?: string // 관리자가 생성한 경우 관리자 ID
|
||||
last_login?: string
|
||||
is_active: boolean
|
||||
preferences: {
|
||||
default_currency: 'KRW' | 'JPY' | 'USD'
|
||||
language: 'ko' | 'en' | 'ja'
|
||||
theme: 'light' | 'dark'
|
||||
map_provider: 'google' | 'leaflet'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
role?: 'admin' | 'user'
|
||||
created_by?: string // 관리자가 생성하는 경우
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
success: boolean
|
||||
user?: User
|
||||
token?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
class UserAuthService {
|
||||
private readonly API_BASE = '/api/auth'
|
||||
private readonly TOKEN_KEY = 'travel_planner_token'
|
||||
private readonly USER_KEY = 'travel_planner_user'
|
||||
|
||||
// 현재 로그인된 사용자
|
||||
private currentUser: User | null = null
|
||||
|
||||
constructor() {
|
||||
// 페이지 로드시 저장된 사용자 정보 복원
|
||||
this.loadStoredUser()
|
||||
}
|
||||
|
||||
// 회원가입
|
||||
async register(data: RegisterData): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.user && result.token) {
|
||||
this.setAuthData(result.user, result.token)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Registration failed:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 스토리지로 시뮬레이션
|
||||
if (import.meta.env.DEV) {
|
||||
return this.simulateRegister(data)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '회원가입 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 로그인
|
||||
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE}/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.user && result.token) {
|
||||
this.setAuthData(result.user, result.token)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 스토리지로 시뮬레이션
|
||||
if (import.meta.env.DEV) {
|
||||
return this.simulateLogin(credentials)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '로그인 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 로그아웃
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
await fetch(`${this.API_BASE}/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout API failed:', error)
|
||||
} finally {
|
||||
this.clearAuthData()
|
||||
}
|
||||
}
|
||||
|
||||
// 토큰 갱신
|
||||
async refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const token = this.getToken()
|
||||
if (!token) return false
|
||||
|
||||
const response = await fetch(`${this.API_BASE}/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.token) {
|
||||
localStorage.setItem(this.TOKEN_KEY, result.token)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
async updateProfile(updates: Partial<User>): Promise<AuthResponse> {
|
||||
try {
|
||||
const token = this.getToken()
|
||||
if (!token) {
|
||||
return { success: false, message: '로그인이 필요합니다' }
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.API_BASE}/profile`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success && result.user) {
|
||||
this.currentUser = result.user
|
||||
localStorage.setItem(this.USER_KEY, JSON.stringify(result.user))
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Profile update failed:', error)
|
||||
return {
|
||||
success: false,
|
||||
message: '프로필 업데이트 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 사용자 정보
|
||||
getCurrentUser(): User | null {
|
||||
return this.currentUser
|
||||
}
|
||||
|
||||
// 로그인 상태 확인
|
||||
isAuthenticated(): boolean {
|
||||
return this.currentUser !== null && this.getToken() !== null
|
||||
}
|
||||
|
||||
// 토큰 가져오기
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(this.TOKEN_KEY)
|
||||
}
|
||||
|
||||
// 인증 헤더 생성
|
||||
getAuthHeaders(): Record<string, string> {
|
||||
const token = this.getToken()
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
// 관리자 권한 확인
|
||||
isAdmin(): boolean {
|
||||
return this.currentUser?.role === 'admin'
|
||||
}
|
||||
|
||||
// 관리자 전용: 모든 사용자 목록 조회
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
if (!this.isAdmin()) {
|
||||
throw new Error('관리자 권한이 필요합니다')
|
||||
}
|
||||
|
||||
try {
|
||||
const token = this.getToken()
|
||||
const response = await fetch(`${this.API_BASE}/admin/users`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result.users || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error)
|
||||
|
||||
// 개발 환경에서는 로컬 스토리지 사용
|
||||
if (import.meta.env.DEV) {
|
||||
return this.getStoredUsers()
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 전용: 사용자 계정 생성
|
||||
async createUser(userData: RegisterData): Promise<AuthResponse> {
|
||||
if (!this.isAdmin()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '관리자 권한이 필요합니다'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const token = this.getToken()
|
||||
const response = await fetch(`${this.API_BASE}/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...userData,
|
||||
created_by: this.currentUser?.id
|
||||
})
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to create user:', error)
|
||||
|
||||
// 개발 환경에서는 시뮬레이션
|
||||
if (import.meta.env.DEV) {
|
||||
return this.simulateAdminCreateUser(userData)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자 생성 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 전용: 사용자 정보 수정
|
||||
async updateUser(userId: string, updates: Partial<User>): Promise<AuthResponse> {
|
||||
if (!this.isAdmin()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '관리자 권한이 필요합니다'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const token = this.getToken()
|
||||
const response = await fetch(`${this.API_BASE}/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify(updates)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to update user:', error)
|
||||
|
||||
// 개발 환경에서는 시뮬레이션
|
||||
if (import.meta.env.DEV) {
|
||||
return this.simulateAdminUpdateUser(userId, updates)
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자 정보 수정 중 오류가 발생했습니다'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 전용: 사용자 계정 비활성화/활성화
|
||||
async toggleUserStatus(userId: string): Promise<AuthResponse> {
|
||||
if (!this.isAdmin()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '관리자 권한이 필요합니다'
|
||||
}
|
||||
}
|
||||
|
||||
const users = this.getStoredUsers()
|
||||
const userIndex = users.findIndex(u => u.id === userId)
|
||||
|
||||
if (userIndex === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자를 찾을 수 없습니다'
|
||||
}
|
||||
}
|
||||
|
||||
users[userIndex].is_active = !users[userIndex].is_active
|
||||
localStorage.setItem('dev_users', JSON.stringify(users))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: users[userIndex],
|
||||
message: `사용자가 ${users[userIndex].is_active ? '활성화' : '비활성화'}되었습니다`
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 전용: 사용자 삭제
|
||||
async deleteUser(userId: string): Promise<AuthResponse> {
|
||||
if (!this.isAdmin()) {
|
||||
return {
|
||||
success: false,
|
||||
message: '관리자 권한이 필요합니다'
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === this.currentUser?.id) {
|
||||
return {
|
||||
success: false,
|
||||
message: '자신의 계정은 삭제할 수 없습니다'
|
||||
}
|
||||
}
|
||||
|
||||
const users = this.getStoredUsers()
|
||||
const filteredUsers = users.filter(u => u.id !== userId)
|
||||
|
||||
if (users.length === filteredUsers.length) {
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자를 찾을 수 없습니다'
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem('dev_users', JSON.stringify(filteredUsers))
|
||||
|
||||
// 비밀번호도 삭제
|
||||
const passwords = this.getStoredPasswords()
|
||||
const userToDelete = users.find(u => u.id === userId)
|
||||
if (userToDelete) {
|
||||
delete passwords[userToDelete.email]
|
||||
localStorage.setItem('dev_passwords', JSON.stringify(passwords))
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '사용자가 삭제되었습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 데이터 설정
|
||||
private setAuthData(user: User, token: string): void {
|
||||
this.currentUser = user
|
||||
localStorage.setItem(this.USER_KEY, JSON.stringify(user))
|
||||
localStorage.setItem(this.TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
// 인증 데이터 삭제
|
||||
private clearAuthData(): void {
|
||||
this.currentUser = null
|
||||
localStorage.removeItem(this.USER_KEY)
|
||||
localStorage.removeItem(this.TOKEN_KEY)
|
||||
}
|
||||
|
||||
// 저장된 사용자 정보 로드
|
||||
private loadStoredUser(): void {
|
||||
try {
|
||||
const userStr = localStorage.getItem(this.USER_KEY)
|
||||
const token = localStorage.getItem(this.TOKEN_KEY)
|
||||
|
||||
if (userStr && token) {
|
||||
this.currentUser = JSON.parse(userStr)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stored user:', error)
|
||||
this.clearAuthData()
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경용 회원가입 시뮬레이션
|
||||
private simulateRegister(data: RegisterData): AuthResponse {
|
||||
const users = this.getStoredUsers()
|
||||
|
||||
// 이메일 중복 확인
|
||||
if (users.find(u => u.email === data.email)) {
|
||||
return {
|
||||
success: false,
|
||||
message: '이미 사용 중인 이메일입니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 새 사용자 생성
|
||||
const newUser: User = {
|
||||
id: `user_${Date.now()}`,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: data.role || 'user',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: data.created_by,
|
||||
is_active: true,
|
||||
preferences: {
|
||||
default_currency: 'KRW',
|
||||
language: 'ko',
|
||||
theme: 'light',
|
||||
map_provider: 'leaflet'
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 목록에 추가
|
||||
users.push(newUser)
|
||||
localStorage.setItem('dev_users', JSON.stringify(users))
|
||||
|
||||
// 비밀번호 저장 (개발용 - 실제로는 해시화 필요)
|
||||
const passwords = this.getStoredPasswords()
|
||||
passwords[data.email] = data.password
|
||||
localStorage.setItem('dev_passwords', JSON.stringify(passwords))
|
||||
|
||||
// 토큰 생성
|
||||
const token = `dev_token_${newUser.id}`
|
||||
this.setAuthData(newUser, token)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
token: token,
|
||||
message: '회원가입이 완료되었습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 환경용 로그인 시뮬레이션
|
||||
private simulateLogin(credentials: LoginCredentials): AuthResponse {
|
||||
const users = this.getStoredUsers()
|
||||
const passwords = this.getStoredPasswords()
|
||||
|
||||
const user = users.find(u => u.email === credentials.email)
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
message: '등록되지 않은 이메일입니다'
|
||||
}
|
||||
}
|
||||
|
||||
if (passwords[credentials.email] !== credentials.password) {
|
||||
return {
|
||||
success: false,
|
||||
message: '비밀번호가 올바르지 않습니다'
|
||||
}
|
||||
}
|
||||
|
||||
if (!user.is_active) {
|
||||
return {
|
||||
success: false,
|
||||
message: '비활성화된 계정입니다. 관리자에게 문의하세요'
|
||||
}
|
||||
}
|
||||
|
||||
// 마지막 로그인 시간 업데이트
|
||||
user.last_login = new Date().toISOString()
|
||||
const allUsers = this.getStoredUsers()
|
||||
const userIndex = allUsers.findIndex(u => u.id === user.id)
|
||||
if (userIndex !== -1) {
|
||||
allUsers[userIndex] = user
|
||||
localStorage.setItem('dev_users', JSON.stringify(allUsers))
|
||||
}
|
||||
|
||||
// 토큰 생성
|
||||
const token = `dev_token_${user.id}`
|
||||
this.setAuthData(user, token)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: user,
|
||||
token: token,
|
||||
message: '로그인되었습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 개발용 사용자 목록 가져오기
|
||||
private getStoredUsers(): User[] {
|
||||
try {
|
||||
const stored = localStorage.getItem('dev_users')
|
||||
return stored ? JSON.parse(stored) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// 개발용 비밀번호 목록 가져오기
|
||||
private getStoredPasswords(): Record<string, string> {
|
||||
try {
|
||||
const stored = localStorage.getItem('dev_passwords')
|
||||
return stored ? JSON.parse(stored) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자용 사용자 생성 시뮬레이션
|
||||
private simulateAdminCreateUser(data: RegisterData): AuthResponse {
|
||||
const users = this.getStoredUsers()
|
||||
|
||||
// 이메일 중복 확인
|
||||
if (users.find(u => u.email === data.email)) {
|
||||
return {
|
||||
success: false,
|
||||
message: '이미 사용 중인 이메일입니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 새 사용자 생성
|
||||
const newUser: User = {
|
||||
id: `user_${Date.now()}`,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
role: data.role || 'user',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
created_by: this.currentUser?.id,
|
||||
is_active: true,
|
||||
preferences: {
|
||||
default_currency: 'KRW',
|
||||
language: 'ko',
|
||||
theme: 'light',
|
||||
map_provider: 'leaflet'
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 목록에 추가
|
||||
users.push(newUser)
|
||||
localStorage.setItem('dev_users', JSON.stringify(users))
|
||||
|
||||
// 비밀번호 저장
|
||||
const passwords = this.getStoredPasswords()
|
||||
passwords[data.email] = data.password
|
||||
localStorage.setItem('dev_passwords', JSON.stringify(passwords))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
message: '사용자가 생성되었습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자용 사용자 수정 시뮬레이션
|
||||
private simulateAdminUpdateUser(userId: string, updates: Partial<User>): AuthResponse {
|
||||
const users = this.getStoredUsers()
|
||||
const userIndex = users.findIndex(u => u.id === userId)
|
||||
|
||||
if (userIndex === -1) {
|
||||
return {
|
||||
success: false,
|
||||
message: '사용자를 찾을 수 없습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자 정보 업데이트
|
||||
users[userIndex] = {
|
||||
...users[userIndex],
|
||||
...updates,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
localStorage.setItem('dev_users', JSON.stringify(users))
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: users[userIndex],
|
||||
message: '사용자 정보가 수정되었습니다'
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 계정 존재 여부 확인
|
||||
hasAdminAccount(): boolean {
|
||||
const users = this.getStoredUsers()
|
||||
return users.some(u => u.role === 'admin')
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스
|
||||
export const userAuthService = new UserAuthService()
|
||||
|
||||
// 인증 상태 변경 이벤트
|
||||
export class AuthStateManager {
|
||||
private listeners: ((user: User | null) => void)[] = []
|
||||
|
||||
// 인증 상태 변경 리스너 등록
|
||||
onAuthStateChange(callback: (user: User | null) => void): () => void {
|
||||
this.listeners.push(callback)
|
||||
|
||||
// 현재 상태로 즉시 호출
|
||||
callback(userAuthService.getCurrentUser())
|
||||
|
||||
// 리스너 제거 함수 반환
|
||||
return () => {
|
||||
const index = this.listeners.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 인증 상태 변경 알림
|
||||
notifyAuthStateChange(user: User | null): void {
|
||||
this.listeners.forEach(callback => callback(user))
|
||||
}
|
||||
}
|
||||
|
||||
export const authStateManager = new AuthStateManager()
|
||||
|
||||
// React Hook 스타일 유틸리티
|
||||
export const useAuth = () => {
|
||||
return {
|
||||
user: userAuthService.getCurrentUser(),
|
||||
isAuthenticated: userAuthService.isAuthenticated(),
|
||||
login: userAuthService.login.bind(userAuthService),
|
||||
register: userAuthService.register.bind(userAuthService),
|
||||
logout: userAuthService.logout.bind(userAuthService),
|
||||
updateProfile: userAuthService.updateProfile.bind(userAuthService)
|
||||
}
|
||||
}
|
||||
123
src/types.ts
123
src/types.ts
@@ -1,6 +1,29 @@
|
||||
export interface DaySchedule {
|
||||
date: Date
|
||||
activities: Activity[]
|
||||
// 공통 타입
|
||||
export interface Coordinates {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Link {
|
||||
url: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
// 일정 관련 타입
|
||||
export type ActivityType = 'attraction' | 'food' | 'accommodation' | 'transport' | 'other'
|
||||
export type RelatedPlaceCategory = 'restaurant' | 'attraction' | 'shopping' | 'accommodation' | 'other'
|
||||
|
||||
export interface RelatedPlace {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
address?: string
|
||||
coordinates?: Coordinates
|
||||
memo?: string
|
||||
willVisit?: boolean
|
||||
category?: RelatedPlaceCategory
|
||||
images?: string[]
|
||||
links?: Link[]
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
@@ -9,7 +32,16 @@ export interface Activity {
|
||||
title: string
|
||||
description?: string
|
||||
location?: string
|
||||
type: 'attraction' | 'food' | 'accommodation' | 'transport' | 'other'
|
||||
type: ActivityType
|
||||
coordinates?: Coordinates
|
||||
relatedPlaces?: RelatedPlace[]
|
||||
images?: string[]
|
||||
links?: Link[]
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
date: Date
|
||||
activities: Activity[]
|
||||
}
|
||||
|
||||
export interface Budget {
|
||||
@@ -29,13 +61,35 @@ export interface ChecklistItem {
|
||||
}
|
||||
|
||||
export interface TravelPlan {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
destination: {
|
||||
country: string
|
||||
city: string
|
||||
region?: string
|
||||
coordinates?: Coordinates
|
||||
}
|
||||
startDate: Date
|
||||
endDate: Date
|
||||
schedule: DaySchedule[]
|
||||
budget: Budget
|
||||
checklist: ChecklistItem[]
|
||||
// 새로운 필드들
|
||||
user_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
is_public: boolean
|
||||
is_template: boolean
|
||||
template_category?: 'japan' | 'korea' | 'asia' | 'europe' | 'america' | 'other'
|
||||
tags: string[]
|
||||
thumbnail?: string
|
||||
status: 'draft' | 'active' | 'completed' | 'cancelled'
|
||||
}
|
||||
|
||||
// 관광지 타입
|
||||
export type AttractionCategory = 'castle' | 'nature' | 'onsen' | 'temple' | 'food' | 'other'
|
||||
|
||||
export interface Attraction {
|
||||
id: string
|
||||
name: string
|
||||
@@ -45,10 +99,61 @@ export interface Attraction {
|
||||
estimatedTime: string
|
||||
admissionFee?: number
|
||||
imageUrl?: string
|
||||
category: 'castle' | 'nature' | 'onsen' | 'temple' | 'food' | 'other'
|
||||
coordinates?: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
category: AttractionCategory
|
||||
coordinates?: Coordinates
|
||||
}
|
||||
|
||||
// 기본 포인트 타입
|
||||
export type BasePointType = 'accommodation' | 'airport' | 'station' | 'parking' | 'other'
|
||||
|
||||
export interface BasePoint {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
type: BasePointType
|
||||
coordinates: Coordinates
|
||||
memo?: string
|
||||
}
|
||||
|
||||
// 공유 관련 타입
|
||||
export interface ShareLink {
|
||||
id: string
|
||||
trip_id: string
|
||||
created_by: string
|
||||
share_code: string
|
||||
expires_at?: string
|
||||
is_active: boolean
|
||||
access_count: number
|
||||
max_access_count?: number
|
||||
permissions: {
|
||||
can_view: boolean
|
||||
can_edit: boolean
|
||||
can_comment: boolean
|
||||
}
|
||||
created_at: string
|
||||
last_accessed?: string
|
||||
}
|
||||
|
||||
export interface TripComment {
|
||||
id: string
|
||||
trip_id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
is_edited: boolean
|
||||
}
|
||||
|
||||
export interface CollaborativeTrip {
|
||||
trip: TravelPlan
|
||||
share_link: ShareLink
|
||||
comments: TripComment[]
|
||||
collaborators: {
|
||||
user_id: string
|
||||
user_name: string
|
||||
role: 'owner' | 'editor' | 'viewer'
|
||||
joined_at: string
|
||||
}[]
|
||||
}
|
||||
|
||||
|
||||
129
src/utils/categoryMapping.ts
Normal file
129
src/utils/categoryMapping.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ActivityType, RelatedPlaceCategory, AttractionCategory, BasePointType } from '../types'
|
||||
|
||||
/**
|
||||
* Activity 타입에 대한 이모지 반환
|
||||
*/
|
||||
export const getActivityEmoji = (type: ActivityType): string => {
|
||||
const emojiMap: Record<ActivityType, string> = {
|
||||
attraction: '🏞️',
|
||||
food: '🍴',
|
||||
accommodation: '🏨',
|
||||
transport: '🚗',
|
||||
other: '📍'
|
||||
}
|
||||
return emojiMap[type] || '📍'
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity 타입에 대한 한글 이름 반환
|
||||
*/
|
||||
export const getActivityName = (type: ActivityType): string => {
|
||||
const nameMap: Record<ActivityType, string> = {
|
||||
attraction: '관광지',
|
||||
food: '식사',
|
||||
accommodation: '숙소',
|
||||
transport: '교통',
|
||||
other: '기타'
|
||||
}
|
||||
return nameMap[type] || '기타'
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedPlace 카테고리에 대한 이모지 반환
|
||||
*/
|
||||
export const getRelatedPlaceCategoryEmoji = (category: RelatedPlaceCategory): string => {
|
||||
const emojiMap: Record<RelatedPlaceCategory, string> = {
|
||||
restaurant: '🍴',
|
||||
attraction: '🏞️',
|
||||
shopping: '🛍️',
|
||||
accommodation: '🏨',
|
||||
other: '📍'
|
||||
}
|
||||
return emojiMap[category] || '📍'
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedPlace 카테고리에 대한 한글 이름 반환
|
||||
*/
|
||||
export const getRelatedPlaceCategoryName = (category: RelatedPlaceCategory): string => {
|
||||
const nameMap: Record<RelatedPlaceCategory, string> = {
|
||||
restaurant: '식당',
|
||||
attraction: '관광지',
|
||||
shopping: '쇼핑',
|
||||
accommodation: '숙소',
|
||||
other: '기타'
|
||||
}
|
||||
return nameMap[category] || '기타'
|
||||
}
|
||||
|
||||
/**
|
||||
* RelatedPlace 카테고리에 대한 배경색 반환 (Tailwind CSS 클래스)
|
||||
*/
|
||||
export const getRelatedPlaceCategoryColor = (category: RelatedPlaceCategory): string => {
|
||||
const colorMap: Record<RelatedPlaceCategory, string> = {
|
||||
restaurant: 'bg-orange-500',
|
||||
attraction: 'bg-blue-500',
|
||||
shopping: 'bg-pink-500',
|
||||
accommodation: 'bg-purple-500',
|
||||
other: 'bg-gray-500'
|
||||
}
|
||||
return colorMap[category] || 'bg-gray-500'
|
||||
}
|
||||
|
||||
/**
|
||||
* Attraction 카테고리에 대한 이모지 반환
|
||||
*/
|
||||
export const getAttractionCategoryEmoji = (category: AttractionCategory): string => {
|
||||
const emojiMap: Record<AttractionCategory, string> = {
|
||||
castle: '🏯',
|
||||
nature: '🏞️',
|
||||
onsen: '♨️',
|
||||
temple: '⛩️',
|
||||
food: '🍴',
|
||||
other: '📍'
|
||||
}
|
||||
return emojiMap[category] || '📍'
|
||||
}
|
||||
|
||||
/**
|
||||
* Attraction 카테고리에 대한 한글 이름 반환
|
||||
*/
|
||||
export const getAttractionCategoryName = (category: AttractionCategory): string => {
|
||||
const nameMap: Record<AttractionCategory, string> = {
|
||||
castle: '성',
|
||||
nature: '자연',
|
||||
onsen: '온천',
|
||||
temple: '신사/사찰',
|
||||
food: '맛집',
|
||||
other: '기타'
|
||||
}
|
||||
return nameMap[category] || '기타'
|
||||
}
|
||||
|
||||
/**
|
||||
* BasePoint 타입에 대한 이모지 반환
|
||||
*/
|
||||
export const getBasePointEmoji = (type: BasePointType): string => {
|
||||
const emojiMap: Record<BasePointType, string> = {
|
||||
accommodation: '🏨',
|
||||
airport: '✈️',
|
||||
station: '🚉',
|
||||
parking: '🅿️',
|
||||
other: '📍'
|
||||
}
|
||||
return emojiMap[type] || '📍'
|
||||
}
|
||||
|
||||
/**
|
||||
* BasePoint 타입에 대한 한글 이름 반환
|
||||
*/
|
||||
export const getBasePointName = (type: BasePointType): string => {
|
||||
const nameMap: Record<BasePointType, string> = {
|
||||
accommodation: '숙소',
|
||||
airport: '공항',
|
||||
station: '역',
|
||||
parking: '주차장',
|
||||
other: '기타'
|
||||
}
|
||||
return nameMap[type] || '기타'
|
||||
}
|
||||
34
src/utils/env.ts
Normal file
34
src/utils/env.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// 환경 변수 유틸리티
|
||||
// Vite 환경에서 안전하게 환경 변수에 접근하기 위한 유틸리티
|
||||
|
||||
export const isDevelopment = import.meta.env.DEV
|
||||
export const isProduction = import.meta.env.PROD
|
||||
export const mode = import.meta.env.MODE
|
||||
|
||||
// Google OAuth Client ID
|
||||
export const googleOAuthClientId = import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID || ''
|
||||
|
||||
// API URL
|
||||
export const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001'
|
||||
|
||||
// 개발 환경 여부 확인 함수
|
||||
export const checkDevelopment = (): boolean => {
|
||||
return isDevelopment
|
||||
}
|
||||
|
||||
// 프로덕션 환경 여부 확인 함수
|
||||
export const checkProduction = (): boolean => {
|
||||
return isProduction
|
||||
}
|
||||
|
||||
// 환경 변수 로깅 (개발 환경에서만)
|
||||
export const logEnvInfo = (): void => {
|
||||
if (isDevelopment) {
|
||||
console.log('Environment Info:', {
|
||||
mode,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
googleOAuthClientId: googleOAuthClientId ? '설정됨' : '미설정'
|
||||
})
|
||||
}
|
||||
}
|
||||
40
start-server.sh
Normal file
40
start-server.sh
Normal file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Kumamoto Travel Planner Server 시작 스크립트
|
||||
|
||||
echo "🚀 Kumamoto Travel Planner Server 시작 중..."
|
||||
|
||||
# 서버 디렉토리로 이동
|
||||
cd server
|
||||
|
||||
# .env 파일 확인
|
||||
if [ ! -f .env ]; then
|
||||
echo "⚠️ .env 파일이 없습니다. env.example을 참고해서 .env 파일을 생성하세요."
|
||||
echo "📋 필수 환경 변수:"
|
||||
echo " - DATABASE_URL: PostgreSQL 연결 문자열"
|
||||
echo " - JWT_SECRET: JWT 토큰 시크릿 키"
|
||||
echo ""
|
||||
echo "💡 예시:"
|
||||
echo " cp env.example .env"
|
||||
echo " nano .env"
|
||||
echo ""
|
||||
read -p "계속하시겠습니까? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 의존성 설치
|
||||
echo "📦 의존성 설치 중..."
|
||||
npm install
|
||||
|
||||
# 데이터베이스 연결 테스트
|
||||
echo "🔍 데이터베이스 연결 테스트 중..."
|
||||
if [ -f .env ]; then
|
||||
source .env
|
||||
fi
|
||||
|
||||
# 서버 시작
|
||||
echo "🌟 서버 시작..."
|
||||
npm run dev
|
||||
@@ -11,6 +11,9 @@ export default {
|
||||
green: '#4A7C59',
|
||||
blue: '#5B8FA8',
|
||||
light: '#F5F7F6',
|
||||
primary: '#E74C3C', // 밝은 빨강 - 헤더 배경
|
||||
secondary: '#C0392B', // 진한 빨강 - 버튼 등
|
||||
accent: '#F39C12', // 주황색 - 강조 요소
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user