🎯 프로젝트 리브랜딩: 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:
Hyungi Ahn
2025-11-25 10:39:58 +09:00
parent a01897f50f
commit fd5a68e44a
81 changed files with 18420 additions and 399 deletions

213
DATABASE_SETUP.md Normal file
View 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
View 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
View File

@@ -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** - 당신의 완벽한 여행을 계획하세요! ✈️🗺️

View File

@@ -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:

View File

@@ -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
View 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
View 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

View File

@@ -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
View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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
View 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 };

View 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

File diff suppressed because it is too large Load Diff

22
server/package.json Normal file
View 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
View 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
};

View 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
View 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;

View 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
View 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
View 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
View 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
View 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();

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -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>
)
}

View 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

View 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

View File

@@ -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
View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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>

View 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

View 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
? '&copy; 구마모토 여행 지도 | OpenStreetMap contributors'
: '&copy; <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

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View 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

View 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
? '&copy; 구마모토 여행 지도 | OpenStreetMap contributors'
: '&copy; <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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View 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
}

View File

@@ -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
View 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
View 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
View 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
}
},
}

View 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
View 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

View 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

View 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()

View 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
}
}

View 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

View 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
View 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
View 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
View 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)
}
}

View File

@@ -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
}[]
}

View 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
View 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
View 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

View File

@@ -11,6 +11,9 @@ export default {
green: '#4A7C59',
blue: '#5B8FA8',
light: '#F5F7F6',
primary: '#E74C3C', // 밝은 빨강 - 헤더 배경
secondary: '#C0392B', // 진한 빨강 - 버튼 등
accent: '#F39C12', // 주황색 - 강조 요소
}
}
},