feat : initial commit
This commit is contained in:
commit
b1f12c5c35
78
.dockerignore
Normal file
78
.dockerignore
Normal file
@ -0,0 +1,78 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
package-lock.json.bak
|
||||
|
||||
# Production build
|
||||
/build
|
||||
/dist
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
*.test.js
|
||||
*.test.jsx
|
||||
*.spec.js
|
||||
*.spec.jsx
|
||||
**/__tests__/**
|
||||
**/__mocks__/**
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
README-*
|
||||
CHANGELOG.md
|
||||
LICENSE
|
||||
docs/
|
||||
*.md
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
.gitlab-ci.yml
|
||||
.travis.yml
|
||||
.circleci/
|
||||
azure-pipelines.yml
|
||||
|
||||
# Development tools
|
||||
.eslintrc*
|
||||
.prettierrc*
|
||||
.editorconfig
|
||||
.nvmrc
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.nyc_output
|
||||
.cache/
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
//* README.md
|
||||
# HealthSync - AI 건강 코치
|
||||
64
Dockerfile
Normal file
64
Dockerfile
Normal file
@ -0,0 +1,64 @@
|
||||
# HealthSync - AI 건강 코치 Dockerfile
|
||||
# Multi-stage build for optimized React application
|
||||
|
||||
# Stage 1: Dependencies installation
|
||||
FROM node:18-alpine AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files for better caching
|
||||
COPY package*.json ./
|
||||
|
||||
# Install ALL dependencies (needed for build)
|
||||
RUN npm ci && npm cache clean --force
|
||||
|
||||
# Stage 2: Build application
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependencies from previous stage
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Set build environment variables
|
||||
ENV CI=false
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Stage 3: Production runtime
|
||||
FROM node:18-alpine AS runner
|
||||
|
||||
# Install wget for health check (alpine doesn't have curl by default)
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S healthsync && \
|
||||
adduser -S healthsync -u 1001 -G healthsync
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install serve locally for better security
|
||||
RUN npm init -y && npm install serve@14.2.1
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
# Change ownership to non-root user
|
||||
RUN chown -R healthsync:healthsync /app
|
||||
|
||||
# Expose port 3000
|
||||
EXPOSE 3000
|
||||
|
||||
# Switch to non-root user
|
||||
USER healthsync
|
||||
|
||||
# Health check using wget instead of curl
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
|
||||
|
||||
# Start the application using npx to run local serve
|
||||
CMD ["npx", "serve", "-s", "build", "-l", "3000", "--no-clipboard"]
|
||||
1020
Jenkinsfile
vendored
Normal file
1020
Jenkinsfile
vendored
Normal file
File diff suppressed because it is too large
Load Diff
541
README.md
Normal file
541
README.md
Normal file
@ -0,0 +1,541 @@
|
||||
# 🏥 HealthSync - AI기반 개인형 맞춤 건강관리 서비스
|
||||
|
||||
> **"AI와 함께하는 스마트한 건강 습관 만들기"**
|
||||
|
||||
직장인을 위한 AI 기반 개인 맞춤형 건강관리 플랫폼으로, 건강검진 데이터 분석부터 일상 건강 습관 형성까지 지원하는 올인원 헬스케어 솔루션입니다.
|
||||
|
||||
## 📋 MVP 산출물
|
||||
|
||||
### 🎯 1. 발표자료
|
||||
- [MVP 발표자료](https://gamma.app/docs/HealthSync-mzr82kum8wfpqyf)
|
||||
|
||||
### 🏗️ 2. 설계결과
|
||||
- [논리 아키텍처](https://drive.google.com/file/d/1pmg7BXCfOjf_XytCBd5aiROmLoZv5BQG/view?usp=drive_link)
|
||||
- [API 설계서](https://docs.google.com/spreadsheets/d/18ApEjdr-ypVo5MlSGuNh8DUjP5tjOd0M/edit?usp=drive_link&ouid=118178534404133188086&rtpof=true&sd=true)
|
||||
- [시퀀스 다이어그램](https://drive.google.com/file/d/1R5LhWQMk1irxiNmfmTvH1s5Y-OZ5D1oA/view?usp=drive_link)
|
||||
- [클래스 설계서](https://drive.google.com/file/d/1bIeeTnuoJRsllwnNnvbcG-znOgPD9829/view?usp=drive_link)
|
||||
- [데이터베이스 설계](https://drive.google.com/file/d/1PIWG69nZMU7_7VsHM1H4A2JiRcPRU0fy/view?usp=drive_link)
|
||||
- [패키지 구조도](https://drive.google.com/file/d/1uewSxUTtsWmibSnxz4DYHJM1hTd3TlXP/view?usp=drive_link)
|
||||
- [물리 아키텍처](https://drive.google.com/file/d/1sW-Noid27NFo1Vj1Pqm_qanz7bA21Ef6/view?usp=drive_link)
|
||||
|
||||
### 📱 3. Git Repository
|
||||
- **백엔드 (User/Health/Goal)**: [HealthSync_BE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git)
|
||||
- **백엔드 (AI Service)**: [HealthSync_Intelligence](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git)
|
||||
- **백엔드 (Motivator Service)**: [HealthSync_Motivator](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git)
|
||||
- **Kubernetes Manifest**: [HealthSync_Manifest](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git)
|
||||
- **프론트엔드**: [HealthSync_FE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git)
|
||||
|
||||
### 🎬 4. 시연 동영상
|
||||
- [MVP 시연 영상](https://www.youtube.com/watch?v=FW2d6m4Wppo)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 프로젝트 소개
|
||||
|
||||
### 💡 비즈니스 가치
|
||||
**🏢 회사 관점**
|
||||
- AI 기반 초개인화 헬스케어 플랫폼 개발로 고객 유치 및 비즈니스 가치 극대화
|
||||
- 기업 인지도 향상 및 헬스케어 시장 선점
|
||||
|
||||
**👤 고객 관점**
|
||||
- 건강한 생활을 유지하고자 하는 고객에게 지속적인 건강 개선 추적 제공
|
||||
- 사용자 중심의 개인화된 헬스케어 경험 제공
|
||||
|
||||
### 🎯 핵심 기능
|
||||
|
||||
#### 🔐 인증 & 온보딩
|
||||
- **간편 로그인**: 구글 계정 연동으로 쉬운 회원가입
|
||||
- **개인화 설정**: 직업군별 맞춤 정보 수집 (IT/PM/마케팅/영업/인프라운영/고객상담)
|
||||
|
||||
#### 📊 건강 데이터 분석
|
||||
- **건강검진 연동**: 건강보험공단 데이터 자동 불러오기
|
||||
- **AI 진단**: Claude API 기반 3줄 요약 건강 상태 분석
|
||||
- **이력 관리**: 최근 5회 건강검진 결과 시각화
|
||||
|
||||
#### 🎯 스마트 미션 시스템
|
||||
- **AI 추천**: 건강 상태 + 직업 특성 고려한 5개 맞춤 미션 제안
|
||||
- **습관 추적**: 일일 목표 설정 및 달성률 모니터링
|
||||
- **성취 관리**: 연속 달성 기록 및 마일스톤 보상
|
||||
|
||||
#### 💬 AI 헬스 코치
|
||||
- **실시간 상담**: 건강 관련 질문에 대한 전문적 답변
|
||||
- **독려 메시지**: 주기적 동기부여 및 리마인더 알림
|
||||
- **축하 시스템**: 미션 달성 시 즉각적인 피드백 제공
|
||||
|
||||
### 🏗️ 기술 아키텍처
|
||||
|
||||
#### 📱 Frontend
|
||||
- **React 18** + **TypeScript**
|
||||
- **Material-UI** 컴포넌트 라이브러리
|
||||
- 모바일 퍼스트 반응형 디자인
|
||||
- PWA 지원으로 네이티브 앱 수준 경험
|
||||
|
||||
#### ⚙️ Backend (마이크로서비스)
|
||||
- **Spring Boot 3.4.0** + **Java 21**
|
||||
- **Clean/Hexagonal Architecture** 적용
|
||||
- **Spring Cloud Gateway** 통합 API 게이트웨이
|
||||
- **JWT 기반** 인증/인가 시스템
|
||||
- **User/Health/Goal**: 핵심 비즈니스 로직 처리
|
||||
- **Motivator**: 동기부여 및 알림 전담 서비스
|
||||
|
||||
#### 🧠 AI Service
|
||||
- **Python FastAPI** + **Claude API** 직접 연동
|
||||
- 비동기 처리로 빠른 응답 시간 보장
|
||||
- Redis 캐싱으로 성능 최적화
|
||||
|
||||
#### 🗄️ Data Layer
|
||||
- **PostgreSQL 15**: 메인 데이터베이스
|
||||
- **Redis 7**: 캐싱 및 세션 관리
|
||||
- **Azure Blob Storage**: 건강검진 파일 저장
|
||||
|
||||
#### ☁️ Infrastructure
|
||||
- **Azure Kubernetes Service (AKS)**: 컨테이너 오케스트레이션
|
||||
- **Azure Container Registry (ACR)**: 컨테이너 이미지 저장소
|
||||
- **Jenkins + ArgoCD**: CI/CD 파이프라인
|
||||
- **GitOps**: Manifest 저장소 기반 배포 자동화
|
||||
- **Nginx Ingress**: 로드밸런싱 및 SSL 종료
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 로컬 실행 가이드
|
||||
|
||||
### 📋 사전 요구사항
|
||||
- **Java 21+**
|
||||
- **Node.js 18+**
|
||||
- **Docker & Docker Compose**
|
||||
- **Git**
|
||||
|
||||
### 🔧 환경 설정
|
||||
|
||||
#### 1. 저장소 클론
|
||||
```bash
|
||||
# 백엔드 (User/Health/Goal 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git
|
||||
cd HealthSync_BE
|
||||
|
||||
# 백엔드 (AI 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git
|
||||
cd HealthSync_Intelligence
|
||||
|
||||
# 백엔드 (Motivator 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git
|
||||
cd HealthSync_Motivator
|
||||
|
||||
# Kubernetes Manifest
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
|
||||
cd HealthSync_Manifest
|
||||
|
||||
# 프론트엔드
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git
|
||||
cd HealthSync_FE
|
||||
```
|
||||
|
||||
#### 2. 환경변수 설정
|
||||
```bash
|
||||
# .env 파일 생성
|
||||
cp .env.example .env
|
||||
|
||||
# 필수 환경변수 설정
|
||||
export CLAUDE_API_KEY=your_claude_api_key
|
||||
export GOOGLE_CLIENT_ID=your_google_client_id
|
||||
export GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
export DB_PASSWORD=your_database_password
|
||||
```
|
||||
|
||||
### 🚀 실행 방법
|
||||
|
||||
#### Option 1: Docker Compose (추천)
|
||||
```bash
|
||||
# 전체 서비스 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 특정 서비스만 시작
|
||||
docker-compose up -d postgres redis
|
||||
docker-compose up -d backend
|
||||
docker-compose up -d frontend
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
#### Option 2: 개별 실행
|
||||
```bash
|
||||
# 1. 데이터베이스 시작
|
||||
docker-compose up -d postgres redis
|
||||
|
||||
# 2. 백엔드 서비스 시작
|
||||
# User/Health/Goal 서비스
|
||||
cd HealthSync_BE
|
||||
./gradlew bootRun
|
||||
|
||||
# AI 서비스 (별도 터미널)
|
||||
cd HealthSync_Intelligence
|
||||
python -m uvicorn main:app --reload --port 8083
|
||||
|
||||
# Motivator 서비스 (별도 터미널)
|
||||
cd HealthSync_Motivator
|
||||
./gradlew bootRun # 또는 python main.py (기술스택에 따라)
|
||||
|
||||
# 3. 프론트엔드 시작
|
||||
cd HealthSync_FE
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
### 🌐 접속 정보
|
||||
- **프론트엔드**: http://localhost:3000
|
||||
- **API Gateway**: http://localhost:8080
|
||||
- **Swagger UI**: http://localhost:8080/swagger-ui.html
|
||||
- **PostgreSQL**: localhost:5432
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
---
|
||||
|
||||
## 🚢 CI/CD 가이드
|
||||
|
||||
### 🔄 자동 배포 파이프라인
|
||||
|
||||
#### 1. CI 파이프라인 (Jenkins)
|
||||
```yaml
|
||||
stages:
|
||||
- checkout: 소스 코드 체크아웃
|
||||
- test: 단위 테스트 및 통합 테스트 실행
|
||||
- build: Docker 이미지 빌드
|
||||
- push: Azure Container Registry에 이미지 푸시
|
||||
- deploy: Manifest 저장소 업데이트 및 ArgoCD 배포 트리거
|
||||
```
|
||||
|
||||
#### 2. CD 파이프라인 (ArgoCD)
|
||||
```yaml
|
||||
source:
|
||||
repoURL: https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
|
||||
path: overlays/production
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: healthsync-prod
|
||||
```
|
||||
|
||||
### 🏗️ 배포 환경
|
||||
|
||||
#### Development
|
||||
- **URL**: https://dev.healthsync.com
|
||||
- **자동 배포**: main 브랜치 push 시
|
||||
- **데이터베이스**: Development PostgreSQL
|
||||
|
||||
#### Production
|
||||
- **URL**: https://healthsync.com
|
||||
- **배포 방식**: 수동 승인 후 배포
|
||||
- **데이터베이스**: Production PostgreSQL (백업 설정)
|
||||
|
||||
### 📊 모니터링
|
||||
- **Prometheus**: 메트릭 수집
|
||||
- **Grafana**: 대시보드 및 알림
|
||||
- **Application Insights**: 애플리케이션 모니터링
|
||||
|
||||
---
|
||||
|
||||
## 👥 팀 구성 (Agentic Workflow)
|
||||
|
||||
### 🎯 M사상 실천
|
||||
**Value-Oriented** | **Interactive** | **Iterative**
|
||||
|
||||
| 역할 | 이름 | 담당 영역 |
|
||||
|------|------|-----------|
|
||||
| **PO** | 김PO "PO" | 제품 기획 및 비즈니스 가치 정의 |
|
||||
| **UI/UX** | 김소영 "유엑스" | 사용자 경험 설계 및 인터페이스 디자인 |
|
||||
| **Frontend** | 이준수 "프론트" | React 기반 웹 애플리케이션 개발 |
|
||||
| **Backend** | 박서준 "백엔드" | Spring Boot 마이크로서비스 개발 |
|
||||
| **DevOps** | 정민아 "데브옵스" | CI/CD 및 인프라 자동화 |
|
||||
| **Data Science** | 최태호 "데사이" | AI 모델 개발 및 데이터 분석 |
|
||||
| **Medical** | 김지현 "닥터킴" | 의료 전문 자문 및 검증 |
|
||||
| **QA** | 윤도현 "테스터" | 품질 보증 및 테스트 자동화 |
|
||||
| **Security** | 서예린 "시큐어" | 보안 및 개인정보보호 |
|
||||
|
||||
---
|
||||
|
||||
## 📜 라이선스
|
||||
|
||||
MIT License - 자세한 내용은 [LICENSE](./LICENSE) 파일을 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의 및 지원
|
||||
|
||||
- **이슈 제출**: [Gitea Issues](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/issues)
|
||||
- **기능 요청**: [Feature Request](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/discussions)
|
||||
- **보안 문의**: security@healthsync.com
|
||||
|
||||
---
|
||||
|
||||
*🎯 건강한 습관, AI와 함께 시작하세요! HealthSync는 여러분의 건강한 내일을 응원합니다.*# 🏥 HealthSync - AI기반 개인형 맞춤 건강관리 서비스
|
||||
|
||||
> **"AI와 함께하는 스마트한 건강 습관 만들기"**
|
||||
|
||||
직장인을 위한 AI 기반 개인 맞춤형 건강관리 플랫폼으로, 건강검진 데이터 분석부터 일상 건강 습관 형성까지 지원하는 올인원 헬스케어 솔루션입니다.
|
||||
|
||||
## 📋 MVP 산출물
|
||||
|
||||
### 🎯 1. 발표자료
|
||||
- [MVP 발표자료](https://gamma.app/docs/HealthSync-mzr82kum8wfpqyf)
|
||||
|
||||
### 🏗️ 2. 설계결과
|
||||
- [논리 아키텍처](https://drive.google.com/file/d/1pmg7BXCfOjf_XytCBd5aiROmLoZv5BQG/view?usp=drive_link)
|
||||
- [API 설계서](https://docs.google.com/spreadsheets/d/18ApEjdr-ypVo5MlSGuNh8DUjP5tjOd0M/edit?usp=drive_link&ouid=118178534404133188086&rtpof=true&sd=true)
|
||||
- [시퀀스 다이어그램](https://drive.google.com/file/d/1R5LhWQMk1irxiNmfmTvH1s5Y-OZ5D1oA/view?usp=drive_link)
|
||||
- [클래스 설계서](https://drive.google.com/file/d/1bIeeTnuoJRsllwnNnvbcG-znOgPD9829/view?usp=drive_link)
|
||||
- [데이터베이스 설계](https://drive.google.com/file/d/1PIWG69nZMU7_7VsHM1H4A2JiRcPRU0fy/view?usp=drive_link)
|
||||
- [패키지 구조도](https://drive.google.com/file/d/1uewSxUTtsWmibSnxz4DYHJM1hTd3TlXP/view?usp=drive_link)
|
||||
- [물리 아키텍처](https://drive.google.com/file/d/1sW-Noid27NFo1Vj1Pqm_qanz7bA21Ef6/view?usp=drive_link)
|
||||
|
||||
### 📱 3. Git Repository
|
||||
- **백엔드 (User/Health/Goal)**: [HealthSync_BE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git)
|
||||
- **백엔드 (AI Service)**: [HealthSync_Intelligence](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git)
|
||||
- **백엔드 (Motivator Service)**: [HealthSync_Motivator](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git)
|
||||
- **Kubernetes Manifest**: [HealthSync_Manifest](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git)
|
||||
- **프론트엔드**: [HealthSync_FE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git)
|
||||
|
||||
### 🎬 4. 시연 동영상
|
||||
- [MVP 시연 영상](https://youtube.com/shorts/ptJ4hGYEh4o?feature=share)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 프로젝트 소개
|
||||
|
||||
### 💡 비즈니스 가치
|
||||
**🏢 회사 관점**
|
||||
- AI 기반 초개인화 헬스케어 플랫폼 개발로 고객 유치 및 비즈니스 가치 극대화
|
||||
- 기업 인지도 향상 및 헬스케어 시장 선점
|
||||
|
||||
**👤 고객 관점**
|
||||
- 건강한 생활을 유지하고자 하는 고객에게 지속적인 건강 개선 추적 제공
|
||||
- 사용자 중심의 개인화된 헬스케어 경험 제공
|
||||
|
||||
### 🎯 핵심 기능
|
||||
|
||||
#### 🔐 인증 & 온보딩
|
||||
- **간편 로그인**: 구글 계정 연동으로 쉬운 회원가입
|
||||
- **개인화 설정**: 직업군별 맞춤 정보 수집 (IT/PM/마케팅/영업/인프라운영/고객상담)
|
||||
|
||||
#### 📊 건강 데이터 분석
|
||||
- **건강검진 연동**: 건강보험공단 데이터 자동 불러오기
|
||||
- **AI 진단**: Claude API 기반 3줄 요약 건강 상태 분석
|
||||
- **이력 관리**: 최근 5회 건강검진 결과 시각화
|
||||
|
||||
#### 🎯 스마트 미션 시스템
|
||||
- **AI 추천**: 건강 상태 + 직업 특성 고려한 5개 맞춤 미션 제안
|
||||
- **습관 추적**: 일일 목표 설정 및 달성률 모니터링
|
||||
- **성취 관리**: 연속 달성 기록 및 마일스톤 보상
|
||||
|
||||
#### 💬 AI 헬스 코치
|
||||
- **실시간 상담**: 건강 관련 질문에 대한 전문적 답변
|
||||
- **독려 메시지**: 주기적 동기부여 및 리마인더 알림
|
||||
- **축하 시스템**: 미션 달성 시 즉각적인 피드백 제공
|
||||
|
||||
### 🏗️ 기술 아키텍처
|
||||
|
||||
#### 📱 Frontend
|
||||
- **React 18** + **TypeScript**
|
||||
- **Material-UI** 컴포넌트 라이브러리
|
||||
- 모바일 퍼스트 반응형 디자인
|
||||
- PWA 지원으로 네이티브 앱 수준 경험
|
||||
|
||||
#### ⚙️ Backend (마이크로서비스)
|
||||
- **Spring Boot 3.4.0** + **Java 21**
|
||||
- **Clean/Hexagonal Architecture** 적용
|
||||
- **Spring Cloud Gateway** 통합 API 게이트웨이
|
||||
- **JWT 기반** 인증/인가 시스템
|
||||
- **User/Health/Goal**: 핵심 비즈니스 로직 처리
|
||||
- **Motivator**: 동기부여 및 알림 전담 서비스
|
||||
|
||||
#### 🧠 AI Service
|
||||
- **Python FastAPI** + **Claude API** 직접 연동
|
||||
- 비동기 처리로 빠른 응답 시간 보장
|
||||
- Redis 캐싱으로 성능 최적화
|
||||
|
||||
#### 🗄️ Data Layer
|
||||
- **PostgreSQL 15**: 메인 데이터베이스
|
||||
- **Redis 7**: 캐싱 및 세션 관리
|
||||
- **Azure Blob Storage**: 건강검진 파일 저장
|
||||
|
||||
#### ☁️ Infrastructure
|
||||
- **Azure Kubernetes Service (AKS)**: 컨테이너 오케스트레이션
|
||||
- **Azure Container Registry (ACR)**: 컨테이너 이미지 저장소
|
||||
- **Jenkins + ArgoCD**: CI/CD 파이프라인
|
||||
- **GitOps**: Manifest 저장소 기반 배포 자동화
|
||||
- **Nginx Ingress**: 로드밸런싱 및 SSL 종료
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 로컬 실행 가이드
|
||||
|
||||
### 📋 사전 요구사항
|
||||
- **Java 21+**
|
||||
- **Node.js 18+**
|
||||
- **Docker & Docker Compose**
|
||||
- **Git**
|
||||
|
||||
### 🔧 환경 설정
|
||||
|
||||
#### 1. 저장소 클론
|
||||
```bash
|
||||
# 백엔드 (User/Health/Goal 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git
|
||||
cd HealthSync_BE
|
||||
|
||||
# 백엔드 (AI 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git
|
||||
cd HealthSync_Intelligence
|
||||
|
||||
# 백엔드 (Motivator 서비스)
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git
|
||||
cd HealthSync_Motivator
|
||||
|
||||
# Kubernetes Manifest
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
|
||||
cd HealthSync_Manifest
|
||||
|
||||
# 프론트엔드
|
||||
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git
|
||||
cd HealthSync_FE
|
||||
```
|
||||
|
||||
#### 2. 환경변수 설정
|
||||
```bash
|
||||
# .env 파일 생성
|
||||
cp .env.example .env
|
||||
|
||||
# 필수 환경변수 설정
|
||||
export CLAUDE_API_KEY=your_claude_api_key
|
||||
export GOOGLE_CLIENT_ID=your_google_client_id
|
||||
export GOOGLE_CLIENT_SECRET=your_google_client_secret
|
||||
export DB_PASSWORD=your_database_password
|
||||
```
|
||||
|
||||
### 🚀 실행 방법
|
||||
|
||||
#### Option 1: Docker Compose (추천)
|
||||
```bash
|
||||
# 전체 서비스 시작
|
||||
docker-compose up -d
|
||||
|
||||
# 특정 서비스만 시작
|
||||
docker-compose up -d postgres redis
|
||||
docker-compose up -d backend
|
||||
docker-compose up -d frontend
|
||||
|
||||
# 로그 확인
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
#### Option 2: 개별 실행
|
||||
```bash
|
||||
# 1. 데이터베이스 시작
|
||||
docker-compose up -d postgres redis
|
||||
|
||||
# 2. 백엔드 서비스 시작
|
||||
# User/Health/Goal 서비스
|
||||
cd HealthSync_BE
|
||||
./gradlew bootRun
|
||||
|
||||
# AI 서비스 (별도 터미널)
|
||||
cd HealthSync_Intelligence
|
||||
python -m uvicorn main:app --reload --port 8083
|
||||
|
||||
# Motivator 서비스 (별도 터미널)
|
||||
cd HealthSync_Motivator
|
||||
./gradlew bootRun # 또는 python main.py (기술스택에 따라)
|
||||
|
||||
# 3. 프론트엔드 시작
|
||||
cd HealthSync_FE
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
### 🌐 접속 정보
|
||||
- **프론트엔드**: http://localhost:3000
|
||||
- **API Gateway**: http://localhost:8080
|
||||
- **Swagger UI**: http://localhost:8080/swagger-ui.html
|
||||
- **PostgreSQL**: localhost:5432
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
---
|
||||
|
||||
## 🚢 CI/CD 가이드
|
||||
|
||||
### 🔄 자동 배포 파이프라인
|
||||
|
||||
#### 1. CI 파이프라인 (Jenkins)
|
||||
```yaml
|
||||
stages:
|
||||
- checkout: 소스 코드 체크아웃
|
||||
- test: 단위 테스트 및 통합 테스트 실행
|
||||
- build: Docker 이미지 빌드
|
||||
- push: Azure Container Registry에 이미지 푸시
|
||||
- deploy: Manifest 저장소 업데이트 및 ArgoCD 배포 트리거
|
||||
```
|
||||
|
||||
#### 2. CD 파이프라인 (ArgoCD)
|
||||
```yaml
|
||||
source:
|
||||
repoURL: https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
|
||||
path: overlays/production
|
||||
|
||||
destination:
|
||||
server: https://kubernetes.default.svc
|
||||
namespace: healthsync-prod
|
||||
```
|
||||
|
||||
### 🏗️ 배포 환경
|
||||
|
||||
#### Development
|
||||
- **URL**: https://dev.healthsync.com
|
||||
- **자동 배포**: main 브랜치 push 시
|
||||
- **데이터베이스**: Development PostgreSQL
|
||||
|
||||
#### Production
|
||||
- **URL**: https://healthsync.com
|
||||
- **배포 방식**: 수동 승인 후 배포
|
||||
- **데이터베이스**: Production PostgreSQL (백업 설정)
|
||||
|
||||
### 📊 모니터링
|
||||
- **Prometheus**: 메트릭 수집
|
||||
- **Grafana**: 대시보드 및 알림
|
||||
- **Application Insights**: 애플리케이션 모니터링
|
||||
|
||||
---
|
||||
|
||||
## 👥 팀 구성 (Agentic Workflow)
|
||||
|
||||
### 🎯 M사상 실천
|
||||
**Value-Oriented** | **Interactive** | **Iterative**
|
||||
|
||||
| 역할 | 이름 | 담당 영역 |
|
||||
|------|------|-----------|
|
||||
| **PO** | 김PO "PO" | 제품 기획 및 비즈니스 가치 정의 |
|
||||
| **UI/UX** | 김소영 "유엑스" | 사용자 경험 설계 및 인터페이스 디자인 |
|
||||
| **Frontend** | 이준수 "프론트" | React 기반 웹 애플리케이션 개발 |
|
||||
| **Backend** | 박서준 "백엔드" | Spring Boot 마이크로서비스 개발 |
|
||||
| **DevOps** | 정민아 "데브옵스" | CI/CD 및 인프라 자동화 |
|
||||
| **Data Science** | 최태호 "데사이" | AI 모델 개발 및 데이터 분석 |
|
||||
| **Medical** | 김지현 "닥터킴" | 의료 전문 자문 및 검증 |
|
||||
| **QA** | 윤도현 "테스터" | 품질 보증 및 테스트 자동화 |
|
||||
| **Security** | 서예린 "시큐어" | 보안 및 개인정보보호 |
|
||||
|
||||
---
|
||||
|
||||
## 📜 라이선스
|
||||
|
||||
MIT License - 자세한 내용은 [LICENSE](./LICENSE) 파일을 참조하세요.
|
||||
|
||||
---
|
||||
|
||||
## 📞 문의 및 지원
|
||||
|
||||
- **이슈 제출**: [Gitea Issues](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/issues)
|
||||
- **기능 요청**: [Feature Request](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/discussions)
|
||||
- **보안 문의**: security@healthsync.com
|
||||
|
||||
---
|
||||
|
||||
*🎯 건강한 습관, AI와 함께 시작하세요! HealthSync는 여러분의 건강한 내일을 응원합니다.*
|
||||
32
deployment/container/Dockerfile-healthsync-front
Normal file
32
deployment/container/Dockerfile-healthsync-front
Normal file
@ -0,0 +1,32 @@
|
||||
# Node.js Multi-stage build for React
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage with simple Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built app
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Create simple nginx config for React SPA
|
||||
RUN echo 'server {' > /etc/nginx/conf.d/default.conf && \
|
||||
echo ' listen 80;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' server_name localhost;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' root /usr/share/nginx/html;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' index index.html;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' location / {' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' try_files $uri $uri/ /index.html;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' }' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' location /health {' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' return 200 "healthy";' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' add_header Content-Type text/plain;' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo ' }' >> /etc/nginx/conf.d/default.conf && \
|
||||
echo '}' >> /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
50
deployment/container/nginx.conf
Normal file
50
deployment/container/nginx.conf
Normal file
@ -0,0 +1,50 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
keepalive_timeout 65;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
}
|
||||
}
|
||||
33
eslint.config.js
Normal file
33
eslint.config.js
Normal file
@ -0,0 +1,33 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
|
||||
export default [
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
36
index.html
Normal file
36
index.html
Normal file
@ -0,0 +1,36 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
|
||||
<title>HealthSync - AI 건강 코치</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script src="runtime-env.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
//* public/runtime-env.js
|
||||
window.__runtime_config__ = {
|
||||
AUTH_URL: 'http://20.1.2.3/auth',
|
||||
HEALTH_URL: 'http://20.1.2.3/health',
|
||||
GOAL_URL: 'http://20.1.2.3/goal',
|
||||
CHAT_URL: 'http://20.1.2.3/chat'
|
||||
}
|
||||
19663
package-lock.json
generated
Normal file
19663
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "healthsync-mobile",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://team1tier.20.214.196.128.nip.io/api",
|
||||
"dependencies": {
|
||||
"@react-oauth/google": "^0.12.2",
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
public.zip
Normal file
BIN
public.zip
Normal file
Binary file not shown.
31
public/index.html
Normal file
31
public/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<title>HealthSync - AI 건강 코치</title>
|
||||
|
||||
<!-- Runtime configuration을 먼저 로드 -->
|
||||
<script>
|
||||
// 기본값 설정 (runtime-env.js가 로드되지 않을 경우 대비)
|
||||
window.__runtime_config__ = {
|
||||
GOOGLE_CLIENT_ID: '487051701969-djfjpee90l9hesopa2dgqll4sagbho0p.apps.googleusercontent.com',
|
||||
AUTH_URL: 'http://team1tier.20.214.196.128.nip.io/api/auth',
|
||||
HEALTH_URL: 'http://team1tier.20.214.196.128.nip.io/api/health',
|
||||
INTELLIGENCE_URL: 'http://team1tier.20.214.196.128.nip.io/api/intelligence',
|
||||
GOAL_URL: 'http://team1tier.20.214.196.128.nip.io/api/goals',
|
||||
MOTIVATOR_URL: 'http://team1tier.20.214.196.128.nip.io/api/motivator'
|
||||
};
|
||||
</script>
|
||||
<script src="%PUBLIC_URL%/runtime-env.js" onerror="console.log('runtime-env.js 로드 실패, 기본값 사용')"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>JavaScript를 활성화해야 이 앱을 실행할 수 있습니다.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
28
public/index.html.backup
Normal file
28
public/index.html.backup
Normal file
@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#667eea" />
|
||||
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
|
||||
<title>HealthSync - AI 건강 코치</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f8f9fa;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script src="runtime-env.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
public/runtime-env.js
Normal file
8
public/runtime-env.js
Normal file
@ -0,0 +1,8 @@
|
||||
window.__runtime_config__ = {
|
||||
AUTH_URL: 'http://localhost:8081', // 인증 서비스 (/api/auth/login)
|
||||
USER_URL: 'http://localhost:8081/api/user', // 사용자 서비스 (/api/users/register, /api/users/profile)
|
||||
HEALTH_URL: 'http://localhost:8082/api/health', // 건강 서비스 (/api/health/checkup/sync, /api/health/checkup/history)
|
||||
GOAL_URL: 'http://localhost:8084/api/goals', // 목표 서비스 (/api/goals/missions/active, /api/goals/missions/select)
|
||||
INTELLIGENCE_URL: 'http://team1tier.20.214.196.128.nip.io/api/intelligence', // AI 서비스 (/api/intelligence/health/diagnosis, /api/intelligence/missions/recommend)
|
||||
CHAT_URL: 'http://team1tier.20.214.196.128.nip.io/api/chat' // 채팅 서비스 (/api/chat/consultation)
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
818
src/App.css
Normal file
818
src/App.css
Normal file
@ -0,0 +1,818 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.App {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* 모바일 우선 - 전체 화면 사용 */
|
||||
.phone-container {
|
||||
width: 100vw;
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 태블릿 세로 모드 - 전체 화면 꽉 채우기 */
|
||||
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {
|
||||
body {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.phone-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 태블릿 가로 모드 - 전체 화면 꽉 채우기! */
|
||||
@media (min-width: 768px) and (orientation: landscape) {
|
||||
body {
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.phone-container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱 */
|
||||
@media (min-width: 1024px) and (orientation: portrait) {
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.phone-container {
|
||||
width: 390px;
|
||||
height: 844px;
|
||||
max-height: 90vh;
|
||||
border-radius: 25px;
|
||||
box-shadow: 0 0 30px rgba(0,0,0,0.1);
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 큰 데스크톱 화면 */
|
||||
@media (min-width: 1440px) {
|
||||
.phone-container {
|
||||
width: 420px;
|
||||
height: 900px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: max(env(safe-area-inset-top), 20px) 20px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
position: relative;
|
||||
min-height: 90px;
|
||||
}
|
||||
|
||||
.header-simple {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: max(env(safe-area-inset-top), 20px) 20px 20px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 90px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: clamp(20px, 5vw, 24px);
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 5px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: clamp(18px, 5vw, 24px);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 콘텐츠 영역 - 스크롤 문제 완전 해결 */
|
||||
.content {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 10px 20px; /* 패딩 더 줄임 */
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
height: calc(100% - 160px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start; /* 위쪽 정렬 */
|
||||
}
|
||||
|
||||
.content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 태블릿과 데스크톱에서 더 나은 스크롤 경험 */
|
||||
@media (min-width: 768px) {
|
||||
.content {
|
||||
padding: 20px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 10px; /* 더 줄임 */
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: clamp(70px, 12vw, 90px); /* 더 작게 */
|
||||
height: clamp(70px, 12vw, 90px);
|
||||
margin: 10px auto 15px; /* 마진 더 줄임 */
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: clamp(30px, 8vw, 40px); /* 크기 줄임 */
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: clamp(12px, 4vw, 16px);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
margin-bottom: 16px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
border: 2px solid #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-google:hover {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
font-size: clamp(14px, 3.5vw, 16px);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: clamp(12px, 3.5vw, 14px);
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
transition: border-color 0.3s;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: clamp(12px, 3.5vw, 14px);
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
background: white;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
padding: 20px; /* 패딩 줄임 */
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: clamp(50px, 12vw, 60px);
|
||||
height: clamp(50px, 12vw, 60px);
|
||||
border: 4px solid #e9ecef;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 30px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.mission-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: clamp(16px, 4vw, 20px);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mission-card.selected {
|
||||
border-color: #667eea;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.mission-card h3 {
|
||||
font-size: clamp(16px, 4.5vw, 18px);
|
||||
margin-bottom: 8px;
|
||||
color: #333;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.mission-card p {
|
||||
color: #666;
|
||||
font-size: clamp(12px, 3.5vw, 14px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: clamp(18px, 4vw, 20px);
|
||||
height: clamp(18px, 4vw, 20px);
|
||||
}
|
||||
|
||||
.chat-container {
|
||||
position: absolute;
|
||||
top: 90px;
|
||||
bottom: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
padding: clamp(16px, 4vw, 20px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
/* 스크롤 영역 명확히 설정 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 16px;
|
||||
padding: clamp(10px, 3vw, 12px) clamp(12px, 4vw, 16px);
|
||||
border-radius: 18px;
|
||||
max-width: 85%;
|
||||
word-wrap: break-word;
|
||||
font-size: clamp(14px, 3.5vw, 16px);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
margin-left: auto;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.message.ai {
|
||||
background: #f1f3f4;
|
||||
color: #333;
|
||||
border-bottom-left-radius: 6px;
|
||||
}
|
||||
|
||||
.message.celebration {
|
||||
background: #e8f5e8;
|
||||
border-left: 4px solid #4caf50;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
padding: clamp(16px, 4vw, 20px);
|
||||
border-top: 1px solid #e9ecef;
|
||||
background: white;
|
||||
flex-shrink: 0;
|
||||
/* 하단 네비게이션 때문에 margin-bottom 제거 */
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-text-input {
|
||||
flex: 1;
|
||||
padding: clamp(10px, 3vw, 12px) clamp(12px, 4vw, 16px);
|
||||
border: 2px solid #e9ecef;
|
||||
border-radius: 20px;
|
||||
outline: none;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: clamp(36px, 9vw, 40px);
|
||||
height: clamp(36px, 9vw, 40px);
|
||||
background: #667eea;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 하단 네비게이션 - 반응형으로 완전히 재작성 */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
background: white;
|
||||
border-top: 1px solid #e9ecef;
|
||||
display: flex;
|
||||
padding: 12px 0;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* 태블릿 세로 모드 */
|
||||
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {
|
||||
.bottom-nav {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
transform: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 태블릿 가로 모드 */
|
||||
@media (min-width: 768px) and (orientation: landscape) {
|
||||
.bottom-nav {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
transform: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 데스크톱 */
|
||||
@media (min-width: 1024px) and (orientation: portrait) {
|
||||
.bottom-nav {
|
||||
width: 390px;
|
||||
max-width: 390px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
transform: none;
|
||||
border-radius: 0 0 25px 25px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 큰 데스크톱 */
|
||||
@media (min-width: 1440px) {
|
||||
.bottom-nav {
|
||||
width: 420px;
|
||||
max-width: 420px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
font-size: clamp(10px, 3vw, 12px);
|
||||
transition: color 0.3s;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.nav-item .icon {
|
||||
font-size: clamp(16px, 5vw, 20px);
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: clamp(16px, 4vw, 20px);
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: clamp(14px, 3.5vw, 16px);
|
||||
}
|
||||
|
||||
.metric:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: clamp(12px, 4vw, 16px);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
flex: 1;
|
||||
font-size: clamp(14px, 4vw, 16px);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.chart-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
background: #f8f9fa;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.chart-tab {
|
||||
flex: 1;
|
||||
padding: clamp(4px, 2vw, 6px) clamp(6px, 2vw, 8px);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #666;
|
||||
font-size: clamp(9px, 2.5vw, 11px);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.chart-tab.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: clamp(160px, 40vw, 200px);
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: clamp(12px, 4vw, 16px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goal-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.goal-count {
|
||||
font-size: clamp(12px, 3.5vw, 14px);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.goal-btn {
|
||||
width: clamp(28px, 7vw, 32px);
|
||||
height: clamp(28px, 7vw, 32px);
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
cursor: pointer;
|
||||
font-size: clamp(14px, 4vw, 18px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.goal-btn.completed {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.achievement-banner {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
padding: clamp(16px, 5vw, 20px);
|
||||
border-radius: 12px;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.achievement-banner h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: clamp(16px, 4.5vw, 18px);
|
||||
}
|
||||
|
||||
.achievement-banner p {
|
||||
opacity: 0.9;
|
||||
font-size: clamp(12px, 3.5vw, 14px);
|
||||
}
|
||||
|
||||
.period-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.period-btn {
|
||||
flex: 1;
|
||||
padding: clamp(6px, 2vw, 8px);
|
||||
border: 1px solid #667eea;
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
color: #667eea;
|
||||
font-size: clamp(10px, 3vw, 12px);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.period-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.overall-rate {
|
||||
font-size: clamp(32px, 10vw, 48px);
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.daily-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: clamp(4px, 2vw, 8px);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
text-align: center;
|
||||
font-size: clamp(10px, 3vw, 12px);
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.day-item {
|
||||
width: clamp(24px, 6vw, 32px);
|
||||
height: clamp(24px, 6vw, 32px);
|
||||
border-radius: 6px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: clamp(9px, 3vw, 12px);
|
||||
}
|
||||
|
||||
.day-item.completed {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.day-item.incomplete {
|
||||
background: #e9ecef;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #f8f9ff;
|
||||
padding: clamp(12px, 4vw, 16px);
|
||||
border-radius: 12px;
|
||||
margin-top: 10px; /* 마진 더 줄임 */
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px; /* 마진 줄임 */
|
||||
text-align: center;
|
||||
font-size: clamp(13px, 3.5vw, 15px); /* 폰트 크기 줄임 */
|
||||
}
|
||||
|
||||
.info-box .subtitle {
|
||||
color: #666;
|
||||
font-size: clamp(11px, 3vw, 13px); /* 폰트 크기 줄임 */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f8f9fa;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dashboard-header-btn {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: clamp(18px, 5vw, 20px);
|
||||
cursor: pointer;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
padding-bottom: clamp(100px, 25vw, 120px) !important;
|
||||
}
|
||||
|
||||
.history-btn {
|
||||
background: none;
|
||||
border: 1px solid #667eea;
|
||||
color: #667eea;
|
||||
padding: clamp(4px, 2vw, 6px) clamp(8px, 3vw, 12px);
|
||||
border-radius: 6px;
|
||||
font-size: clamp(10px, 3vw, 12px);
|
||||
cursor: pointer;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 접근성 개선 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 터치 디바이스 최적화 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn:hover,
|
||||
.btn-primary:hover,
|
||||
.btn-google:hover,
|
||||
.back-btn:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn:active,
|
||||
.btn-primary:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 매우 작은 화면 최적화 */
|
||||
@media (max-width: 320px) {
|
||||
.content {
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.mission-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.chat-messages,
|
||||
.chat-input {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
160
src/App.js
Normal file
160
src/App.js
Normal file
@ -0,0 +1,160 @@
|
||||
//* src/App.js
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import LoadingPage from './pages/LoadingPage';
|
||||
import MissionPage from './pages/MissionPage';
|
||||
import ChatPage from './pages/ChatPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import GoalsPage from './pages/GoalsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import OAuthCallbackPage from './pages/OAuthCallbackPage';
|
||||
|
||||
function App() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [selectedMissions, setSelectedMissions] = useState([]);
|
||||
const [healthData, setHealthData] = useState(null); // 건강검진 데이터
|
||||
const [healthDiagnosis, setHealthDiagnosis] = useState([]); // 건강진단 3줄 요약
|
||||
const [aiMissions, setAiMissions] = useState([]); // AI 추천 미션들
|
||||
const [goalData, setGoalData] = useState({
|
||||
water: { current: 3, target: 8 },
|
||||
walk: { current: 10, target: 10 },
|
||||
stretch: { current: 5, target: 5 },
|
||||
snack: { current: 0, target: 1 },
|
||||
sleep: { current: 0, target: 1 }
|
||||
});
|
||||
|
||||
// 🔍 상태 변경 감지 함수들
|
||||
const setHealthDataWithLog = (data) => {
|
||||
console.log('=== App.js setHealthData 호출 ===');
|
||||
console.log('받은 healthData:', data);
|
||||
console.log('데이터 타입:', typeof data);
|
||||
if (data) {
|
||||
console.log('데이터 키들:', Object.keys(data));
|
||||
}
|
||||
setHealthData(data);
|
||||
};
|
||||
|
||||
const setHealthDiagnosisWithLog = (diagnosis) => {
|
||||
console.log('=== App.js setHealthDiagnosis 호출 ===');
|
||||
console.log('받은 healthDiagnosis:', diagnosis);
|
||||
console.log('진단 타입:', typeof diagnosis);
|
||||
console.log('진단 배열 여부:', Array.isArray(diagnosis));
|
||||
console.log('진단 길이:', diagnosis?.length);
|
||||
setHealthDiagnosis(diagnosis);
|
||||
};
|
||||
|
||||
const setAiMissionsWithLog = (missions) => {
|
||||
console.log('=== App.js setAiMissions 호출 ===');
|
||||
console.log('받은 aiMissions:', missions);
|
||||
console.log('미션 타입:', typeof missions);
|
||||
console.log('미션 배열 여부:', Array.isArray(missions));
|
||||
console.log('미션 개수:', missions?.length);
|
||||
setAiMissions(missions);
|
||||
};
|
||||
|
||||
const setGoalDataWithLog = (goals) => {
|
||||
console.log('=== App.js setGoalData 호출 ===');
|
||||
console.log('이전 goalData:', goalData);
|
||||
console.log('받은 goalData:', goals);
|
||||
|
||||
// ✅ goalData 변경 상세 로깅
|
||||
if (goals && Object.keys(goals).length > 0) {
|
||||
console.log('목표 개수:', Object.keys(goals).length);
|
||||
Object.keys(goals).forEach(goalType => {
|
||||
const goal = goals[goalType];
|
||||
console.log(`${goalType}:`, {
|
||||
current: goal?.current,
|
||||
target: goal?.target,
|
||||
goalName: goal?.goalName,
|
||||
missionId: goal?.missionId,
|
||||
completedToday: goal?.completedToday
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
setGoalData(goals);
|
||||
};
|
||||
|
||||
// 🔍 현재 상태 로깅 (렌더링 시마다)
|
||||
console.log('=== App.js 현재 상태 ===');
|
||||
console.log('App healthData:', healthData);
|
||||
console.log('App healthDiagnosis:', healthDiagnosis);
|
||||
console.log('App aiMissions:', aiMissions);
|
||||
console.log('App goalData:', goalData);
|
||||
console.log('App user:', user);
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
<Router>
|
||||
<div className="phone-container">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage setUser={setUser} />} />
|
||||
<Route path="/register" element={<RegisterPage setUser={setUser} />} />
|
||||
<Route
|
||||
path="/loading"
|
||||
element={
|
||||
<LoadingPage
|
||||
setHealthData={setHealthDataWithLog}
|
||||
setGoalData={setGoalDataWithLog}
|
||||
setAiMissions={setAiMissionsWithLog}
|
||||
setHealthDiagnosis={setHealthDiagnosisWithLog}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/login/oauth2/code/google" element={<OAuthCallbackPage setUser={setUser} />} />
|
||||
<Route
|
||||
path="/mission"
|
||||
element={
|
||||
<MissionPage
|
||||
selectedMissions={selectedMissions}
|
||||
setSelectedMissions={setSelectedMissions}
|
||||
setGoalData={setGoalDataWithLog} // 디버깅 함수로 변경
|
||||
aiMissions={aiMissions} // AI 추천 미션 전달
|
||||
healthData={healthData} // 건강 데이터 전달
|
||||
healthDiagnosis={healthDiagnosis} // 건강진단 3줄 요약 전달
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/chat" element={<ChatPage user={user} />} />
|
||||
|
||||
<Route
|
||||
path="/goals"
|
||||
element={
|
||||
<GoalsPage
|
||||
goalData={goalData}
|
||||
setGoalData={setGoalDataWithLog} // 디버깅 함수로 변경
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<DashboardPage
|
||||
user={user}
|
||||
healthData={healthData}
|
||||
healthDiagnosis={healthDiagnosis}
|
||||
goalData={goalData}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* ✅ HistoryPage에 goalData props 추가 */}
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<HistoryPage
|
||||
goalData={goalData}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
60
src/App.js.backup
Normal file
60
src/App.js.backup
Normal file
@ -0,0 +1,60 @@
|
||||
//* src/App.js
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import LoadingPage from './pages/LoadingPage';
|
||||
import MissionPage from './pages/MissionPage';
|
||||
import ChatPage from './pages/ChatPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import GoalsPage from './pages/GoalsPage';
|
||||
import HistoryPage from './pages/HistoryPage';
|
||||
import { GoogleOAuthProvider } from '@react-oauth/google';
|
||||
|
||||
function App() {
|
||||
const googleClientId = window.__runtime_config__?.GOOGLE_CLIENT_ID;
|
||||
const [user, setUser] = useState(null);
|
||||
const [healthData, setHealthData] = useState(null);
|
||||
const [selectedMissions, setSelectedMissions] = useState([]);
|
||||
const [goalData, setGoalData] = useState({
|
||||
water: { current: 3, target: 8 },
|
||||
walk: { current: 10, target: 10 },
|
||||
stretch: { current: 5, target: 5 },
|
||||
snack: { current: 0, target: 1 },
|
||||
sleep: { current: 0, target: 1 }
|
||||
});
|
||||
|
||||
return (
|
||||
<GoogleOAuthProvider clientId={googleClientId}>
|
||||
<div className="App">
|
||||
<Router>
|
||||
<div className="phone-container">
|
||||
<Routes>
|
||||
{/* Navigate 컴포넌트는 props를 받지 않습니다 */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="/login" element={<LoginPage setUser={setUser} />} />
|
||||
<Route path="/register" element={<RegisterPage setUser={setUser} />} />
|
||||
<Route
|
||||
path="/loading"
|
||||
element={
|
||||
<LoadingPage
|
||||
setHealthData={setHealthData}
|
||||
setGoalData={setGoalData}
|
||||
user={user}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="/mission" element={<MissionPage selectedMissions={selectedMissions} setSelectedMissions={setSelectedMissions} />} />
|
||||
<Route path="/chat" element={<ChatPage user={user} />} />
|
||||
<Route path="/dashboard" element={<DashboardPage user={user} healthData={healthData} />} />
|
||||
<Route path="/goals" element={<GoalsPage goalData={goalData} setGoalData={setGoalData} />} />
|
||||
<Route path="/history" element={<HistoryPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</div>
|
||||
</GoogleOAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
35
src/App.jsx
Normal file
35
src/App.jsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.jsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
48
src/components/BottomNav.js
Normal file
48
src/components/BottomNav.js
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
function BottomNav() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const navItems = [
|
||||
{ path: '/chat', icon: '💬', label: '채팅' },
|
||||
{ path: '/dashboard', icon: '📊', label: '대시보드' },
|
||||
{ path: '/goals', icon: '🎯', label: '목표' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'white',
|
||||
borderTop: '1px solid #e9ecef',
|
||||
display: 'flex',
|
||||
padding: '12px 0',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
|
||||
}}>
|
||||
{navItems.map(item => (
|
||||
<div
|
||||
key={item.path}
|
||||
style={{
|
||||
flex: 1,
|
||||
textAlign: 'center',
|
||||
padding: '8px',
|
||||
cursor: 'pointer',
|
||||
color: location.pathname === item.path ? '#667eea' : '#999',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<div style={{ fontSize: '20px', marginBottom: '4px' }}>{item.icon}</div>
|
||||
<div>{item.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BottomNav;
|
||||
28
src/components/Header.js
Normal file
28
src/components/Header.js
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function Header({ title, showBack = false, backPath = null, children }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleBack = () => {
|
||||
if (backPath) {
|
||||
navigate(backPath);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="header-simple">
|
||||
{showBack && (
|
||||
<button className="back-btn" onClick={handleBack}>
|
||||
←
|
||||
</button>
|
||||
)}
|
||||
<div className="title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
12
src/components/LoadingSpinner.js
Normal file
12
src/components/LoadingSpinner.js
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
function LoadingSpinner({ message = "로딩 중..." }) {
|
||||
return (
|
||||
<div className="loading-container">
|
||||
<div className="spinner"></div>
|
||||
<h3 style={{ marginBottom: '16px' }}>{message}</h3>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingSpinner;
|
||||
21
src/components/MissionCard.js
Normal file
21
src/components/MissionCard.js
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
function MissionCard({ mission, isSelected, onToggle }) {
|
||||
return (
|
||||
<div
|
||||
className={`mission-card ${isSelected ? 'selected' : ''}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
<h3>{mission.title}</h3>
|
||||
<p>{mission.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MissionCard;
|
||||
68
src/index.css
Normal file
68
src/index.css
Normal file
@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
11
src/index.js
Normal file
11
src/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './App.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
10
src/main.jsx
Normal file
10
src/main.jsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
386
src/pages/ChatPage.js
Normal file
386
src/pages/ChatPage.js
Normal file
@ -0,0 +1,386 @@
|
||||
//* src/pages/ChatPage.js
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
|
||||
function ChatPage({ user }) {
|
||||
const messagesEndRef = useRef(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFirstLoad, setIsFirstLoad] = useState(true);
|
||||
|
||||
// 컴포넌트 마운트 시 환영 메시지 표시
|
||||
useEffect(() => {
|
||||
initializeChat();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // initializeChat은 마운트 시에만 실행되어야 함
|
||||
|
||||
// 새 메시지가 추가될 때마다 스크롤을 맨 아래로
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// 채팅 초기화 함수 수정
|
||||
const initializeChat = async () => {
|
||||
try {
|
||||
// 기본 환영 메시지 (항상 맨 위에 표시)
|
||||
const welcomeMessage = {
|
||||
id: 'welcome',
|
||||
type: 'ai',
|
||||
text: '안녕하세요! 저는 회원님의 AI 건강 코치입니다. 건강검진 결과에 대해 궁금한 점이 있으시면 언제든 물어보세요! 🏥',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 기존 대화 히스토리 로드 시도
|
||||
const loadedMessages = await loadChatHistory();
|
||||
|
||||
if (!loadedMessages || loadedMessages.length === 0) {
|
||||
// 히스토리가 없으면 환영 메시지만 표시
|
||||
setMessages([welcomeMessage]);
|
||||
console.log('첫 방문 - 환영 메시지만 표시합니다.');
|
||||
} else {
|
||||
// 히스토리가 있으면 환영 메시지 + 히스토리 메시지들 표시
|
||||
setMessages([welcomeMessage, ...loadedMessages]);
|
||||
console.log(`환영 메시지와 ${loadedMessages.length}개의 히스토리 메시지를 불러왔습니다.`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('채팅 초기화 중 예상치 못한 오류:', error);
|
||||
// 초기화 실패 시 기본 환영 메시지만 표시
|
||||
setMessages([{
|
||||
id: 'welcome',
|
||||
type: 'ai',
|
||||
text: '안녕하세요! 저는 회원님의 AI 건강 코치입니다. 건강에 대해 궁금한 점이 있으시면 언제든 물어보세요! 🏥',
|
||||
timestamp: new Date().toISOString()
|
||||
}]);
|
||||
} finally {
|
||||
setIsFirstLoad(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 대화 히스토리 로드 함수 수정 (메시지 배열을 반환하도록)
|
||||
const loadChatHistory = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const storedUserId = localStorage.getItem('userId');
|
||||
const userId = storedUserId ? parseInt(storedUserId) : 1;
|
||||
|
||||
console.log(`사용자 ${userId}의 대화 히스토리를 불러오는 중...`);
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/chat/history?user_id=${userId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 || response.status === 422) {
|
||||
console.log('대화 기록이 없습니다. 새로운 대화를 시작합니다.');
|
||||
return []; // 빈 배열 반환
|
||||
}
|
||||
throw new Error(`히스토리 API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const chatHistory = result.chat_history || [];
|
||||
|
||||
if (Array.isArray(chatHistory) && chatHistory.length > 0) {
|
||||
// 메시지를 시간순으로 정렬하고 사용자/AI 메시지 분리
|
||||
const allMessages = [];
|
||||
|
||||
chatHistory
|
||||
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) // 시간순 정렬 (오래된 것부터)
|
||||
.forEach((record) => {
|
||||
// AI 응답이 오류 메시지인지 먼저 확인
|
||||
const hasErrorResponse = record.response_content && (
|
||||
record.response_content.includes('💭 응답을 생성하고 있습니다') ||
|
||||
record.response_content.includes('❌ 죄송합니다. 응답 생성 중 오류가 발생했습니다') ||
|
||||
record.response_content.includes('죄송합니다. 일시적인 오류가 발생했습니다') ||
|
||||
record.response_content.includes('건강 상담 처리 실패') ||
|
||||
record.response_content.includes('건강검진 데이터를 찾을 수 없습니다') ||
|
||||
record.response_content.includes('AI 채팅 API 오류')
|
||||
);
|
||||
|
||||
// 오류 응답이 있는 경우 사용자 질문과 AI 응답 모두 제외
|
||||
if (hasErrorResponse) {
|
||||
return; // 이 record 전체를 건너뛰기
|
||||
}
|
||||
|
||||
// 사용자 메시지 (message_content가 있는 경우)
|
||||
if (record.message_content && record.message_content.trim() !== '') {
|
||||
allMessages.push({
|
||||
id: `user_${record.message_id}`,
|
||||
type: 'user',
|
||||
text: record.message_content,
|
||||
timestamp: record.created_at
|
||||
});
|
||||
}
|
||||
|
||||
// AI 응답 (정상적인 응답인 경우만)
|
||||
if (record.response_content && record.response_content.trim() !== '') {
|
||||
allMessages.push({
|
||||
id: `ai_${record.message_id}`,
|
||||
type: 'ai',
|
||||
text: record.response_content,
|
||||
timestamp: record.created_at
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`${allMessages.length}개의 메시지를 불러왔습니다.`);
|
||||
return allMessages; // 메시지 배열 반환
|
||||
} else {
|
||||
console.log('대화 기록이 비어있습니다.');
|
||||
return []; // 빈 배열 반환
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('대화 히스토리 로드 중 오류:', error);
|
||||
return []; // 오류 시 빈 배열 반환
|
||||
}
|
||||
};
|
||||
|
||||
// AI 채팅 API 호출 함수
|
||||
const sendMessageToAI = async (message) => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const storedUserId = localStorage.getItem('userId');
|
||||
const userId = storedUserId ? parseInt(storedUserId) : 1; // 로컬스토리지에서 가져오되 없으면 1
|
||||
|
||||
console.log('💬 채팅 API 요청:', {
|
||||
url: `${window.__runtime_config__?.INTELLIGENCE_URL}/chat/consultation`,
|
||||
userId,
|
||||
message: message.substring(0, 50) + '...'
|
||||
});
|
||||
|
||||
// ✅ INTELLIGENCE_URL 사용으로 수정
|
||||
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/chat/consultation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
user_id: userId // integer 타입으로 전송
|
||||
})
|
||||
});
|
||||
|
||||
console.log('📡 채팅 API 응답 상태:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ 채팅 API 오류:', response.status, errorText);
|
||||
throw new Error(`AI 채팅 API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ 채팅 API 성공:', result);
|
||||
|
||||
return result.response || result.message || '죄송합니다. 응답을 생성할 수 없습니다.';
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI 채팅 API 호출 오류:', error);
|
||||
return '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
};
|
||||
|
||||
// 메시지 전송 핸들러
|
||||
|
||||
// 메시지 전송 핸들러
|
||||
const handleSend = async () => {
|
||||
if (inputText.trim() && !isLoading) {
|
||||
const userMessage = {
|
||||
id: messages.length + 1,
|
||||
type: 'user',
|
||||
text: inputText.trim(),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// 사용자 메시지 즉시 추가
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// AI 응답 요청
|
||||
const aiResponseText = await sendMessageToAI(userMessage.text);
|
||||
|
||||
// AI 응답 메시지 추가
|
||||
const aiMessage = {
|
||||
id: messages.length + 2,
|
||||
type: 'ai',
|
||||
text: aiResponseText,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('메시지 전송 오류:', error);
|
||||
|
||||
// 오류 시 기본 응답 추가
|
||||
const errorMessage = {
|
||||
id: messages.length + 2,
|
||||
type: 'ai',
|
||||
text: '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
if (isFirstLoad) {
|
||||
return (
|
||||
<div className="phone-container">
|
||||
<div className="header-simple">
|
||||
<div className="title">AI 건강 코치</div>
|
||||
</div>
|
||||
<div className="loading-container" style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '60vh',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div className="loading-spinner"></div>
|
||||
<p style={{ marginTop: '20px', color: '#666' }}>AI 건강 코치를 준비하는 중...</p>
|
||||
</div>
|
||||
<BottomNav currentPage="chat" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="phone-container">
|
||||
{/* 헤더 */}
|
||||
<div className="header-simple">
|
||||
<div className="title">AI 건강 코치</div>
|
||||
</div>
|
||||
|
||||
{/* 채팅 컨테이너 */}
|
||||
<div className="chat-container">
|
||||
{/* 메시지 영역 */}
|
||||
<div className="chat-messages">
|
||||
{messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`message ${message.type} ${message.className || ''}`}
|
||||
>
|
||||
{message.text}
|
||||
{message.timestamp && (
|
||||
<div className="message-time" style={{
|
||||
fontSize: '11px',
|
||||
color: '#999',
|
||||
marginTop: '4px',
|
||||
textAlign: message.type === 'user' ? 'right' : 'left'
|
||||
}}>
|
||||
{new Date(message.timestamp).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* AI 응답 로딩 표시 */}
|
||||
{isLoading && (
|
||||
<div className="message ai">
|
||||
<div className="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
AI가 답변을 생성하고 있습니다...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 스크롤 위치 참조용 div */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* 입력 영역 */}
|
||||
<div className="chat-input">
|
||||
<div className="input-container">
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="건강에 대해 궁금한 점을 물어보세요..."
|
||||
className="chat-text-input"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className="send-btn"
|
||||
disabled={isLoading || !inputText.trim()}
|
||||
style={{
|
||||
opacity: (isLoading || !inputText.trim()) ? 0.5 : 1,
|
||||
cursor: (isLoading || !inputText.trim()) ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
{isLoading ? '...' : '→'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomNav currentPage="chat" />
|
||||
|
||||
{/* 추가 스타일 */}
|
||||
<style>{`
|
||||
.typing-indicator {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
height: 8px;
|
||||
width: 8px;
|
||||
background: #999;
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes typing {
|
||||
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
|
||||
40% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
border: 3px solid #f3f3f3;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPage;
|
||||
521
src/pages/DashboardPage.js
Normal file
521
src/pages/DashboardPage.js
Normal file
@ -0,0 +1,521 @@
|
||||
// DashboardPage.js - API 데이터 매핑 함수 추가 (기존 코드 수정 최소화)
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Header from '../components/Header';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
|
||||
function DashboardPage({ healthData, healthDiagnosis, goalData }) {
|
||||
const [activeChart, setActiveChart] = useState('bloodPressure');
|
||||
|
||||
// 🔍 받은 props 디버깅
|
||||
console.log('=== DashboardPage Props 디버깅 ===');
|
||||
console.log('1. healthData:', healthData);
|
||||
console.log('2. healthData 타입:', typeof healthData);
|
||||
console.log('3. healthData null 여부:', healthData === null);
|
||||
console.log('4. healthData undefined 여부:', healthData === undefined);
|
||||
console.log('5. healthDiagnosis:', healthDiagnosis);
|
||||
console.log('6. healthDiagnosis 타입:', typeof healthDiagnosis);
|
||||
console.log('7. healthDiagnosis 배열 여부:', Array.isArray(healthDiagnosis));
|
||||
console.log('8. healthDiagnosis 길이:', healthDiagnosis?.length);
|
||||
|
||||
|
||||
// DashboardPage.js 맨 위에 추가
|
||||
console.log('=== DashboardPage에서 받은 실제 healthData ===');
|
||||
console.log('healthData 전체:', healthData);
|
||||
console.log('healthData JSON:', JSON.stringify(healthData, null, 2));
|
||||
|
||||
if (healthData) {
|
||||
console.log('최상위 키들:', Object.keys(healthData));
|
||||
|
||||
// 가능한 모든 하위 구조 확인
|
||||
Object.keys(healthData).forEach(key => {
|
||||
console.log(`${key}:`, healthData[key]);
|
||||
if (typeof healthData[key] === 'object' && healthData[key] !== null) {
|
||||
console.log(`${key}의 키들:`, Object.keys(healthData[key]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ API 데이터를 기존 구조에 맞게 매핑하는 함수
|
||||
const mapApiToExpectedStructure = (apiData) => {
|
||||
if (!apiData) return null;
|
||||
|
||||
// API 구조: userInfo, recentHealthProfile, healthProfiles
|
||||
// 기존 코드 구조: basicInfo, latestRecord, trends
|
||||
|
||||
const userInfo = apiData.userInfo || {};
|
||||
const recentProfile = apiData.recentHealthProfile || {};
|
||||
const profiles = apiData.healthProfiles || [];
|
||||
|
||||
// trends 데이터 생성 (healthProfiles 배열에서)
|
||||
const generateTrends = (profiles) => {
|
||||
if (profiles.length === 0) {
|
||||
// 기본 trends 반환
|
||||
return {
|
||||
bloodPressure: [
|
||||
{ year: "2021", value: "120/80", y: 100 },
|
||||
{ year: "2022", value: "125/82", y: 110 },
|
||||
{ year: "2023", value: "130/85", y: 120 },
|
||||
{ year: "2024", value: "125/83", y: 115 },
|
||||
{ year: "2025", value: "123/81", y: 105 }
|
||||
],
|
||||
cholesterol: [
|
||||
{ year: "2021", value: "200", y: 100 },
|
||||
{ year: "2022", value: "210", y: 110 },
|
||||
{ year: "2023", value: "220", y: 120 },
|
||||
{ year: "2024", value: "225", y: 125 },
|
||||
{ year: "2025", value: "232", y: 130 }
|
||||
],
|
||||
weight: [
|
||||
{ year: "2021", value: "65kg", y: 120 },
|
||||
{ year: "2022", value: "63kg", y: 115 },
|
||||
{ year: "2023", value: "61kg", y: 110 },
|
||||
{ year: "2024", value: "60kg", y: 105 },
|
||||
{ year: "2025", value: "59kg", y: 100 }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
// 실제 프로필 데이터에서 trends 생성
|
||||
const sortedProfiles = profiles.sort((a, b) => a.referenceYear - b.referenceYear).slice(-5);
|
||||
|
||||
return {
|
||||
bloodPressure: sortedProfiles.map((profile, index) => ({
|
||||
year: profile.referenceYear?.toString() || "2024",
|
||||
value: `${profile.systolicBp || 120}/${profile.diastolicBp || 80}`,
|
||||
y: 100 + (index * 10)
|
||||
})),
|
||||
cholesterol: sortedProfiles.map((profile, index) => ({
|
||||
year: profile.referenceYear?.toString() || "2024",
|
||||
value: (profile.totalCholesterol || 200).toString(),
|
||||
y: 100 + (index * 10)
|
||||
})),
|
||||
weight: sortedProfiles.map((profile, index) => ({
|
||||
year: profile.referenceYear?.toString() || "2024",
|
||||
value: `${profile.weight || 70}kg`,
|
||||
y: 100 + (index * 10)
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
basicInfo: {
|
||||
age: userInfo.age,
|
||||
gender_code: userInfo.genderCode || (userInfo.gender === "남성" ? 1 : 2),
|
||||
occupation: userInfo.occupation
|
||||
},
|
||||
latestRecord: {
|
||||
reference_year: recentProfile.referenceYear,
|
||||
height: recentProfile.height,
|
||||
weight: recentProfile.weight,
|
||||
waist_circumference: recentProfile.waistCircumference,
|
||||
visual_acuity_left: recentProfile.visualAcuityLeft,
|
||||
visual_acuity_right: recentProfile.visualAcuityRight,
|
||||
hearing_left: recentProfile.hearingLeft,
|
||||
hearing_right: recentProfile.hearingRight,
|
||||
systolic_bp: recentProfile.systolicBp,
|
||||
diastolic_bp: recentProfile.diastolicBp,
|
||||
fasting_glucose: recentProfile.fastingGlucose,
|
||||
total_cholesterol: recentProfile.totalCholesterol,
|
||||
triglyceride: recentProfile.triglyceride,
|
||||
hdl_cholesterol: recentProfile.hdlCholesterol,
|
||||
ldl_cholesterol: recentProfile.ldlCholesterol,
|
||||
hemoglobin: recentProfile.hemoglobin,
|
||||
urine_protein: recentProfile.urineProtein,
|
||||
serum_creatinine: recentProfile.serumCreatinine,
|
||||
ast: recentProfile.ast,
|
||||
alt: recentProfile.alt,
|
||||
gamma_gtp: recentProfile.gammaGtp,
|
||||
smoking_status: recentProfile.smokingStatus,
|
||||
drinking_status: recentProfile.drinkingStatus
|
||||
},
|
||||
trends: generateTrends(profiles)
|
||||
};
|
||||
};
|
||||
|
||||
// healthData 세부 구조 확인
|
||||
if (healthData) {
|
||||
console.log('9. healthData 키들:', Object.keys(healthData));
|
||||
console.log('10. healthData.basicInfo:', healthData.basicInfo);
|
||||
console.log('11. healthData.latestRecord:', healthData.latestRecord);
|
||||
console.log('12. healthData.trends:', healthData.trends);
|
||||
} else {
|
||||
console.log('9. healthData가 null/undefined이므로 기본값 사용됨');
|
||||
}
|
||||
|
||||
// healthDiagnosis 내용 확인
|
||||
if (healthDiagnosis && healthDiagnosis.length > 0) {
|
||||
console.log('13. healthDiagnosis 내용:', healthDiagnosis);
|
||||
} else {
|
||||
console.log('13. healthDiagnosis가 비어있으므로 기본값 사용됨');
|
||||
}
|
||||
|
||||
// healthData가 없으면 기본값 사용(테스트용)
|
||||
const defaultHealthData = {
|
||||
basicInfo: { age: 32, gender_code: 1, occupation: "IT개발" },
|
||||
latestRecord: {
|
||||
reference_year: 2024,
|
||||
height: 175,
|
||||
weight: 70,
|
||||
waist_circumference: 85,
|
||||
visual_acuity_left: 1.0,
|
||||
visual_acuity_right: 1.0,
|
||||
hearing_left: 25,
|
||||
hearing_right: 25,
|
||||
systolic_bp: 140,
|
||||
diastolic_bp: 90,
|
||||
fasting_glucose: 95,
|
||||
total_cholesterol: 220,
|
||||
triglyceride: 150,
|
||||
hdl_cholesterol: 50,
|
||||
ldl_cholesterol: 130,
|
||||
hemoglobin: 14.5,
|
||||
urine_protein: 0,
|
||||
serum_creatinine: 1.0,
|
||||
ast: 25,
|
||||
alt: 30,
|
||||
gamma_gtp: 25,
|
||||
smoking_status: 0,
|
||||
drinking_status: 0
|
||||
},
|
||||
trends: {
|
||||
bloodPressure: [
|
||||
{ year: "2020", value: "145/88", y: 120 },
|
||||
{ year: "2021", value: "142/85", y: 100 },
|
||||
{ year: "2022", value: "144/87", y: 110 },
|
||||
{ year: "2023", value: "141/89", y: 95 },
|
||||
{ year: "2024", value: "140/90", y: 105 }
|
||||
],
|
||||
cholesterol: [
|
||||
{ year: "2020", value: "240", y: 100 },
|
||||
{ year: "2021", value: "235", y: 90 },
|
||||
{ year: "2022", value: "230", y: 85 },
|
||||
{ year: "2023", value: "225", y: 75 },
|
||||
{ year: "2024", value: "220", y: 80 }
|
||||
],
|
||||
weight: [
|
||||
{ year: "2020", value: "75kg", y: 110 },
|
||||
{ year: "2021", value: "73kg", y: 105 },
|
||||
{ year: "2022", value: "71kg", y: 100 },
|
||||
{ year: "2023", value: "70kg", y: 98 },
|
||||
{ year: "2024", value: "68kg", y: 95 }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// 기본 건강진단 3줄 요약 (LoadingPage와 동일)
|
||||
const defaultHealthDiagnosis = [
|
||||
"혈압과 콜레스테롤 수치가 주의 범위에 있어 적극적인 관리가 필요해요!",
|
||||
"IT 직업 특성상 장시간 앉아있는 생활 패턴으로 인한 건강 리스크가 보여요!",
|
||||
"하지만 지금 시작하면 충분히 개선 가능하니 함께 건강해져요!"
|
||||
];
|
||||
|
||||
// 🔍 실제 사용할 데이터 결정 과정 디버깅
|
||||
// ✅ API 데이터가 있으면 매핑해서 사용, 없으면 기본값 사용
|
||||
const data = healthData ? mapApiToExpectedStructure(healthData) : defaultHealthData;
|
||||
const diagnosis = healthDiagnosis || defaultHealthDiagnosis;
|
||||
|
||||
console.log('14. 최종 사용할 data:', data);
|
||||
console.log('15. 최종 사용할 diagnosis:', diagnosis);
|
||||
console.log('16. 기본값 사용 여부 - healthData:', !healthData);
|
||||
console.log('17. 기본값 사용 여부 - healthDiagnosis:', !healthDiagnosis || healthDiagnosis.length === 0);
|
||||
console.log('=== DashboardPage 디버깅 끝 ===');
|
||||
|
||||
// BMI 계산 함수
|
||||
const calculateBMI = (height, weight) => {
|
||||
if (!height || !weight) return 24.2;
|
||||
const heightInM = height / 100;
|
||||
const bmi = weight / (heightInM * heightInM);
|
||||
return Math.round(bmi * 10) / 10;
|
||||
};
|
||||
|
||||
// BMI 상태 판정
|
||||
const getBMIStatus = (bmi) => {
|
||||
if (bmi < 18.5) return { text: "저체중", color: "#3498db" };
|
||||
if (bmi < 23) return { text: "정상", color: "#27ae60" };
|
||||
if (bmi < 25) return { text: "과체중", color: "#f39c12" };
|
||||
return { text: "비만", color: "#e74c3c" };
|
||||
};
|
||||
|
||||
// 혈압 상태 판정
|
||||
const getBloodPressureStatus = (systolic, diastolic) => {
|
||||
if (systolic >= 140 || diastolic >= 90) {
|
||||
return { color: "#e74c3c" }; // 고혈압
|
||||
} else if (systolic >= 130 || diastolic >= 80) {
|
||||
return { color: "#f39c12" }; // 주의
|
||||
}
|
||||
return { color: "#27ae60" }; // 정상
|
||||
};
|
||||
|
||||
// 콜레스테롤 상태 판정
|
||||
const getCholesterolStatus = (cholesterol) => {
|
||||
if (cholesterol >= 240) return { color: "#e74c3c" };
|
||||
if (cholesterol >= 200) return { color: "#f39c12" };
|
||||
return { color: "#27ae60" };
|
||||
};
|
||||
|
||||
// 혈당 상태 판정
|
||||
const getGlucoseStatus = (glucose) => {
|
||||
if (glucose >= 126) return { color: "#e74c3c" };
|
||||
if (glucose >= 100) return { color: "#f39c12" };
|
||||
return { color: "#27ae60" };
|
||||
};
|
||||
|
||||
const chartData = {
|
||||
bloodPressure: {
|
||||
label: '혈압',
|
||||
color: '#667eea',
|
||||
data: data.trends?.bloodPressure || defaultHealthData.trends.bloodPressure
|
||||
},
|
||||
cholesterol: {
|
||||
label: '콜레스테롤',
|
||||
color: '#f39c12',
|
||||
data: data.trends?.cholesterol || defaultHealthData.trends.cholesterol
|
||||
},
|
||||
weight: {
|
||||
label: '체중',
|
||||
color: '#e74c3c',
|
||||
data: data.trends?.weight || defaultHealthData.trends.weight
|
||||
}
|
||||
};
|
||||
|
||||
const handleChartChange = (chartType) => {
|
||||
setActiveChart(chartType);
|
||||
};
|
||||
|
||||
const renderChart = () => {
|
||||
const chartInfo = chartData[activeChart];
|
||||
const points = chartInfo.data.map((item, index) => `${20 + index * 60},${item.y}`).join(' ');
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<svg width="100%" height="100%">
|
||||
{/* 격자 라인 */}
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="30" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 30" fill="none" stroke="#e9ecef" strokeWidth="1"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
{/* 라인 차트 */}
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke={chartInfo.color}
|
||||
strokeWidth="3"
|
||||
points={points}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* 데이터 포인트 */}
|
||||
{chartInfo.data.map((item, index) => (
|
||||
<g key={index}>
|
||||
<circle
|
||||
cx={20 + index * 60}
|
||||
cy={item.y}
|
||||
r="4"
|
||||
fill={chartInfo.color}
|
||||
/>
|
||||
<text
|
||||
x={20 + index * 60}
|
||||
y="160"
|
||||
textAnchor="middle"
|
||||
fontSize="10"
|
||||
fill="#666"
|
||||
>
|
||||
{item.year}
|
||||
</text>
|
||||
<text
|
||||
x={20 + index * 60}
|
||||
y={item.y - 5}
|
||||
textAnchor="middle"
|
||||
fontSize="9"
|
||||
fill={chartInfo.color}
|
||||
>
|
||||
{item.value}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 실제 데이터 계산
|
||||
const bmi = calculateBMI(data.latestRecord?.height, data.latestRecord?.weight);
|
||||
const bmiStatus = getBMIStatus(bmi);
|
||||
const bloodPressureStatus = getBloodPressureStatus(data.latestRecord?.systolic_bp, data.latestRecord?.diastolic_bp);
|
||||
const cholesterolStatus = getCholesterolStatus(data.latestRecord?.total_cholesterol);
|
||||
const glucoseStatus = getGlucoseStatus(data.latestRecord?.fasting_glucose);
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<Header title="건강 대시보드" showBack backPath="/chat" />
|
||||
|
||||
<div className="content dashboard-content">
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>기본 정보</h3>
|
||||
<div className="metric">
|
||||
<span>나이/성별</span>
|
||||
<span>
|
||||
{data.basicInfo?.age || 32}세 / {data.basicInfo?.gender_code === 1 ? "남성" : data.basicInfo?.gender_code === 2 ? "여성" : "남성"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>직업군</span>
|
||||
<span>{data.basicInfo?.occupation || "IT개발"}</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<span>BMI</span>
|
||||
<span style={{ color: bmiStatus.color }}>{bmi} ({bmiStatus.text})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>
|
||||
최근 검진 결과
|
||||
<span style={{ fontSize: '14px', fontWeight: 'normal', color: '#666', marginLeft: '8px' }}>
|
||||
({data.latestRecord?.reference_year || 2024}년)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="metric">
|
||||
<span>혈압</span>
|
||||
<span style={{ color: bloodPressureStatus.color }}>
|
||||
{data.latestRecord?.systolic_bp || 140}/{data.latestRecord?.diastolic_bp || 90} mmHg
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>신장/체중</span>
|
||||
<span>
|
||||
{data.latestRecord?.height || 175}cm / {data.latestRecord?.weight || 70}kg
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>허리둘레</span>
|
||||
<span>{data.latestRecord?.waist_circumference || 85}cm</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>시력 (좌/우)</span>
|
||||
<span>
|
||||
{(data.latestRecord?.visual_acuity_left || 1.0).toFixed(1)} / {(data.latestRecord?.visual_acuity_right || 1.0).toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>청력 (좌/우)</span>
|
||||
<span>
|
||||
{data.latestRecord?.hearing_left || 25}dB / {data.latestRecord?.hearing_right || 25}dB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>공복혈당</span>
|
||||
<span style={{ color: glucoseStatus.color }}>
|
||||
{data.latestRecord?.fasting_glucose || 95} mg/dL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>총콜레스테롤</span>
|
||||
<span style={{ color: cholesterolStatus.color }}>
|
||||
{data.latestRecord?.total_cholesterol || 220} mg/dL
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>트리글리세라이드</span>
|
||||
<span>{data.latestRecord?.triglyceride || 150} mg/dL</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>HDL콜레스테롤</span>
|
||||
<span>{data.latestRecord?.hdl_cholesterol || 50} mg/dL</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>LDL콜레스테롤</span>
|
||||
<span>{data.latestRecord?.ldl_cholesterol || 130} mg/dL</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>혈색소</span>
|
||||
<span>{(data.latestRecord?.hemoglobin || 14.5).toFixed(1)} g/dL</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>요단백</span>
|
||||
<span>{data.latestRecord?.urine_protein === 1 ? "양성" : "음성"}</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>혈청크레아티닌</span>
|
||||
<span>{(data.latestRecord?.serum_creatinine || 1.0).toFixed(1)} mg/dL</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>AST/GOT</span>
|
||||
<span>{data.latestRecord?.ast || 25} IU/L</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>ALT/GPT</span>
|
||||
<span>{data.latestRecord?.alt || 30} IU/L</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>감마지티피</span>
|
||||
<span>{data.latestRecord?.gamma_gtp || 25} IU/L</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>흡연상태</span>
|
||||
<span>{data.latestRecord?.smoking_status === 1 ? "흡연" : "비흡연"}</span>
|
||||
</div>
|
||||
|
||||
<div className="metric">
|
||||
<span>음주여부</span>
|
||||
<span>{data.latestRecord?.drinking_status === 1 ? "음주" : "금주"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>5년간 추이</h3>
|
||||
|
||||
<div className="chart-tabs">
|
||||
<button
|
||||
className={`chart-tab ${activeChart === 'bloodPressure' ? 'active' : ''}`}
|
||||
onClick={() => handleChartChange('bloodPressure')}
|
||||
>
|
||||
혈압
|
||||
</button>
|
||||
<button
|
||||
className={`chart-tab ${activeChart === 'cholesterol' ? 'active' : ''}`}
|
||||
onClick={() => handleChartChange('cholesterol')}
|
||||
>
|
||||
콜레스테롤
|
||||
</button>
|
||||
<button
|
||||
className={`chart-tab ${activeChart === 'weight' ? 'active' : ''}`}
|
||||
onClick={() => handleChartChange('weight')}
|
||||
>
|
||||
체중
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{renderChart()}
|
||||
|
||||
{/* 추가 여백 */}
|
||||
<div style={{ height: '40px' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
556
src/pages/GoalsPage.js
Normal file
556
src/pages/GoalsPage.js
Normal file
@ -0,0 +1,556 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import BottomNav from '../components/BottomNav';
|
||||
|
||||
function GoalsPage({ goalData, setGoalData }) {
|
||||
const navigate = useNavigate();
|
||||
const [updating, setUpdating] = useState({});
|
||||
const [missionData, setMissionData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 안전한 goalData 접근
|
||||
const safeGoalData = goalData || {};
|
||||
|
||||
// goalType을 missionId로 변환하는 함수
|
||||
const getMissionId = (goalType) => {
|
||||
const missionIdMap = {
|
||||
'water': '1101',
|
||||
'walk': '1750062662886002',
|
||||
'stretch': '1750062662866001',
|
||||
'snack': 'mission_snack_001',
|
||||
'sleep': 'mission_sleep_001'
|
||||
};
|
||||
return missionIdMap[goalType] || goalType;
|
||||
};
|
||||
|
||||
// API 미션 데이터를 goalData 형식으로 변환 (API 문서 기준)
|
||||
const convertMissionDataToGoalData = (dailyMissions) => {
|
||||
const converted = {};
|
||||
const usedGoalTypes = new Set(); // 이미 사용된 goalType 추적
|
||||
|
||||
dailyMissions.forEach((mission, index) => {
|
||||
let goalType = 'unknown';
|
||||
const title = mission.title.toLowerCase();
|
||||
|
||||
if (title.includes('물') || title.includes('water')) {
|
||||
goalType = 'water';
|
||||
} else if (title.includes('걷기') || title.includes('산책') || title.includes('walk')) {
|
||||
goalType = 'walk';
|
||||
} else if (title.includes('스트레칭') || title.includes('stretch')) {
|
||||
goalType = 'stretch';
|
||||
} else if (title.includes('간식') || title.includes('snack')) {
|
||||
goalType = 'snack';
|
||||
} else if (title.includes('수면') || title.includes('sleep')) {
|
||||
goalType = 'sleep';
|
||||
} else {
|
||||
goalType = 'default';
|
||||
}
|
||||
|
||||
// 중복 체크: 이미 사용된 goalType이면 고유한 키 생성
|
||||
if (usedGoalTypes.has(goalType)) {
|
||||
goalType = `${goalType}_${mission.missionId}`;
|
||||
console.log(`### 중복 발견! 새로운 goalType: ${goalType}`);
|
||||
}
|
||||
|
||||
usedGoalTypes.add(goalType);
|
||||
console.log(`### 최종 goalType: ${goalType} for mission: ${mission.title}`);
|
||||
|
||||
// API 문서 형식에 맞춘 필드 매핑
|
||||
converted[goalType] = {
|
||||
missionId: mission.missionId,
|
||||
current: mission.completedCount || 0,
|
||||
target: mission.targetCount || 1,
|
||||
goalName: mission.title,
|
||||
description: mission.description,
|
||||
status: mission.status,
|
||||
completedToday: mission.completedToday,
|
||||
streakDays: mission.streakDays,
|
||||
completionRate: mission.completionRate,
|
||||
targetAchieved: mission.targetAchieved,
|
||||
nextReminderTime: mission.nextReminderTime
|
||||
};
|
||||
});
|
||||
|
||||
return converted;
|
||||
};
|
||||
|
||||
|
||||
// localStorage에 일일 성과 저장 (모든 미션 포함, 정확한 달성률)
|
||||
const saveDailyProgressToLocal = (currentGoalData = null) => {
|
||||
try {
|
||||
const dataToSave = currentGoalData || goalData;
|
||||
if (!dataToSave || Object.keys(dataToSave).length === 0) {
|
||||
console.log('⚠️ goalData가 없어서 로컬 저장 건너뛰기');
|
||||
return;
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const goals = Object.values(dataToSave);
|
||||
const total = goals.length;
|
||||
const completed = goals.filter(goal => goal && goal.current >= goal.target).length;
|
||||
const rate = Math.round((completed / total) * 100);
|
||||
|
||||
const dailyData = {
|
||||
date: today,
|
||||
progress: { rate, completed, total },
|
||||
goals: Object.keys(dataToSave).map(goalType => {
|
||||
const goal = dataToSave[goalType];
|
||||
const current = goal.current || 0;
|
||||
const target = goal.target || 1;
|
||||
const isCompleted = current >= target;
|
||||
|
||||
// 정확한 달성률 계산: 완료했으면 100%, 아니면 current/target 비율
|
||||
const accurateRate = isCompleted ? 100 : Math.round((current / target) * 100);
|
||||
|
||||
console.log(`📊 ${goal.goalName} 달성률 계산:`, {
|
||||
현재: current,
|
||||
목표: target,
|
||||
완료여부: isCompleted,
|
||||
달성률: accurateRate + '%'
|
||||
});
|
||||
|
||||
return {
|
||||
type: goalType,
|
||||
name: goal.goalName || goalType,
|
||||
missionId: goal.missionId,
|
||||
current: current,
|
||||
target: target,
|
||||
completed: isCompleted,
|
||||
rate: accurateRate, // 정확한 달성률 (0%, 50%, 100% 등)
|
||||
needsSync: goal.needsSync || false
|
||||
};
|
||||
}),
|
||||
timestamp: new Date().toISOString(),
|
||||
allMissionsIncluded: true // 중요: 모든 미션이 포함되었음을 표시
|
||||
};
|
||||
|
||||
const existingData = JSON.parse(localStorage.getItem('dailyMissionHistory') || '[]');
|
||||
const updatedData = existingData.filter(item =>
|
||||
new Date(item.date) >= new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
const todayIndex = updatedData.findIndex(item => item.date === today);
|
||||
if (todayIndex >= 0) {
|
||||
updatedData[todayIndex] = dailyData;
|
||||
} else {
|
||||
updatedData.push(dailyData);
|
||||
}
|
||||
|
||||
localStorage.setItem('dailyMissionHistory', JSON.stringify(updatedData));
|
||||
console.log('💾 일일 진행률 로컬 저장 완료 (정확한 달성률 포함):', dailyData);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('⚠️ 로컬 저장 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 미션 진행 API 호출 (수정된 데이터 형식)
|
||||
const recordMissionProgress = async (goalType, currentValue) => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
|
||||
localStorage.getItem('userId') || '1';
|
||||
|
||||
const goalInfo = safeGoalData[goalType];
|
||||
const missionId = goalInfo?.missionId || getMissionId(goalType);
|
||||
const isCompleted = currentValue >= (goalInfo?.target || 1);
|
||||
|
||||
// API 문서에 맞는 정확한 데이터 구조
|
||||
const requestData = {
|
||||
memberSerialNumber: parseInt(memberSerialNumber),
|
||||
completed: isCompleted,
|
||||
completedAt: new Date().toISOString(),
|
||||
notes: `${goalInfo?.goalName || goalType} 목표 진행`,
|
||||
incrementCount: 1,
|
||||
completionType: "INCREMENT",
|
||||
incrementMode: true,
|
||||
fullCompleteMode: isCompleted
|
||||
// updateHistory, currentProgress, targetProgress, achievementRate 제거
|
||||
// - API 문서에 없는 필드들
|
||||
};
|
||||
|
||||
console.log(`🚀 ${goalType} 미션 완료 API 요청:`, requestData);
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/${missionId}/complete`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ ${goalType} 미션 완료 API 오류 (${response.status}):`, errorText);
|
||||
|
||||
// 상세 에러 로그
|
||||
if (response.status === 400) {
|
||||
console.error('🔍 Bad Request - 요청 데이터:', requestData);
|
||||
} else if (response.status === 404) {
|
||||
console.error('🔍 Not Found - 미션 ID가 존재하지 않음:', missionId);
|
||||
} else if (response.status === 500) {
|
||||
console.error('🔍 Internal Server Error - 서버 내부 오류');
|
||||
}
|
||||
|
||||
throw new Error(`미션 완료 기록 실패 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log(`✅ ${goalType} 미션 완료 API 성공:`, result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`💥 ${goalType} 미션 완료 API 호출 실패:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 완료 메시지 표시
|
||||
const showCompletionMessage = (goalType, apiResponse = null) => {
|
||||
const goalInfo = safeGoalData[goalType];
|
||||
const goalName = goalInfo?.goalName || goalType;
|
||||
|
||||
let message = '';
|
||||
if (apiResponse && apiResponse.data && apiResponse.data.achievementMessage) {
|
||||
message = apiResponse.data.achievementMessage;
|
||||
}
|
||||
if (!message) {
|
||||
message = `🎉 ${goalName} 목표 달성! 잘하셨어요!`;
|
||||
}
|
||||
|
||||
const notification = document.createElement('div');
|
||||
notification.style.cssText = `
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||
background: #27ae60; color: white; padding: 16px 24px; border-radius: 8px;
|
||||
z-index: 1000; font-weight: bold; text-align: center;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-width: 300px; word-wrap: break-word;
|
||||
`;
|
||||
notification.textContent = message;
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.body.contains(notification)) {
|
||||
document.body.removeChild(notification);
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// 목표 업데이트 (간소화된 에러 처리)
|
||||
const updateGoal = async (goalType, increment = 1) => {
|
||||
const currentGoal = safeGoalData[goalType] || { current: 0, target: 1 };
|
||||
|
||||
if (updating[goalType] || currentGoal.current >= currentGoal.target) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCurrent = Math.min(currentGoal.current + increment, currentGoal.target);
|
||||
setUpdating(prev => ({ ...prev, [goalType]: true }));
|
||||
|
||||
// 먼저 로컬 상태 업데이트 (즉각적인 UI 반응)
|
||||
const updatedGoalData = {
|
||||
...goalData,
|
||||
[goalType]: {
|
||||
...goalData[goalType],
|
||||
current: newCurrent,
|
||||
completedToday: newCurrent >= currentGoal.target
|
||||
}
|
||||
};
|
||||
|
||||
setGoalData(updatedGoalData);
|
||||
|
||||
if (newCurrent >= currentGoal.target) {
|
||||
showCompletionMessage(goalType);
|
||||
}
|
||||
|
||||
try {
|
||||
// 서버 API 호출 시도
|
||||
const apiResponse = await recordMissionProgress(goalType, newCurrent);
|
||||
console.log(`✅ ${goalType} 서버 동기화 성공:`, apiResponse);
|
||||
|
||||
// 성공 시 완료 메시지 업데이트
|
||||
if (newCurrent >= currentGoal.target && apiResponse.data?.achievementMessage) {
|
||||
showCompletionMessage(goalType, apiResponse);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ ${goalType} 서버 동기화 실패 (로컬은 정상 업데이트됨):`, error.message);
|
||||
|
||||
// 동기화 필요 표시
|
||||
const failedSyncGoalData = {
|
||||
...updatedGoalData,
|
||||
[goalType]: {
|
||||
...updatedGoalData[goalType],
|
||||
needsSync: true
|
||||
}
|
||||
};
|
||||
setGoalData(failedSyncGoalData);
|
||||
}
|
||||
|
||||
// 항상 로컬 저장 수행
|
||||
setTimeout(() => {
|
||||
saveDailyProgressToLocal(updatedGoalData);
|
||||
}, 300);
|
||||
|
||||
setUpdating(prev => ({ ...prev, [goalType]: false }));
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 한 번만 실행 (의도적으로 의존성 제외)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useEffect(() => {
|
||||
const loadActiveMissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('authToken');
|
||||
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
|
||||
localStorage.getItem('userId') || '1';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
memberSerialNumber: memberSerialNumber
|
||||
});
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/active?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('활성 미션 조회 성공:', result);
|
||||
|
||||
if (result.success && result.data) {
|
||||
setMissionData(result.data);
|
||||
|
||||
const convertedGoalData = convertMissionDataToGoalData(result.data.dailyMissions);
|
||||
setGoalData(convertedGoalData);
|
||||
|
||||
// 초기 데이터 처리 (간소화)
|
||||
setTimeout(() => {
|
||||
if (convertedGoalData && Object.keys(convertedGoalData).length > 0) {
|
||||
console.log('📱 초기 데이터 로컬 저장');
|
||||
// 로컬 저장만 수행 (서버 동기화는 사용자 액션 시에만)
|
||||
saveDailyProgressToLocal(convertedGoalData);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('활성 미션 조회 오류:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadActiveMissions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // 빈 배열로 한 번만 실행
|
||||
|
||||
const calculateProgress = () => {
|
||||
if (!goalData || Object.keys(goalData).length === 0) return 0;
|
||||
const totalGoals = Object.keys(goalData).length;
|
||||
const completedGoals = Object.values(goalData).filter(
|
||||
goal => goal && goal.current >= goal.target
|
||||
).length;
|
||||
return Math.round((completedGoals / totalGoals) * 100);
|
||||
};
|
||||
|
||||
const getCompletedCount = () => {
|
||||
if (!goalData || Object.keys(goalData).length === 0) return 0;
|
||||
return Object.values(goalData).filter(
|
||||
goal => goal && goal.current >= goal.target
|
||||
).length;
|
||||
};
|
||||
|
||||
const generateGoalsFromData = () => {
|
||||
const defaultGoals = [
|
||||
{ id: 'water', icon: '💧', title: '하루 8잔 물마시기', unit: '잔' },
|
||||
{ id: 'walk', icon: '🚶♂️', title: '점심시간 10분 산책', unit: '회' },
|
||||
{ id: 'stretch', icon: '🧘♀️', title: '5분 목&어깨 스트레칭', unit: '회' },
|
||||
{ id: 'snack', icon: '🥗', title: '하루 1회 건강간식', unit: '회' },
|
||||
{ id: 'sleep', icon: '😴', title: '규칙적인 수면시간', unit: '회' }
|
||||
];
|
||||
|
||||
if (Object.keys(safeGoalData).length > 0) {
|
||||
return Object.keys(safeGoalData).map(goalType => {
|
||||
const goalInfo = safeGoalData[goalType];
|
||||
|
||||
// 기본 타입과 확장 타입 구분
|
||||
const baseType = goalType.split('_')[0]; // 'stretch_1234' → 'stretch'
|
||||
const defaultGoal = defaultGoals.find(g => g.id === baseType);
|
||||
|
||||
return {
|
||||
id: goalType,
|
||||
icon: defaultGoal?.icon || '🎯',
|
||||
title: goalInfo.goalName || defaultGoal?.title || goalType,
|
||||
unit: defaultGoal?.unit || '회'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return defaultGoals;
|
||||
};
|
||||
|
||||
const goals = generateGoalsFromData();
|
||||
const progress = calculateProgress();
|
||||
const completedCount = getCompletedCount();
|
||||
const totalGoals = goals.length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="goals-page">
|
||||
<Header title="목표 관리" showBack backPath="/chat" />
|
||||
<div className="content" style={{ paddingBottom: '80px' }}>
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<p>목표를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="goals-page">
|
||||
<Header title="목표 관리" showBack backPath="/chat" />
|
||||
|
||||
<div className="content" style={{ paddingBottom: '80px' }}>
|
||||
<div className="dashboard-card">
|
||||
<div className="section-header">
|
||||
<h3>오늘의 미션</h3>
|
||||
<button
|
||||
className="history-btn"
|
||||
onClick={() => navigate('/history')}
|
||||
>
|
||||
이력
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar">
|
||||
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
|
||||
</div>
|
||||
<p style={{ textAlign: 'center', margin: '8px 0', color: '#666', fontSize: '14px' }}>
|
||||
{completedCount}/{totalGoals} 완료 ({progress}%)
|
||||
</p>
|
||||
|
||||
{missionData?.motivationalMessage && (
|
||||
<div style={{
|
||||
marginTop: '12px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f0f8f0',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#27ae60' }}>
|
||||
{missionData.motivationalMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{missionData?.currentStreak > 0 && (
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
textAlign: 'center',
|
||||
fontSize: '13px',
|
||||
color: '#667eea'
|
||||
}}>
|
||||
🔥 연속 {missionData.currentStreak}일 달성 중!
|
||||
{missionData.bestStreak && (
|
||||
<span style={{ color: '#666', marginLeft: '8px' }}>
|
||||
(최고 기록: {missionData.bestStreak}일)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{goals.map(goal => {
|
||||
const data = safeGoalData[goal.id] || { current: 0, target: 1 };
|
||||
const isCompleted = data.current >= data.target;
|
||||
const isUpdating = updating[goal.id];
|
||||
|
||||
return (
|
||||
<div key={goal.id} className="todo-item">
|
||||
<div className="todo-text">
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: '8px' }}>{goal.icon}</span>
|
||||
<div>
|
||||
<div>{goal.title}</div>
|
||||
{data.description && (
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
|
||||
{data.description.length > 50 ?
|
||||
`${data.description.substring(0, 50)}...` :
|
||||
data.description
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{data.streakDays > 0 && (
|
||||
<div style={{ fontSize: '11px', color: '#f39c12', marginTop: '2px' }}>
|
||||
🔥 {data.streakDays}일 연속
|
||||
</div>
|
||||
)}
|
||||
{data.needsSync && (
|
||||
<div style={{ fontSize: '10px', color: '#e74c3c', marginTop: '2px' }}>
|
||||
📡 동기화 대기 중
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="goal-counter">
|
||||
<span className="goal-count">
|
||||
{data.current}/{data.target}{goal.unit}
|
||||
</span>
|
||||
<button
|
||||
className={`goal-btn ${isCompleted ? 'completed' : ''} ${isUpdating ? 'updating' : ''}`}
|
||||
onClick={() => updateGoal(goal.id)}
|
||||
disabled={isCompleted || isUpdating}
|
||||
>
|
||||
{isUpdating ? '...' : isCompleted ? '✓' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{completedCount === totalGoals && totalGoals > 0 && (
|
||||
<div className="achievement-banner">
|
||||
<h3>🎉 오늘의 모든 미션 완료!</h3>
|
||||
<p>정말 대단해요! 건강한 하루를 보내셨네요!</p>
|
||||
{missionData?.completionRate === 100 && (
|
||||
<p style={{ fontSize: '14px', color: '#27ae60', marginTop: '8px' }}>
|
||||
달성률 100%! 완벽한 하루였습니다! 👏
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{goals.length === 0 && (
|
||||
<div className="dashboard-card">
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<p style={{ color: '#666' }}>설정된 미션이 없습니다.</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => navigate('/mission')}
|
||||
style={{ marginTop: '16px' }}
|
||||
>
|
||||
새 미션 설정하기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GoalsPage;
|
||||
630
src/pages/HistoryPage.js
Normal file
630
src/pages/HistoryPage.js
Normal file
@ -0,0 +1,630 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Header from '../components/Header';
|
||||
|
||||
function HistoryPage({ goalData }) { // ✅ goalData props 추가
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('week');
|
||||
const [historyData, setHistoryData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 미션 아이콘 헬퍼 함수
|
||||
const getMissionIcon = useCallback((title) => {
|
||||
if (!title) return '📝';
|
||||
|
||||
const titleLower = title.toLowerCase();
|
||||
if (titleLower.includes('물') || titleLower.includes('water')) return '💧';
|
||||
if (titleLower.includes('걷기') || titleLower.includes('산책') || titleLower.includes('walk')) return '🚶♂️';
|
||||
if (titleLower.includes('스트레칭') || titleLower.includes('stretch')) return '🧘♀️';
|
||||
if (titleLower.includes('간식') || titleLower.includes('snack') || titleLower.includes('식사')) return '🥗';
|
||||
if (titleLower.includes('수면') || titleLower.includes('sleep')) return '😴';
|
||||
if (titleLower.includes('운동') || titleLower.includes('exercise')) return '💪';
|
||||
if (titleLower.includes('명상') || titleLower.includes('meditation')) return '🧘';
|
||||
|
||||
return '📝';
|
||||
}, []);
|
||||
|
||||
// 진행률 색상 헬퍼 함수
|
||||
const getProgressColor = useCallback((rate) => {
|
||||
if (rate >= 80) return '#27ae60';
|
||||
if (rate >= 60) return '#f39c12';
|
||||
return '#e74c3c';
|
||||
}, []);
|
||||
|
||||
// 상태 메시지 헬퍼 함수
|
||||
const getStatusMessage = useCallback((rate) => {
|
||||
if (rate >= 80) return '우수';
|
||||
if (rate >= 60) return '보통';
|
||||
return '개선필요';
|
||||
}, []);
|
||||
|
||||
// ✅ 현재 GoalsPage 데이터를 기반으로 즉시 달성률 계산
|
||||
const getCurrentAchievementData = useCallback(() => {
|
||||
console.log('🎯 현재 목표 데이터 기반 달성률 계산:', goalData);
|
||||
|
||||
if (!goalData || Object.keys(goalData).length === 0) {
|
||||
console.log('⚠️ goalData 없음');
|
||||
return null;
|
||||
}
|
||||
|
||||
const goals = Object.keys(goalData);
|
||||
const totalGoals = goals.length;
|
||||
const completedGoals = goals.filter(goalType => {
|
||||
const goal = goalData[goalType];
|
||||
return goal && goal.current >= goal.target;
|
||||
}).length;
|
||||
|
||||
const overallRate = Math.round((completedGoals / totalGoals) * 100);
|
||||
|
||||
// goalStats 생성
|
||||
const goalStats = goals.map(goalType => {
|
||||
const goal = goalData[goalType];
|
||||
const current = goal?.current || 0;
|
||||
const target = goal?.target || 1;
|
||||
const rate = Math.round((current / target) * 100);
|
||||
|
||||
return {
|
||||
goalName: goal?.goalName || goalType,
|
||||
icon: getMissionIcon(goal?.goalName || goalType),
|
||||
rate: Math.min(rate, 100), // 100% 초과 방지
|
||||
completed: current >= target ? 1 : 0,
|
||||
total: 1,
|
||||
current,
|
||||
target
|
||||
};
|
||||
});
|
||||
|
||||
console.log('📊 계산된 즉시 달성률:', {
|
||||
overallRate,
|
||||
completedGoals,
|
||||
totalGoals,
|
||||
goalStats
|
||||
});
|
||||
|
||||
return {
|
||||
overallRate,
|
||||
goalStats,
|
||||
totalMissions: totalGoals,
|
||||
averageCompletionRate: overallRate,
|
||||
bestPerformingMission: goalStats.length > 0 ?
|
||||
[...goalStats].sort((a, b) => b.rate - a.rate)[0].goalName : null,
|
||||
improvementNeeded: goalStats.find(g => g.rate < 60)?.goalName || null,
|
||||
bestStreak: 0,
|
||||
insights: [],
|
||||
chartData: null
|
||||
};
|
||||
}, [goalData, getMissionIcon]);
|
||||
|
||||
// 기간에 따른 날짜 범위 계산
|
||||
const getDateRange = useCallback((period) => {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
|
||||
if (period === 'week') {
|
||||
startDate.setDate(endDate.getDate() - 7);
|
||||
} else {
|
||||
startDate.setDate(endDate.getDate() - 30);
|
||||
}
|
||||
|
||||
return {
|
||||
startDate: startDate.toISOString().split('T')[0],
|
||||
endDate: endDate.toISOString().split('T')[0]
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 서버 데이터를 UI 형태로 변환
|
||||
const transformServerData = useCallback((serverData) => {
|
||||
console.log('🔄 서버 데이터 변환:', serverData);
|
||||
|
||||
const goalStats = serverData.missionStats?.map(mission => ({
|
||||
goalName: mission.title,
|
||||
icon: getMissionIcon(mission.title),
|
||||
rate: Math.round(mission.achievementRate || 0),
|
||||
completed: mission.completedDays || 0,
|
||||
total: mission.totalDays || 0
|
||||
})) || [];
|
||||
|
||||
// 최고 성과와 개선 필요 항목 찾기
|
||||
let bestPerformingMission = null;
|
||||
let improvementNeeded = null;
|
||||
|
||||
if (goalStats.length > 0) {
|
||||
const sortedByRate = [...goalStats].sort((a, b) => b.rate - a.rate);
|
||||
bestPerformingMission = sortedByRate[0].goalName;
|
||||
|
||||
const worstMission = sortedByRate[sortedByRate.length - 1];
|
||||
if (worstMission.rate < 60) {
|
||||
improvementNeeded = worstMission.goalName;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
overallRate: Math.round(serverData.totalAchievementRate || 0),
|
||||
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
|
||||
goalStats,
|
||||
totalMissions: goalStats.length,
|
||||
averageCompletionRate: Math.round(serverData.periodAchievementRate || 0),
|
||||
bestPerformingMission,
|
||||
improvementNeeded,
|
||||
bestStreak: serverData.bestStreak || 0,
|
||||
insights: serverData.insights || [],
|
||||
chartData: serverData.chartData
|
||||
};
|
||||
}, [selectedPeriod, getMissionIcon]);
|
||||
|
||||
// 기본 빈 데이터 구조 (개선됨)
|
||||
const getDefaultHistoryData = useCallback(() => {
|
||||
console.log('📋 기본 데이터 생성 시도');
|
||||
|
||||
// ✅ goalData가 있으면 현재 상태 기반으로 생성
|
||||
const currentData = getCurrentAchievementData();
|
||||
if (currentData) {
|
||||
console.log('✅ 현재 목표 데이터로 기본값 생성');
|
||||
return {
|
||||
...currentData,
|
||||
period: selectedPeriod === 'week' ? '지난 일주일 (현재 상태)' : '지난 한달 (현재 상태)'
|
||||
};
|
||||
}
|
||||
|
||||
// goalData도 없으면 완전 빈 상태
|
||||
return {
|
||||
overallRate: 0,
|
||||
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
|
||||
goalStats: [],
|
||||
totalMissions: 0,
|
||||
averageCompletionRate: 0,
|
||||
bestPerformingMission: null,
|
||||
improvementNeeded: null,
|
||||
bestStreak: 0,
|
||||
insights: [],
|
||||
chartData: null
|
||||
};
|
||||
}, [selectedPeriod, getCurrentAchievementData]);
|
||||
|
||||
// 로컬 데이터에서 이력 정보 가져오기 (개선됨)
|
||||
const getLocalHistoryData = useCallback(() => {
|
||||
try {
|
||||
const localData = JSON.parse(localStorage.getItem('dailyMissionHistory') || '[]');
|
||||
console.log('📱 로컬 저장된 이력 데이터:', localData);
|
||||
|
||||
if (!localData.length) {
|
||||
console.log('📊 로컬 데이터 없음 - 현재 목표 데이터 사용 시도');
|
||||
return getCurrentAchievementData(); // ✅ 현재 데이터로 대체
|
||||
}
|
||||
|
||||
// 선택된 기간에 따라 데이터 필터링
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
if (selectedPeriod === 'week') {
|
||||
startDate.setDate(endDate.getDate() - 7);
|
||||
} else {
|
||||
startDate.setDate(endDate.getDate() - 30);
|
||||
}
|
||||
|
||||
const filteredData = localData.filter(item => {
|
||||
const itemDate = new Date(item.date);
|
||||
return itemDate >= startDate && itemDate <= endDate;
|
||||
});
|
||||
|
||||
console.log(`📅 ${selectedPeriod} 기간 필터링 결과:`, {
|
||||
전체데이터: localData.length,
|
||||
필터링후: filteredData.length
|
||||
});
|
||||
|
||||
if (!filteredData.length) {
|
||||
console.log('📊 필터링된 데이터 없음 - 현재 목표 데이터 사용');
|
||||
return getCurrentAchievementData(); // ✅ 현재 데이터로 대체
|
||||
}
|
||||
|
||||
// 미션별 통계 계산
|
||||
const missionStats = {};
|
||||
filteredData.forEach(day => {
|
||||
day.goals.forEach(goal => {
|
||||
if (!missionStats[goal.type]) {
|
||||
missionStats[goal.type] = {
|
||||
name: goal.name,
|
||||
totalDays: 0,
|
||||
completedDays: 0,
|
||||
missionId: goal.missionId,
|
||||
icon: getMissionIcon(goal.name)
|
||||
};
|
||||
}
|
||||
missionStats[goal.type].totalDays++;
|
||||
if (goal.completed) {
|
||||
missionStats[goal.type].completedDays++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 전체 달성률 계산
|
||||
const totalPossibleMissions = filteredData.reduce((sum, day) => sum + day.goals.length, 0);
|
||||
const totalCompletedMissions = filteredData.reduce((sum, day) =>
|
||||
sum + day.goals.filter(goal => goal.completed).length, 0
|
||||
);
|
||||
const overallRate = totalPossibleMissions > 0 ?
|
||||
Math.round((totalCompletedMissions / totalPossibleMissions) * 100) : 0;
|
||||
|
||||
// goalStats 형식으로 변환
|
||||
const goalStats = Object.values(missionStats).map(stat => ({
|
||||
goalName: stat.name,
|
||||
icon: stat.icon,
|
||||
rate: stat.totalDays > 0 ? Math.round((stat.completedDays / stat.totalDays) * 100) : 0,
|
||||
completed: stat.completedDays,
|
||||
total: stat.totalDays
|
||||
}));
|
||||
|
||||
return {
|
||||
overallRate,
|
||||
goalStats,
|
||||
totalMissions: goalStats.length,
|
||||
averageCompletionRate: overallRate
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('로컬 데이터 처리 오류:', error);
|
||||
return getCurrentAchievementData(); // ✅ 오류 시에도 현재 데이터 사용
|
||||
}
|
||||
}, [selectedPeriod, getMissionIcon, getCurrentAchievementData]);
|
||||
|
||||
// 메인 데이터 로딩 함수 (개선됨)
|
||||
const loadHistoryData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
console.log('🔄 이력 데이터 로딩 시작');
|
||||
|
||||
// ✅ 먼저 로컬 데이터 확인 (현재 목표 데이터 포함)
|
||||
const localData = getLocalHistoryData();
|
||||
|
||||
if (localData && (localData.goalStats.length > 0 || localData.overallRate > 0)) {
|
||||
console.log('✅ 로컬/현재 데이터 사용:', localData);
|
||||
const transformedLocalData = {
|
||||
...localData,
|
||||
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
|
||||
bestPerformingMission: localData.goalStats.length > 0 ?
|
||||
[...localData.goalStats].sort((a, b) => b.rate - a.rate)[0].goalName : null,
|
||||
improvementNeeded: localData.goalStats.find(g => g.rate < 60)?.goalName || null,
|
||||
bestStreak: 0,
|
||||
insights: [],
|
||||
chartData: null
|
||||
};
|
||||
setHistoryData(transformedLocalData);
|
||||
setLoading(false);
|
||||
return; // 데이터가 있으면 서버 호출하지 않음
|
||||
}
|
||||
|
||||
// 로컬/현재 데이터도 없으면 서버에서 조회
|
||||
console.log('🌐 서버 데이터 조회');
|
||||
const token = localStorage.getItem('authToken');
|
||||
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
|
||||
localStorage.getItem('userId') || '1';
|
||||
|
||||
const { startDate, endDate } = getDateRange(selectedPeriod);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
memberSerialNumber: memberSerialNumber,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
});
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/history?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('📡 이력 API 응답 상태:', response.status, response.statusText);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.log('서버에 이력 데이터 없음 - 기본 데이터 표시');
|
||||
setHistoryData(getDefaultHistoryData());
|
||||
return;
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ 서버 이력 데이터 로드 성공:', result);
|
||||
|
||||
if (result.success && result.data) {
|
||||
const transformedData = transformServerData(result.data);
|
||||
setHistoryData(transformedData);
|
||||
} else {
|
||||
console.log('서버 응답에 데이터 없음 - 기본 데이터 표시');
|
||||
setHistoryData(getDefaultHistoryData());
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 목표 이력 로딩 오류:', error);
|
||||
setHistoryData(getDefaultHistoryData());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedPeriod, getDateRange, transformServerData, getDefaultHistoryData, getLocalHistoryData]);
|
||||
|
||||
// 기간 변경 핸들러
|
||||
const handlePeriodChange = useCallback((period) => {
|
||||
console.log(`🔄 기간 변경: ${selectedPeriod} → ${period}`);
|
||||
setSelectedPeriod(period);
|
||||
}, [selectedPeriod]);
|
||||
|
||||
// ✅ goalData 변경 감지 및 즉시 반영
|
||||
useEffect(() => {
|
||||
console.log('🔄 goalData 변경 감지:', goalData);
|
||||
if (goalData && Object.keys(goalData).length > 0) {
|
||||
// 현재 데이터를 기반으로 즉시 업데이트
|
||||
const currentData = getCurrentAchievementData();
|
||||
if (currentData) {
|
||||
console.log('⚡ goalData 기반 즉시 업데이트');
|
||||
setHistoryData({
|
||||
...currentData,
|
||||
period: selectedPeriod === 'week' ? '지난 일주일 (현재 상태)' : '지난 한달 (현재 상태)'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [goalData, getCurrentAchievementData, selectedPeriod]);
|
||||
|
||||
// useEffect
|
||||
useEffect(() => {
|
||||
loadHistoryData();
|
||||
}, [loadHistoryData]);
|
||||
|
||||
// 로딩 중 렌더링
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="history-page">
|
||||
<Header title="목표 달성 이력" showBack backPath="/goals" />
|
||||
<div className="content">
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ marginBottom: '16px' }}>📊</div>
|
||||
<p>이력을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 메인 렌더링
|
||||
return (
|
||||
<div className="history-page">
|
||||
<Header title="목표 달성 이력" showBack backPath="/goals" />
|
||||
|
||||
<div className="content">
|
||||
{/* 기간 선택 */}
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>기간 선택</h3>
|
||||
<div className="period-buttons">
|
||||
<button
|
||||
className={`period-btn ${selectedPeriod === 'week' ? 'active' : ''}`}
|
||||
onClick={() => handlePeriodChange('week')}
|
||||
>
|
||||
일주일
|
||||
</button>
|
||||
<button
|
||||
className={`period-btn ${selectedPeriod === 'month' ? 'active' : ''}`}
|
||||
onClick={() => handlePeriodChange('month')}
|
||||
>
|
||||
한달
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{historyData && (
|
||||
<>
|
||||
{/* 전체 달성률 */}
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>전체 달성률</h3>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
fontSize: '48px',
|
||||
fontWeight: 'bold',
|
||||
color: getProgressColor(historyData.overallRate),
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{historyData.overallRate}%
|
||||
</div>
|
||||
<p style={{ color: '#666', fontSize: '14px', marginBottom: '8px' }}>
|
||||
{historyData.period} 평균
|
||||
</p>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '4px 12px',
|
||||
backgroundColor: getProgressColor(historyData.overallRate),
|
||||
color: 'white',
|
||||
borderRadius: '16px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{getStatusMessage(historyData.overallRate)}
|
||||
</div>
|
||||
{historyData.bestStreak > 0 && (
|
||||
<p style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
|
||||
🔥 최고 연속: {historyData.bestStreak}일
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 항목별 달성률 */}
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>항목별 달성률</h3>
|
||||
|
||||
{historyData.goalStats && historyData.goalStats.length > 0 ? (
|
||||
historyData.goalStats.map((goal, index) => (
|
||||
<div key={index} style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<span style={{ marginRight: '8px', fontSize: '18px' }}>{goal.icon}</span>
|
||||
<span style={{ fontWeight: '500' }}>{goal.goalName}</span>
|
||||
{goal.current !== undefined && goal.target !== undefined && (
|
||||
<span style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '12px',
|
||||
color: '#666'
|
||||
}}>
|
||||
({goal.current}/{goal.target})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span style={{
|
||||
color: getProgressColor(goal.rate),
|
||||
fontWeight: 'bold',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
{goal.rate}%
|
||||
</span>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: getProgressColor(goal.rate),
|
||||
fontWeight: 'bold',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{getStatusMessage(goal.rate)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{
|
||||
width: `${goal.rate}%`,
|
||||
backgroundColor: getProgressColor(goal.rate)
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<p style={{
|
||||
fontSize: '12px',
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
marginTop: '4px'
|
||||
}}>
|
||||
{goal.completed}/{goal.total}일 달성
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>
|
||||
<p>아직 달성 이력 데이터가 없습니다.</p>
|
||||
<p style={{ fontSize: '14px', marginTop: '8px' }}>
|
||||
미션을 수행하면 여기에서 달성률을 확인할 수 있어요!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 달성 통계 요약 */}
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>달성 요약</h3>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gap: '12px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#27ae60' }}>
|
||||
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate >= 80).length : 0}
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>우수 달성</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#f39c12' }}>
|
||||
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate >= 60 && goal.rate < 80).length : 0}
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>보통 달성</p>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#e74c3c' }}>
|
||||
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate < 60).length : 0}
|
||||
</div>
|
||||
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>개선 필요</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI 인사이트 */}
|
||||
{historyData.insights && historyData.insights.length > 0 && (
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>🤖 AI 인사이트</h3>
|
||||
{historyData.insights.map((insight, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderLeft: '4px solid #007bff',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#495057' }}>
|
||||
{insight}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 개선 제안 */}
|
||||
{historyData.improvementNeeded && (
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>💡 개선 제안</h3>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderLeft: '4px solid #ffc107',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#856404' }}>
|
||||
<strong>{historyData.improvementNeeded}</strong> 항목의 달성률이 낮습니다.<br />
|
||||
조금 더 신경써서 실천해보세요! 💪
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 최고 성과 */}
|
||||
{historyData.bestPerformingMission && (
|
||||
<div className="dashboard-card">
|
||||
<h3 style={{ marginBottom: '16px' }}>🏆 최고 성과</h3>
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
backgroundColor: '#d4edda',
|
||||
borderLeft: '4px solid #28a745',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '14px', color: '#155724' }}>
|
||||
<strong>{historyData.bestPerformingMission}</strong> 항목에서 가장 좋은 성과를 보였습니다!<br />
|
||||
이 패턴을 다른 목표에도 적용해보세요! 🎉
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 데이터가 없는 경우 */}
|
||||
{!historyData && !loading && (
|
||||
<div className="dashboard-card">
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
|
||||
<p style={{ color: '#666', marginBottom: '16px' }}>
|
||||
아직 달성 이력이 없습니다.
|
||||
</p>
|
||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
||||
목표를 설정하고 실천하면<br />
|
||||
이곳에서 달성 현황을 확인할 수 있어요!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryPage;
|
||||
418
src/pages/LoadingPage.js
Normal file
418
src/pages/LoadingPage.js
Normal file
@ -0,0 +1,418 @@
|
||||
//* src/pages/LoadingPage.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
|
||||
function LoadingPage({ setHealthData, setGoalData, setAiMissions, setHealthDiagnosis, user }) {
|
||||
const navigate = useNavigate();
|
||||
const [loadingMessage, setLoadingMessage] = useState('건강검진 기록을 확인하고 있습니다');
|
||||
|
||||
const isNewUser = localStorage.getItem('isNewUser') === 'true';
|
||||
|
||||
useEffect(() => {
|
||||
loadHealthData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 가라 진단 데이터 반환 함수
|
||||
const getDefaultDiagnosis = () => {
|
||||
return [
|
||||
"혈압과 콜레스테롤 수치가 주의 범위에 있어 적극적인 관리가 필요해요!",
|
||||
"IT 직업 특성상 장시간 앉아있는 생활 패턴으로 인한 건강 리스크가 보여요!",
|
||||
"하지만 지금 시작하면 충분히 개선 가능하니 함께 건강해져요!"
|
||||
];
|
||||
};
|
||||
|
||||
// 가라 미션 데이터 반환 함수
|
||||
const getDefaultMissions = () => {
|
||||
return [
|
||||
{
|
||||
missionId: "mission_001",
|
||||
title: "매일 물 8잔 마시기",
|
||||
description: "하루 8잔(약 2L)의 물을 나누어 마셔 수분 균형을 유지하세요.",
|
||||
category: "nutrition",
|
||||
difficulty: "easy",
|
||||
healthBenefit: "신진대사 개선 및 독소 배출",
|
||||
occupationRelevance: "장시간 앉아 있는 직업군에게 필수적인 수분 공급",
|
||||
estimatedTimeMinutes: 5
|
||||
},
|
||||
{
|
||||
missionId: "mission_002",
|
||||
title: "하루 1만보 걷기",
|
||||
description: "매일 1만보를 목표로 걷기 운동을 실시하세요.",
|
||||
category: "exercise",
|
||||
difficulty: "medium",
|
||||
healthBenefit: "심혈관 건강 개선 및 체중 관리",
|
||||
occupationRelevance: "사무직 근무자의 운동 부족 해소",
|
||||
estimatedTimeMinutes: 60
|
||||
},
|
||||
{
|
||||
missionId: "mission_003",
|
||||
title: "목과 어깨 스트레칭",
|
||||
description: "1시간마다 5분씩 목과 어깨 스트레칭을 실시하세요.",
|
||||
category: "exercise",
|
||||
difficulty: "easy",
|
||||
healthBenefit: "목과 어깨 근육 긴장 완화",
|
||||
occupationRelevance: "컴퓨터 업무로 인한 거북목 예방",
|
||||
estimatedTimeMinutes: 5
|
||||
},
|
||||
{
|
||||
missionId: "mission_004",
|
||||
title: "규칙적인 수면 패턴",
|
||||
description: "매일 같은 시간에 잠들고 7-8시간 수면을 유지하세요.",
|
||||
category: "sleep",
|
||||
difficulty: "medium",
|
||||
healthBenefit: "면역력 강화 및 스트레스 감소",
|
||||
occupationRelevance: "야근이 잦은 직업군의 수면 리듬 정상화",
|
||||
estimatedTimeMinutes: 480
|
||||
},
|
||||
{
|
||||
missionId: "mission_005",
|
||||
title: "명상 및 깊은 호흡",
|
||||
description: "하루 10분간 명상이나 깊은 호흡 운동을 실시하세요.",
|
||||
category: "mental_health",
|
||||
difficulty: "easy",
|
||||
healthBenefit: "스트레스 감소 및 집중력 향상",
|
||||
occupationRelevance: "업무 스트레스가 높은 환경에서 멘탈 케어",
|
||||
estimatedTimeMinutes: 10
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const loadHealthData = async () => {
|
||||
try {
|
||||
// 1단계: 건강검진 기록 확인
|
||||
setLoadingMessage('건강검진 기록을 확인하고 있습니다');
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
// 2단계: 건강검진 데이터 동기화 API 호출
|
||||
const healthData = await syncHealthCheckupData();
|
||||
|
||||
// 건강 데이터를 App.js에 저장
|
||||
setHealthData(healthData);
|
||||
|
||||
// 3단계: 건강진단 3줄 요약 생성
|
||||
setLoadingMessage('AI가 건강 데이터를 분석하고 있습니다');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const healthDiagnosis = await generateHealthDiagnosis(healthData, user);
|
||||
setHealthDiagnosis(healthDiagnosis); // 건강진단 결과를 App.js에 저장
|
||||
|
||||
if (!isNewUser) {
|
||||
// 4-A. 기존 회원: 목표 데이터 로드
|
||||
setLoadingMessage('설정된 목표를 불러오고 있습니다');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const goalData = await loadActiveGoals();
|
||||
setGoalData(goalData);
|
||||
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
// 4-B. 신규 회원: AI 미션 추천 생성
|
||||
setLoadingMessage('AI가 맞춤형 건강 미션을 생성하고 있습니다');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const aiMissions = await generateAiMissions(healthData, user);
|
||||
setAiMissions(aiMissions); // AI 추천 미션을 App.js에 저장
|
||||
|
||||
navigate('/mission');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('건강 데이터 로딩 오류:', error);
|
||||
alert('건강 데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const syncHealthCheckupData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.HEALTH_URL}/checkup/history`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('🔍 API 원본 응답:', result);
|
||||
|
||||
// ✅ API 응답이 정상이면 원본 데이터만 최소한 정리해서 전달
|
||||
if (result.data && result.data.userInfo && result.data.recentHealthProfile) {
|
||||
console.log('✅ API 데이터 정상 - 원본 구조로 전달');
|
||||
return result.data; // API 원본 구조 그대로 전달
|
||||
}
|
||||
|
||||
// API 응답이 예상과 다르면 null 반환 (Dashboard에서 기본값 사용)
|
||||
console.warn('⚠️ API 응답 구조가 예상과 다름 - null 반환하여 Dashboard 기본값 사용');
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 건강검진 동기화 API 오류:', error);
|
||||
return null; // 에러 시 null 반환 (Dashboard에서 기본값 사용)
|
||||
}
|
||||
}
|
||||
// LoadingPage.js에서 generateHealthDiagnosis 함수를 이것으로 교체하세요
|
||||
|
||||
const generateHealthDiagnosis = async (healthData, user) => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const user_id = localStorage.getItem('userId') || 'temp-user-id';
|
||||
|
||||
const params = new URLSearchParams({
|
||||
user_id: user_id
|
||||
});
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/health/diagnosis?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`건강진단 API 에러: ${response.status} - 가라데이터 사용`);
|
||||
return getDefaultDiagnosis();
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('=== 건강진단 API 응답 디버깅 ===');
|
||||
console.log('전체 응답:', result);
|
||||
|
||||
let diagnosis = null;
|
||||
|
||||
// ✅ Case 1: 이미 배열로 온 경우 (올바른 백엔드 구현)
|
||||
if (result.threeSentenceSummary && Array.isArray(result.threeSentenceSummary)) {
|
||||
console.log('✅ 배열 형태로 수신:', result.threeSentenceSummary);
|
||||
diagnosis = result.threeSentenceSummary; // 있는 그대로만 사용
|
||||
}
|
||||
// ✅ Case 2: 문자열로 온 경우 (현재 백엔드 구현) - 응답 그대로만 분리
|
||||
else if (result.threeSentenceSummary && typeof result.threeSentenceSummary === 'string') {
|
||||
console.log('⚠️ 문자열 형태로 수신 - 배열로 변환:', result.threeSentenceSummary);
|
||||
|
||||
const originalText = result.threeSentenceSummary.trim();
|
||||
let sentences = [];
|
||||
|
||||
// 🔧 방법 1: 마침표 + 공백으로 분리
|
||||
sentences = originalText.split(/\.\s+/);
|
||||
console.log('방법1 결과:', sentences);
|
||||
|
||||
// 🔧 방법 2: 마침표만으로 분리 (공백 없는 경우)
|
||||
if (sentences.length <= 1) {
|
||||
sentences = originalText.split(/\./);
|
||||
console.log('방법2 결과:', sentences);
|
||||
}
|
||||
|
||||
// 🔧 방법 3: 느낌표, 물음표도 포함
|
||||
if (sentences.length <= 1) {
|
||||
sentences = originalText.split(/[.!?]\s*/);
|
||||
console.log('방법3 결과:', sentences);
|
||||
}
|
||||
|
||||
// 🔧 방법 4: 줄바꿈으로 분리 시도
|
||||
if (sentences.length <= 1) {
|
||||
sentences = originalText.split(/\n+/);
|
||||
console.log('방법4 결과:', sentences);
|
||||
}
|
||||
|
||||
// 문장 정리
|
||||
sentences = sentences
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 5) // 너무 짧은 문장 제거
|
||||
.map(s => {
|
||||
// 문장 끝에 마침표가 없으면 추가
|
||||
if (s && !s.match(/[.!?]$/)) {
|
||||
return s + '.';
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
console.log('최종 정리된 문장들:', sentences);
|
||||
|
||||
// ✅ API 응답 그대로만 사용 (부족해도 추가 안함)
|
||||
if (sentences.length > 0) {
|
||||
diagnosis = sentences; // 있는 것만 그대로 사용
|
||||
console.log('✅ 문장 분리 성공 (응답 그대로):', diagnosis);
|
||||
} else {
|
||||
// 완전히 분리 실패한 경우만 원문 사용
|
||||
console.log('⚠️ 문장 분리 실패, 원문 그대로 사용');
|
||||
diagnosis = [originalText];
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 백업: 다른 키에서도 확인
|
||||
if (!diagnosis) {
|
||||
console.log('⚠️ threeSentenceSummary 없음, 다른 키 확인');
|
||||
|
||||
if (result.diagnosis && Array.isArray(result.diagnosis)) {
|
||||
diagnosis = result.diagnosis;
|
||||
} else if (result.data?.diagnosis && Array.isArray(result.data.diagnosis)) {
|
||||
diagnosis = result.data.diagnosis;
|
||||
} else if (result.data && Array.isArray(result.data)) {
|
||||
diagnosis = result.data;
|
||||
} else if (Array.isArray(result)) {
|
||||
diagnosis = result;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎯 최종 diagnosis 결과 (API 응답 그대로):', diagnosis);
|
||||
|
||||
// ✅ 최종 검증
|
||||
if (diagnosis && Array.isArray(diagnosis) && diagnosis.length > 0) {
|
||||
console.log(`✅ AI 건강진단 로드 성공! (${diagnosis.length}문장)`, diagnosis);
|
||||
return diagnosis; // API 응답 그대로 반환
|
||||
} else {
|
||||
console.log('❌ 최종 파싱 실패 - 가라데이터 사용');
|
||||
return getDefaultDiagnosis();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.warn('건강진단 API 연결 실패 - 가라데이터 사용:', error.message);
|
||||
return getDefaultDiagnosis();
|
||||
}
|
||||
};
|
||||
|
||||
// AI 미션 추천 API 호출 (신규회원 전용, POST 요청)
|
||||
const generateAiMissions = async (healthData, user) => {
|
||||
try {
|
||||
// 신규회원이 아닌 경우 API 호출하지 않고 빈 배열 반환
|
||||
if (!isNewUser) {
|
||||
console.log('기존 회원 - AI 미션 추천 건너뛰기');
|
||||
return [];
|
||||
}
|
||||
|
||||
setLoadingMessage('AI가 건강 데이터를 분석하고 있습니다');
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
const token = localStorage.getItem('authToken');
|
||||
const userId = localStorage.getItem('userId') || 'temp-user-id';
|
||||
|
||||
// POST 요청으로 userId만 body에 전송
|
||||
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/missions/recommend`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
user_id: parseInt(userId) // API 문서에 맞춰 user_id로 전송, integer 타입
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 422) {
|
||||
console.log('AI 미션 데이터 없음 (422) - 가라데이터 사용');
|
||||
return getDefaultMissions();
|
||||
} else if (response.status === 404) {
|
||||
console.log('AI 미션 API 없음 (404) - 가라데이터 사용');
|
||||
return getDefaultMissions();
|
||||
} else {
|
||||
console.warn(`AI 미션 추천 API 에러: ${response.status} - 가라데이터 사용`);
|
||||
return getDefaultMissions();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const missions = result.missions || result.data?.missions;
|
||||
|
||||
if (!missions || !Array.isArray(missions) || missions.length === 0) {
|
||||
console.log('AI 미션 데이터 없음 - 가라데이터 사용');
|
||||
return getDefaultMissions();
|
||||
}
|
||||
|
||||
setLoadingMessage('개인 맞춤 미션이 완성되었습니다!');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
console.log('AI 미션 추천 데이터 로드 성공');
|
||||
return missions;
|
||||
|
||||
} catch (error) {
|
||||
console.warn('AI 미션 추천 API 연결 실패 - 가라데이터 사용:', error.message);
|
||||
setLoadingMessage('기본 추천 미션을 준비하고 있습니다');
|
||||
return getDefaultMissions();
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 수정된 목표 데이터 로딩 함수 - memberSerialNumber 파라미터 추가
|
||||
const loadActiveGoals = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const memberSerialNumber = localStorage.getItem('memberSerialNumber') || localStorage.getItem('userId') || '1';
|
||||
|
||||
// ✅ memberSerialNumber 쿼리 파라미터 추가
|
||||
const params = new URLSearchParams({
|
||||
memberSerialNumber: memberSerialNumber
|
||||
});
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/active?${params}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data || result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('목표 데이터 로딩 API 오류:', error);
|
||||
|
||||
// API 오류 시 기본 더미 데이터 반환(테스트용)
|
||||
return {
|
||||
water: { current: 3, target: 8, goalName: "물 마시기", description: "하루 8잔의 물 마시기" },
|
||||
walk: { current: 5000, target: 10000, goalName: "걷기", description: "하루 1만보 걷기" },
|
||||
stretch: { current: 2, target: 5, goalName: "스트레칭", description: "하루 5회 스트레칭" },
|
||||
meditation: { current: 0, target: 1, goalName: "명상", description: "하루 10분 명상" },
|
||||
sleep: { current: 0, target: 1, goalName: "충분한 수면", description: "하루 7시간 이상 수면" }
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="loading-page">
|
||||
<Header title="건강정보 연동" />
|
||||
<div className="content">
|
||||
<LoadingSpinner message={loadingMessage} />
|
||||
<p style={{ color: '#666', marginBottom: '40px', textAlign: 'center' }}>
|
||||
{isNewUser ?
|
||||
'AI가 회원님의 건강 데이터를 분석하여 맞춤형 미션을 생성하는 중...' :
|
||||
'건강보험공단과 연동하여 검진 이력을 불러오는 중...'
|
||||
}
|
||||
</p>
|
||||
|
||||
<div className="info-box">
|
||||
<p>잠시만 기다려주세요!</p>
|
||||
<p className="subtitle">
|
||||
{isNewUser ? (
|
||||
<>
|
||||
AI가 회원님의 건강 상태를 분석하여<br />
|
||||
맞춤형 건강 미션을 생성하고 있습니다.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
기존 설정된 건강 미션과<br />
|
||||
최신 건강 데이터를 불러오고 있습니다.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingPage;
|
||||
140
src/pages/LoginPage.js
Normal file
140
src/pages/LoginPage.js
Normal file
@ -0,0 +1,140 @@
|
||||
//* src/pages/LoginPage.js
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function LoginPage({ setUser }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 백엔드 /api/auth/login으로 리다이렉트
|
||||
const handleGoogleLogin = () => {
|
||||
console.log('구글 로그인 요청 시작 - 백엔드로 리다이렉트');
|
||||
|
||||
// 백엔드의 /api/auth/login 엔드포인트로 리다이렉트
|
||||
window.location.href = `${window.__runtime_config__?.AUTH_URL || 'http://localhost:8081'}/oauth2/authorization/google`;
|
||||
};
|
||||
|
||||
// 구글 OAuth 완료 후 백엔드에서 돌아온 결과 처리
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// 새로운 파라미터명으로 변경
|
||||
const success = urlParams.get('success');
|
||||
const accessToken = urlParams.get('accessToken');
|
||||
const refreshToken = urlParams.get('refreshToken');
|
||||
const isNewUser = urlParams.get('isNewUser');
|
||||
const userId = urlParams.get('userId');
|
||||
const error = urlParams.get('error');
|
||||
|
||||
console.log('OAuth 파라미터:', {
|
||||
success,
|
||||
accessToken: accessToken ? accessToken.substring(0, 20) + '...' : null,
|
||||
refreshToken: refreshToken ? refreshToken.substring(0, 20) + '...' : null,
|
||||
isNewUser,
|
||||
userId,
|
||||
error
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('OAuth 에러:', error);
|
||||
alert('로그인 중 오류가 발생했습니다: ' + decodeURIComponent(error));
|
||||
// URL에서 에러 파라미터 제거
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
}
|
||||
|
||||
// success가 'true'이고 accessToken이 있는 경우 로그인 성공
|
||||
if (success === 'true' && accessToken) {
|
||||
console.log('OAuth 로그인 성공');
|
||||
|
||||
// JWT 토큰들을 로컬 스토리지에 저장
|
||||
localStorage.setItem('authToken', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
localStorage.setItem('isNewUser', isNewUser || 'false');
|
||||
localStorage.setItem('userId', userId);
|
||||
|
||||
// 사용자 정보 설정 (일단 기본 정보만)
|
||||
const userInfo = {
|
||||
id: userId,
|
||||
isNewUser: isNewUser === 'true'
|
||||
};
|
||||
setUser(userInfo);
|
||||
console.log('사용자 정보 설정 완료:', userInfo);
|
||||
|
||||
// URL에서 파라미터들 제거 (보안상 좋음)
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
// 신규 회원인지 기존 회원인지에 따라 라우팅
|
||||
if (isNewUser === 'true') {
|
||||
console.log('신규 회원 - 회원가입 페이지로 이동');
|
||||
navigate('/register');
|
||||
} else {
|
||||
console.log('기존 회원 - 로딩 페이지로 이동');
|
||||
navigate('/loading');
|
||||
}
|
||||
}
|
||||
}, [navigate, setUser]);
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="header">
|
||||
<div className="title">HealthSync</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="logo">
|
||||
<div className="logo-container">
|
||||
<span className="logo-icon">✓</span>
|
||||
</div>
|
||||
<h2 style={{ textAlign: 'center', color: '#333', marginBottom: '16px', fontWeight: '600' }}>
|
||||
AI 건강 코치와 함께
|
||||
</h2>
|
||||
<p style={{ color: '#666', marginBottom: '80px', textAlign: 'center', lineHeight: '1.5' }}>
|
||||
개인 맞춤형 건강관리를<br />시작해보세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '16px' }}>
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.target.style.borderColor = '#667eea';
|
||||
e.target.style.boxShadow = '0 2px 10px rgba(102, 126, 234, 0.1)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.target.style.borderColor = '#e9ecef';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" 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="#34A853" 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="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.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="#EA4335" 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>
|
||||
|
||||
<p style={{ textAlign: 'center', color: '#999', fontSize: '14px', marginTop: '30px' }}>
|
||||
간편하게 로그인하고 건강한 습관을 만들어보세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
177
src/pages/LoginPage.js.backup
Normal file
177
src/pages/LoginPage.js.backup
Normal file
@ -0,0 +1,177 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGoogleLogin } from '@react-oauth/google';
|
||||
|
||||
function LoginPage({ setUser }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// useGoogleLogin 훅을 사용한 Authorization Code Flow
|
||||
const googleLogin = useGoogleLogin({
|
||||
flow: 'auth-code', // Authorization Code Flow 사용
|
||||
onSuccess: async (codeResponse) => {
|
||||
try {
|
||||
console.log('구글 로그인 성공:', codeResponse);
|
||||
|
||||
// Authorization Code 추출
|
||||
const code = codeResponse.code;
|
||||
|
||||
if (!code) {
|
||||
console.error('Authorization code를 받지 못했습니다.');
|
||||
console.log('받은 응답:', codeResponse);
|
||||
alert('구글 로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('구글 Authorization Code:', code);
|
||||
|
||||
// 백엔드 API에 code 값만 전송
|
||||
const loginResult = await loginWithGoogleCode(code);
|
||||
|
||||
// isNewUser 정보를 localStorage에 저장
|
||||
localStorage.setItem('isNewUser', loginResult.isNewUser.toString());
|
||||
|
||||
// 사용자 정보 설정
|
||||
setUser(loginResult.user);
|
||||
|
||||
if (loginResult.isNewUser) {
|
||||
// 신규 회원이면 회원가입 페이지로
|
||||
console.log('신규 회원 가입');
|
||||
navigate('/register');
|
||||
} else {
|
||||
// 기존 회원이면 바로 로딩 화면으로
|
||||
console.log('기존 회원 로그인');
|
||||
navigate('/loading');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('구글 로그인 처리 중 오류:', error);
|
||||
alert('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
console.error('구글 로그인 실패');
|
||||
alert('구글 로그인에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
// 백엔드 API에 구글 Authorization Code만 전송
|
||||
const loginWithGoogleCode = async (code) => {
|
||||
try {
|
||||
const response = await fetch(`${window.__runtime_config__?.AUTH_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: code // 구글에서 받은 code 값만 전송
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 백엔드에서 처리할 작업:
|
||||
// 1. code를 구글 OAuth 서버에 보내서 Access Token 받기
|
||||
// 2. Access Token으로 구글에서 사용자 정보 조회
|
||||
// 3. 사용자 정보로 회원 가입/로그인 처리
|
||||
// 4. 우리 앱용 JWT 토큰 생성해서 응답
|
||||
|
||||
// API 응답 예시:
|
||||
// {
|
||||
// isNewUser: true/false,
|
||||
// user: {
|
||||
// id: "123",
|
||||
// googleId: "구글ID",
|
||||
// name: "홍길동",
|
||||
// email: "hong@gmail.com",
|
||||
// picture: "profile_image_url"
|
||||
// },
|
||||
// token: "eyJhbGciOiJIUzI1NiIs...", // 우리 앱의 JWT 토큰
|
||||
// message: "로그인 성공"
|
||||
// }
|
||||
|
||||
// 우리 앱의 JWT 토큰을 로컬 스토리지에 저장
|
||||
if (result.token) {
|
||||
localStorage.setItem('authToken', result.token);
|
||||
console.log('JWT 토큰 저장 완료');
|
||||
}
|
||||
|
||||
return {
|
||||
isNewUser: result.isNewUser,
|
||||
user: result.user,
|
||||
token: result.token
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('로그인 API 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="header">
|
||||
<div className="title">HealthSync</div>
|
||||
</div>
|
||||
<div className="content">
|
||||
<div className="logo">
|
||||
<div className="logo-container">
|
||||
<span className="logo-icon">✓</span>
|
||||
</div>
|
||||
<h2 style={{ textAlign: 'center', color: '#333', marginBottom: '16px', fontWeight: '600' }}>
|
||||
AI 건강 코치와 함께
|
||||
</h2>
|
||||
<p style={{ color: '#666', marginBottom: '80px', textAlign: 'center', lineHeight: '1.5' }}>
|
||||
개인 맞춤형 건강관리를<br />시작해보세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '16px' }}>
|
||||
<button
|
||||
onClick={googleLogin}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e9ecef',
|
||||
borderRadius: '8px',
|
||||
background: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
fontSize: '16px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.target.style.borderColor = '#667eea';
|
||||
e.target.style.boxShadow = '0 2px 10px rgba(102, 126, 234, 0.1)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.target.style.borderColor = '#e9ecef';
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" 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="#34A853" 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="#FBBC05" 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="#EA4335" 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>
|
||||
|
||||
<p style={{ textAlign: 'center', color: '#999', fontSize: '14px', marginTop: '30px' }}>
|
||||
간편하게 로그인하고 건강한 습관을 만들어보세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoginPage;
|
||||
329
src/pages/MissionPage.js
Normal file
329
src/pages/MissionPage.js
Normal file
@ -0,0 +1,329 @@
|
||||
//* src/pages/MissionPage.js
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
import MissionCard from '../components/MissionCard';
|
||||
|
||||
function MissionPage({ selectedMissions, setSelectedMissions, setGoalData, aiMissions, healthData, healthDiagnosis }) {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 🔧 AI 추천 미션을 MissionCard 형식으로 변환 (UI 표시용만)
|
||||
const convertAiMissionsToCards = (aiMissions) => {
|
||||
if (!aiMissions || !Array.isArray(aiMissions) || aiMissions.length === 0) {
|
||||
console.log('AI 미션이 없어서 기본 미션 사용');
|
||||
return getDefaultMissions();
|
||||
}
|
||||
|
||||
console.log('=== AI 미션 변환 (UI용만) ===');
|
||||
console.log('원본 AI 미션들:', aiMissions);
|
||||
|
||||
return aiMissions.map((mission, index) => {
|
||||
// missionId 생성 (없으면 인덱스 기반으로)
|
||||
const missionId = mission.missionId || `ai_mission_${index + 1}`;
|
||||
|
||||
// 카테고리에 따른 이모지 매핑
|
||||
const getCategoryEmoji = (category) => {
|
||||
switch (category?.toLowerCase()) {
|
||||
case 'nutrition': return '💧';
|
||||
case 'exercise': return '🚶♂️';
|
||||
case 'sleep': return '😴';
|
||||
case 'mental_health': return '🧘♀️';
|
||||
default: return '🎯';
|
||||
}
|
||||
};
|
||||
|
||||
const convertedMission = {
|
||||
id: missionId,
|
||||
title: `${getCategoryEmoji(mission.category)} ${mission.title}`,
|
||||
description: mission.reason || '건강한 생활습관을 위한 미션입니다.',
|
||||
dailyTarget: mission.dailyTargetCount || mission.estimatedTimeMinutes || 1,
|
||||
reason: mission.healthBenefit || mission.occupationRelevance || '건강 개선을 위한 추천 미션',
|
||||
category: mission.category,
|
||||
difficulty: mission.difficulty,
|
||||
estimatedTimeMinutes: mission.estimatedTimeMinutes,
|
||||
originalData: mission // ✅ 원본 AI 미션 데이터 보존
|
||||
};
|
||||
|
||||
console.log(`변환된 미션 ${index + 1}:`, convertedMission);
|
||||
return convertedMission;
|
||||
});
|
||||
};
|
||||
|
||||
// 기본 미션 (AI 추천이 없을 때 사용)
|
||||
const getDefaultMissions = () => {
|
||||
return [
|
||||
{
|
||||
id: 'water',
|
||||
title: '💧 하루 8잔 물마시기',
|
||||
description: '충분한 수분 섭취로 신진대사를 활발하게 하고 피부 건강을 개선합니다.',
|
||||
dailyTarget: 8,
|
||||
reason: '장시간 앉아 있는 직업군에게 필수적인 수분 공급으로 신진대사 개선',
|
||||
originalData: {
|
||||
title: '하루 8잔 물마시기',
|
||||
dailyTargetCount: 8,
|
||||
healthBenefit: '장시간 앉아 있는 직업군에게 필수적인 수분 공급으로 신진대사 개선',
|
||||
category: 'nutrition',
|
||||
difficulty: 'easy'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'walk',
|
||||
title: '🚶♂️ 점심시간 10분 산책',
|
||||
description: '장시간 앉아있는 직업 특성을 고려한 혈액순환 개선 운동입니다.',
|
||||
dailyTarget: 1,
|
||||
reason: '혈압과 혈당 관리, 체중 감량을 위한 유산소 운동',
|
||||
originalData: {
|
||||
title: '점심시간 10분 산책',
|
||||
dailyTargetCount: 1,
|
||||
healthBenefit: '혈압과 혈당 관리, 체중 감량을 위한 유산소 운동',
|
||||
category: 'exercise',
|
||||
difficulty: 'easy'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'stretch',
|
||||
title: '🧘♀️ 5분 목&어깨 스트레칭',
|
||||
description: '컴퓨터 작업으로 인한 목과 어깨 긴장을 완화하는 스트레칭입니다.',
|
||||
dailyTarget: 3,
|
||||
reason: '장시간 모니터 사용으로 인한 목 긴장 완화 및 거북목 예방',
|
||||
originalData: {
|
||||
title: '5분 목&어깨 스트레칭',
|
||||
dailyTargetCount: 3,
|
||||
healthBenefit: '장시간 모니터 사용으로 인한 목 긴장 완화 및 거북목 예방',
|
||||
category: 'exercise',
|
||||
difficulty: 'easy'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'snack',
|
||||
title: '🥗 하루 1회 건강간식',
|
||||
description: '과일이나 견과류로 건강한 간식 습관을 만들어보세요.',
|
||||
dailyTarget: 1,
|
||||
reason: '혈당 조절과 영양 균형을 위한 건강한 간식 섭취',
|
||||
originalData: {
|
||||
title: '하루 1회 건강간식',
|
||||
dailyTargetCount: 1,
|
||||
healthBenefit: '혈당 조절과 영양 균형을 위한 건강한 간식 섭취',
|
||||
category: 'nutrition',
|
||||
difficulty: 'easy'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'sleep',
|
||||
title: '😴 규칙적인 수면시간',
|
||||
description: '매일 같은 시간에 잠자리에 들어 생체리듬을 조절합니다.',
|
||||
dailyTarget: 1,
|
||||
reason: '스트레스 관리와 면역력 강화를 위한 규칙적인 수면 패턴',
|
||||
originalData: {
|
||||
title: '규칙적인 수면시간',
|
||||
dailyTargetCount: 1,
|
||||
healthBenefit: '스트레스 관리와 면역력 강화를 위한 규칙적인 수면 패턴',
|
||||
category: 'sleep',
|
||||
difficulty: 'normal'
|
||||
}
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
// 🔧 실제 사용할 미션들 결정
|
||||
const missions = convertAiMissionsToCards(aiMissions);
|
||||
|
||||
console.log('=== MissionPage 미션 데이터 ===');
|
||||
console.log('받은 aiMissions:', aiMissions);
|
||||
console.log('최종 사용할 missions:', missions);
|
||||
console.log('AI 미션 사용 여부:', aiMissions && aiMissions.length > 0);
|
||||
|
||||
const handleMissionToggle = (missionId) => {
|
||||
setSelectedMissions(prev => {
|
||||
if (prev.includes(missionId)) {
|
||||
return prev.filter(id => id !== missionId);
|
||||
} else {
|
||||
return [...prev, missionId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleStart = async () => {
|
||||
if (selectedMissions.length === 0) {
|
||||
alert('최소 1개의 미션을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// ✅ 선택된 미션들의 원본 데이터를 그대로 사용
|
||||
const selectedMissionData = selectedMissions.map(missionId => {
|
||||
const mission = missions.find(m => m.id === missionId);
|
||||
// ✅ originalData가 있으면 그대로 사용, 없으면 기본 형식으로 변환
|
||||
if (mission.originalData) {
|
||||
return mission.originalData;
|
||||
} else {
|
||||
// 기본 미션의 경우 기본 형식으로 변환
|
||||
return {
|
||||
title: mission.title.replace(/^.+\s/, ''), // 이모지 제거
|
||||
dailyTargetCount: mission.dailyTarget,
|
||||
healthBenefit: mission.reason,
|
||||
category: mission.category,
|
||||
difficulty: mission.difficulty
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ 원본 AI 미션 데이터 그대로 전송:', selectedMissionData);
|
||||
|
||||
// 미션 선택 API 호출
|
||||
await submitSelectedMissions(selectedMissionData);
|
||||
|
||||
// 선택된 미션을 기반으로 초기 목표 데이터 설정
|
||||
const initialGoalData = {};
|
||||
selectedMissions.forEach(missionId => {
|
||||
const mission = missions.find(m => m.id === missionId);
|
||||
initialGoalData[missionId] = {
|
||||
current: 0,
|
||||
target: mission.dailyTarget,
|
||||
goalName: mission.title.replace(/^.+\s/, ''), // 이모지 제거
|
||||
description: mission.description
|
||||
};
|
||||
});
|
||||
|
||||
console.log('생성된 목표 데이터:', initialGoalData);
|
||||
|
||||
// App.js의 goalData 상태 업데이트
|
||||
setGoalData(initialGoalData);
|
||||
|
||||
// 신규 사용자 플래그 제거 (이제 기존 사용자가 됨)
|
||||
localStorage.setItem('isNewUser', 'false');
|
||||
|
||||
// 대시보드로 이동
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (error) {
|
||||
console.error('미션 설정 오류:', error);
|
||||
alert('미션 설정 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitSelectedMissions = async (selectedMissionData) => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
|
||||
localStorage.getItem('userId') || '1';
|
||||
|
||||
// ✅ AI 추천 미션 원본 데이터를 그대로 전송
|
||||
const requestData = {
|
||||
memberSerialNumber: parseInt(memberSerialNumber),
|
||||
selectedMissionIds: selectedMissionData // ✅ 원본 데이터 그대로 사용
|
||||
};
|
||||
|
||||
console.log('=== 미션 선택 API 요청 (원본 데이터) ===');
|
||||
console.log('📋 요청 데이터:', JSON.stringify(requestData, null, 2));
|
||||
console.log('📊 선택된 미션 개수:', requestData.selectedMissionIds.length);
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/select`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(requestData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('❌ 미션 선택 API 오류:', response.status, errorText);
|
||||
throw new Error(`미션 선택 API 오류: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('✅ 미션 선택 API 성공:', result);
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 미션 선택 API 호출 실패:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mission-page">
|
||||
<Header title="AI 추천 건강 미션" showBack backPath="/loading" />
|
||||
<div className="content">
|
||||
{/* 🔥 건강진단 요약 표시 */}
|
||||
{healthDiagnosis && healthDiagnosis.length > 0 && (
|
||||
<div style={{
|
||||
backgroundColor: '#f8f9ff',
|
||||
padding: '16px',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '24px',
|
||||
border: '1px solid #e0e6ff'
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
|
||||
<span style={{ fontSize: '18px', marginRight: '8px' }}>🤖</span>
|
||||
<h4 style={{ margin: 0, color: '#667eea' }}>AI 건강 분석 결과</h4>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', lineHeight: '1.5', color: '#333' }}>
|
||||
{healthDiagnosis.map((line, index) => (
|
||||
<p key={index} style={{ margin: '4px 0' }}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p style={{
|
||||
color: '#666',
|
||||
marginBottom: '24px',
|
||||
textAlign: 'center',
|
||||
fontSize: '15px'
|
||||
}}>
|
||||
{aiMissions && aiMissions.length > 0 ? (
|
||||
<>
|
||||
회원님의 건강검진 결과와 직업 특성을 분석하여<br />
|
||||
<strong style={{ color: '#667eea' }}>AI가 맞춤 추천한</strong> 건강 미션입니다.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
회원님의 건강을 위한<br />
|
||||
추천 건강 미션을 선택해주세요.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{missions.map(mission => (
|
||||
<MissionCard
|
||||
key={mission.id}
|
||||
mission={mission}
|
||||
isSelected={selectedMissions.includes(mission.id)}
|
||||
onToggle={() => handleMissionToggle(mission.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<p style={{ color: '#667eea', fontSize: '14px', margin: '20px 0' }}>
|
||||
✓ 최소 1개의 미션을 선택해주세요 (다중 선택 가능)
|
||||
{aiMissions && aiMissions.length > 0 && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ color: '#28a745' }}>
|
||||
✨ 위 미션들은 AI가 회원님의 건강 상태를 분석하여 개인 맞춤 추천한 미션입니다
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleStart}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '설정 중...' : '시작하기'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MissionPage;
|
||||
113
src/pages/OAuthCallbackPage.js
Normal file
113
src/pages/OAuthCallbackPage.js
Normal file
@ -0,0 +1,113 @@
|
||||
//* src/pages/OAuthCallbackPage.js
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
function OAuthCallbackPage({ setUser }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// 페이지 로드 즉시 화면 숨김
|
||||
document.body.style.display = 'none';
|
||||
document.documentElement.style.display = 'none';
|
||||
|
||||
console.log('OAuth 콜백 페이지 로드됨');
|
||||
|
||||
// JSON 응답 처리
|
||||
const handleOAuthResponse = () => {
|
||||
try {
|
||||
const bodyText = document.body.textContent || document.body.innerText || '';
|
||||
console.log('페이지 내용:', bodyText.substring(0, 200));
|
||||
|
||||
// JSON 응답인지 확인
|
||||
if (bodyText.includes('"accessToken"') || bodyText.includes('"success"') ||
|
||||
(bodyText.trim().startsWith('{') && bodyText.includes('memberSerialNumber'))) {
|
||||
console.log('JSON 응답 감지!');
|
||||
|
||||
try {
|
||||
// JSON 파싱
|
||||
const response = JSON.parse(bodyText.trim());
|
||||
console.log('파싱된 응답:', response);
|
||||
|
||||
const data = response.data || response;
|
||||
console.log('데이터:', data);
|
||||
|
||||
if (data.accessToken) {
|
||||
console.log('로그인 성공! 토큰 처리 시작');
|
||||
|
||||
// 토큰 localStorage에 저장
|
||||
localStorage.setItem('authToken', data.accessToken);
|
||||
|
||||
if (data.refreshToken) {
|
||||
localStorage.setItem('refreshToken', data.refreshToken);
|
||||
}
|
||||
|
||||
// 사용자 정보 설정 및 저장
|
||||
if (data.user) {
|
||||
localStorage.setItem('userInfo', JSON.stringify(data.user));
|
||||
setUser(data.user);
|
||||
|
||||
// userId 별도 저장 (API 호출용)
|
||||
if (data.user.memberSerialNumber) {
|
||||
localStorage.setItem('userId', data.user.memberSerialNumber.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// newUser 값으로 분기 처리
|
||||
const isNewUser = data.newUser;
|
||||
console.log('신규 사용자 여부:', isNewUser);
|
||||
|
||||
if (isNewUser === true) {
|
||||
console.log('신규 회원 → 회원가입 페이지로 이동');
|
||||
navigate('/register');
|
||||
} else {
|
||||
console.log('기존 회원 → 로딩 페이지로 이동');
|
||||
navigate('/loading');
|
||||
}
|
||||
} else {
|
||||
console.error('accessToken이 없습니다:', data);
|
||||
alert('로그인 처리 중 오류가 발생했습니다.');
|
||||
navigate('/');
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('JSON 파싱 오류:', parseError);
|
||||
alert('로그인 응답 처리 중 오류가 발생했습니다.');
|
||||
navigate('/');
|
||||
}
|
||||
} else {
|
||||
console.log('JSON 응답이 아직 로드되지 않음');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('OAuth 처리 중 오류:', error);
|
||||
alert('로그인 처리 중 오류가 발생했습니다.');
|
||||
navigate('/');
|
||||
}
|
||||
};
|
||||
|
||||
// 즉시 실행 및 재시도
|
||||
handleOAuthResponse();
|
||||
|
||||
const intervals = [10, 50, 100, 200, 500, 1000];
|
||||
const timeouts = intervals.map(delay =>
|
||||
setTimeout(handleOAuthResponse, delay)
|
||||
);
|
||||
|
||||
// MutationObserver로 DOM 변경 감지
|
||||
const observer = new MutationObserver(handleOAuthResponse);
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
|
||||
// cleanup
|
||||
return () => {
|
||||
timeouts.forEach(clearTimeout);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [navigate, setUser]);
|
||||
|
||||
// 화면에는 아무것도 렌더링하지 않음 (이미 숨김 처리됨)
|
||||
return null;
|
||||
}
|
||||
|
||||
export default OAuthCallbackPage;
|
||||
162
src/pages/RegisterPage.js
Normal file
162
src/pages/RegisterPage.js
Normal file
@ -0,0 +1,162 @@
|
||||
//* src/pages/RegisterPage.js
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import Header from '../components/Header';
|
||||
|
||||
function RegisterPage({ setUser }) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
birthDate: '',
|
||||
occupation: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 회원가입 API 호출
|
||||
await registerUser(formData);
|
||||
|
||||
// 회원정보 저장
|
||||
setUser(prev => ({ ...prev, ...formData }));
|
||||
|
||||
console.log('회원가입 성공');
|
||||
navigate('/loading');
|
||||
|
||||
} catch (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
alert('회원가입 중 오류가 발생했습니다. 다시 시도해주세요.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const registerUser = async (userData) => {
|
||||
try {
|
||||
// 저장된 JWT 토큰 가져오기
|
||||
const token = localStorage.getItem('authToken');
|
||||
|
||||
// 토큰 확인 로그
|
||||
console.log('저장된 토큰:', token ? token.substring(0, 50) + '...' : 'null');
|
||||
|
||||
console.log('회원가입 API 호출:', {
|
||||
url: `${window.__runtime_config__?.USER_URL || 'http://localhost:8081'}/register`,
|
||||
userData,
|
||||
hasToken: !!token
|
||||
});
|
||||
|
||||
const response = await fetch(`${window.__runtime_config__?.USER_URL || 'http://localhost:8081'}/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: userData.name,
|
||||
birthDate: userData.birthDate,
|
||||
occupation: userData.occupation
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('회원가입 API 에러:', response.status, errorText);
|
||||
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('회원가입 API 성공:', result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('회원가입 API 오류:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="register-page">
|
||||
<Header title="회원정보 입력" showBack backPath="/" />
|
||||
<div className="content">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">이름</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="form-input"
|
||||
placeholder="이름을 입력하세요"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">생년월일</label>
|
||||
<input
|
||||
type="date"
|
||||
name="birthDate"
|
||||
className="form-input"
|
||||
value={formData.birthDate}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">직업군</label>
|
||||
<select
|
||||
name="occupation"
|
||||
className="form-select"
|
||||
value={formData.occupation}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
>
|
||||
<option value="">직업을 선택하세요</option>
|
||||
<option value="개발">IT 개발</option>
|
||||
<option value="PM">PM (Product Manager)</option>
|
||||
<option value="마케팅">마케팅</option>
|
||||
<option value="영업">영업</option>
|
||||
<option value="인프라운영">인프라운영</option>
|
||||
<option value="고객상담">고객상담</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '가입 중...' : '가입하기'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RegisterPage;
|
||||
7
vite.config.js
Normal file
7
vite.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user