Compare commits

..

No commits in common. "edc189a7e01974e7e544abfeecf1483c32238e8e" and "ac9e7125d1e77bc8fa7ce6d9e39ef72562805cde" have entirely different histories.

88 changed files with 1265 additions and 5514 deletions

View File

@ -1,47 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js
.next
out
# Testing
coverage
.nyc_output
playwright-report
test-results
# Misc
.DS_Store
*.pem
# Debug
*.log
# Local env files
.env*.local
.env
# Vercel
.vercel
# TypeScript
*.tsbuildinfo
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.swp
*.swo
# Deployment
deployment/k8s
claude
claudedocs

3
.idea/.gitignore generated vendored
View File

@ -1,3 +0,0 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/misc.xml generated
View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="23" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated
View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kt-event-marketing-fe.iml" filepath="$PROJECT_DIR$/.idea/kt-event-marketing-fe.iml" />
</modules>
</component>
</project>

7
.idea/vcs.xml generated
View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
.serena/.gitignore vendored
View File

@ -1 +0,0 @@
/cache

View File

@ -1,83 +0,0 @@
# list of languages for which language servers are started; choose from:
# al bash clojure cpp csharp csharp_omnisharp
# dart elixir elm erlang fortran go
# haskell java julia kotlin lua markdown
# nix perl php python python_jedi r
# rego ruby ruby_solargraph rust scala swift
# terraform typescript typescript_vts zig
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# Special requirements:
# - csharp: Requires the presence of a .sln file in the project folder.
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- typescript
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "fe-kt-event-marketing"

View File

@ -1,47 +0,0 @@
# Stage 1: Build Stage
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# Copy source code
COPY . .
# Build Next.js application
RUN npm run build
# Stage 2: Production Stage with Nginx
FROM nginx:alpine
# Install Node.js for Next.js standalone mode
RUN apk add --no-cache nodejs
# Copy nginx configuration
COPY deployment/container/nginx.conf /etc/nginx/nginx.conf
# Copy built Next.js application from builder
COPY --from=builder /app/.next/standalone /app
COPY --from=builder /app/.next/static /app/.next/static
COPY --from=builder /app/public /app/public
# Create health check endpoint
RUN echo '<!DOCTYPE html><html><body><h1>OK</h1></body></html>' > /usr/share/nginx/html/health.html
# Copy runtime-env.js template (will be replaced by ConfigMap in K8s)
COPY public/runtime-env.js /usr/share/nginx/html/runtime-env.js
# Create startup script
RUN echo '#!/bin/sh' > /start.sh && \
echo 'cd /app && HOSTNAME=0.0.0.0 PORT=3000 node server.js &' >> /start.sh && \
echo 'sleep 3' >> /start.sh && \
echo 'nginx -g "daemon off;"' >> /start.sh && \
chmod +x /start.sh
EXPOSE 8080
CMD ["/bin/sh", "/start.sh"]

449
README.md
View File

@ -1,449 +0,0 @@
# KT 이벤트 파트너
- AI 기반 소상공인 마케팅 지원 서비스
## 1. 소개
KT AI 기반 소상공인 이벤트 자동 생성 서비스는 소상공인이 AI를 활용하여 효과적인 이벤트를 쉽게 기획하고 관리할 수 있도록 지원하는 플랫폼입니다.
매장 정보와 AI 추천을 기반으로 이벤트를 생성하고, SNS 콘텐츠를 자동 생성하며, 다양한 채널로 배포하고, 실시간으로 성과를 분석할 수 있습니다.
### 1.1 핵심 기능
- **AI 기반 이벤트 추천**: 매장 정보, 업종, 지역 트렌드를 분석하여 최적의 이벤트 아이디어 제공
- **자동 콘텐츠 생성**: AI가 생성한 이벤트 내용을 바탕으로 SNS 이미지 자동 생성
- **다중 채널 배포**: 네이버, 카카오톡, 인스타그램 등 다양한 SNS 채널로 원클릭 배포
- **참여자 관리**: 이벤트 참여자 접수 및 당첨자 추첨 기능
- **실시간 성과 분석**: 조회수, 참여율, ROI 등 실시간 대시보드 제공
### 1.2 MVP 산출물
- **발표자료**: [추가 예정]
- **설계결과**:
- [유저스토리](design/userstory.md)
- [논리 아키텍처](design/backend/logical/logical-architecture.md)
- [UI/UX 설계서](design/frontend/uiux-design.md)
- [API 설계서](design/backend/api/API-설계서.md)
- **Git Repo**:
- **프론트엔드**: https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing-fe.git
- **백엔드**: https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing.git
- **manifest**: [추가 예정]
- **시연 동영상**: [추가 예정]
## 2. 시스템 아키텍처
### 2.1 전체 구조
프론트엔드(Next.js)와 마이크로서비스 백엔드(Spring Boot)로 구성된 애플리케이션입니다.
Event-Driven 아키텍처와 CQRS 패턴을 적용하여 확장성과 성능을 고려한 설계입니다.
### 2.2 마이크로서비스 구성
- **User Service**: 사용자 인증 및 매장정보 관리
- **Event Service**: 이벤트 생성, 수정, 삭제, 조회 및 전체 플로우 오케스트레이션
- **AI Service**: AI 기반 트렌드 분석 및 이벤트 추천
- **Content Service**: SNS 콘텐츠(이미지) 자동 생성
- **Distribution Service**: 다중 채널(네이버, 카카오톡, 인스타그램) 배포
- **Participation Service**: 이벤트 참여자 접수 및 당첨자 관리
- **Analytics Service**: 실시간 성과 분석 및 통합 대시보드
### 2.3 기술 스택
- **프론트엔드**: Next.js 14, React 18, TypeScript, Material UI, React Query, Zustand
- **백엔드**: Spring Boot, Java, Kafka (Event Bus + Job Queue)
- **인프라**: Azure Kubernetes Service (AKS), Azure Container Registry (ACR)
- **CI/CD**: Docker, Kubernetes
- **백킹 서비스**:
- **Database**: PostgreSQL
- **Cache**: Redis
- **Message Queue**: Kafka
## 3. 로컬 실행 가이드
### 3.1 사전 요구사항
- **Node.js**: 18.x 이상
- **npm**: 9.x 이상
- **Git**: 2.x 이상
### 3.2 프로젝트 클론
```bash
git clone https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing-fe.git
cd kt-event-marketing-fe
git clone https://gitea.cbiz.kubepia.net/shared-dg05-dodari/kt-event-marketing.git
cd kt-event-marketing
```
### 3.3 환경 변수 설정
`.env.example` 파일을 복사하여 `.env.local` 파일을 생성합니다:
```bash
cp .env.example .env.local
```
`.env.local` 파일을 열어 백엔드 API 호스트를 설정합니다:
```env
# API Hosts (로컬 개발 환경)
NEXT_PUBLIC_USER_HOST=http://localhost:8081
NEXT_PUBLIC_EVENT_HOST=http://localhost:8080
NEXT_PUBLIC_CONTENT_HOST=http://localhost:8084
NEXT_PUBLIC_AI_HOST=http://localhost:8083
NEXT_PUBLIC_PARTICIPATION_HOST=http://localhost:8084
NEXT_PUBLIC_DISTRIBUTION_HOST=http://localhost:8085
NEXT_PUBLIC_ANALYTICS_HOST=http://localhost:8086
# API Version prefix
NEXT_PUBLIC_API_VERSION=api
```
> **참고**: 백엔드 서비스가 다른 포트나 호스트에서 실행 중이라면 해당 주소로 변경하세요.
### 3.4 의존성 설치
```bash
npm install
```
### 3.5 개발 서버 실행
```bash
npm run dev
```
브라우저에서 [http://localhost:3000](http://localhost:3000)으로 접속하여 애플리케이션을 확인합니다.
### 3.6 빌드 및 타입 체크
```bash
# TypeScript 타입 체크
npm run type-check
# 프로덕션 빌드
npm run build
# 프로덕션 서버 실행
npm run start
```
## 4. Docker 빌드 및 로컬 실행
### 4.1 Docker 이미지 빌드
```bash
docker build -t kt-event-marketing-frontend:latest .
```
### 4.2 Docker 컨테이너 실행
```bash
docker run -p 8080:8080 kt-event-marketing-frontend:latest
```
브라우저에서 [http://localhost:8080](http://localhost:8080)으로 접속합니다.
### 4.3 Health Check 확인
```bash
curl http://localhost:8080/health
```
## 5. Kubernetes 배포 가이드
### 5.1 사전 준비
#### 5.1.1 Azure 로그인
```bash
az login
az account show
```
#### 5.1.2 AKS 자격 증명 설정
```bash
az aks get-credentials --resource-group <리소스그룹명> --name aks-digitalgarage-01
kubectl cluster-info
```
#### 5.1.3 Namespace 생성
```bash
kubectl create namespace kt-event-marketing
```
#### 5.1.4 ImagePullSecret 생성
ACR에서 이미지를 가져오기 위한 시크릿을 생성합니다:
```bash
kubectl create secret docker-registry kt-event-marketing-frontend \
--docker-server=acrdigitalgarage01.azurecr.io \
--docker-username=<ACR_USERNAME> \
--docker-password=<ACR_PASSWORD> \
--namespace=kt-event-marketing
```
### 5.2 이미지 빌드 및 푸시
#### 5.2.1 이미지 빌드
```bash
docker build -t kt-event-marketing-frontend:latest .
```
#### 5.2.2 ACR 태깅
```bash
docker tag kt-event-marketing-frontend:latest \
acrdigitalgarage01.azurecr.io/kt-event-marketing/kt-event-marketing-frontend:latest
```
#### 5.2.3 ACR 로그인
```bash
az acr login --name acrdigitalgarage01
```
#### 5.2.4 이미지 푸시
```bash
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/kt-event-marketing-frontend:latest
```
### 5.3 Kubernetes 배포
#### 5.3.1 ConfigMap 수정 (선택 사항)
배포 환경에 맞게 백엔드 API 호스트를 수정합니다:
```bash
# ConfigMap 편집
kubectl edit configmap cm-kt-event-marketing-frontend -n kt-event-marketing
```
또는 `deployment/k8s/configmap.yaml` 파일을 직접 수정한 후 적용합니다.
#### 5.3.2 매니페스트 적용
```bash
# 모든 매니페스트 일괄 적용
kubectl apply -f deployment/k8s/
```
또는 개별적으로 적용:
```bash
kubectl apply -f deployment/k8s/configmap.yaml
kubectl apply -f deployment/k8s/service.yaml
kubectl apply -f deployment/k8s/deployment.yaml
kubectl apply -f deployment/k8s/ingress.yaml
```
### 5.4 배포 확인
#### 5.4.1 Pod 상태 확인
```bash
kubectl get pods -n kt-event-marketing -l app=kt-event-marketing-frontend
```
#### 5.4.2 Service 확인
```bash
kubectl get svc kt-event-marketing-frontend -n kt-event-marketing
```
#### 5.4.3 Ingress 확인
```bash
kubectl get ingress kt-event-marketing-frontend -n kt-event-marketing
```
#### 5.4.4 로그 확인
```bash
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --tail=100 -f
```
### 5.5 접속 테스트
1. 프론트 페이지 주소 구하기:
```
kubens {namespace}
k get svc
```
2. 로그인
- ID : dodari@naver.com
- PW : p1234567890
> **참고**: External IP는 환경에 따라 다를 수 있습니다. 다음 명령으로 확인하세요:
> ```bash
> kubectl get svc ingress-nginx-controller -n ingress-nginx
> ```
## 6. 재배포 가이드
### 6.1 코드 수정 후 재배포
```bash
# 1. 이미지 빌드
docker build -t kt-event-marketing-frontend:latest .
# 2. ACR 태깅
docker tag kt-event-marketing-frontend:latest \
acrdigitalgarage01.azurecr.io/kt-event-marketing/kt-event-marketing-frontend:latest
# 3. 이미지 푸시
docker push acrdigitalgarage01.azurecr.io/kt-event-marketing/kt-event-marketing-frontend:latest
# 4. Kubernetes Deployment 재시작
kubectl rollout restart deployment kt-event-marketing-frontend -n kt-event-marketing
# 5. 롤아웃 상태 확인
kubectl rollout status deployment/kt-event-marketing-frontend -n kt-event-marketing
```
### 6.2 ConfigMap 수정 후 재배포
```bash
# ConfigMap 수정
kubectl apply -f deployment/k8s/configmap.yaml
# Pod 재시작
kubectl rollout restart deployment kt-event-marketing-frontend -n kt-event-marketing
```
### 6.3 롤백
```bash
# 이전 버전으로 롤백
kubectl rollout undo deployment/kt-event-marketing-frontend -n kt-event-marketing
# 롤아웃 히스토리 확인
kubectl rollout history deployment/kt-event-marketing-frontend -n kt-event-marketing
```
## 7. 트러블슈팅
### 7.1 Pod가 Running 상태가 아닌 경우
#### ImagePullBackOff 에러
```bash
# ImagePullSecret 확인
kubectl get secret kt-event-marketing-frontend -n kt-event-marketing
# Secret 재생성
kubectl delete secret kt-event-marketing-frontend -n kt-event-marketing
kubectl create secret docker-registry kt-event-marketing-frontend \
--docker-server=acrdigitalgarage01.azurecr.io \
--docker-username=<ACR_USERNAME> \
--docker-password=<ACR_PASSWORD> \
--namespace=kt-event-marketing
```
#### CrashLoopBackOff 에러
```bash
# Pod 로그 확인
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --tail=100
# 이전 컨테이너 로그 확인
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --previous
```
### 7.2 Ingress로 접속이 안 되는 경우
```bash
# Ingress Controller 확인
kubectl get svc ingress-nginx-controller -n ingress-nginx
# Ingress 상태 확인
kubectl describe ingress kt-event-marketing-frontend -n kt-event-marketing
# Service Endpoint 확인
kubectl get endpoints kt-event-marketing-frontend -n kt-event-marketing
```
### 7.3 ConfigMap이 적용되지 않는 경우
```bash
# Pod 내부 확인
kubectl exec -n kt-event-marketing -it $(kubectl get pod -n kt-event-marketing -l app=kt-event-marketing-frontend -o jsonpath='{.items[0].metadata.name}') \
-- cat /usr/share/nginx/html/runtime-env.js
```
## 8. 개발 가이드
### 8.1 프로젝트 구조
```
kt-event-marketing-fe/
├── src/
│ ├── app/ # Next.js App Router 페이지
│ ├── components/ # React 컴포넌트
│ ├── hooks/ # Custom React Hooks
│ ├── lib/ # 유틸리티 함수 및 설정
│ ├── services/ # API 서비스 레이어
│ ├── store/ # Zustand 상태 관리
│ └── types/ # TypeScript 타입 정의
├── public/ # 정적 파일
├── design/ # 설계 문서
├── deployment/ # 배포 관련 파일
│ ├── container/ # Nginx 설정
│ └── k8s/ # Kubernetes 매니페스트
├── Dockerfile # Docker 이미지 빌드 파일
└── package.json # 프로젝트 의존성
```
### 8.2 코딩 컨벤션
- **TypeScript**: 타입 안정성을 위해 엄격한 타입 체크 사용
- **ESLint**: Next.js 표준 ESLint 설정 적용
- **Prettier**: 코드 포맷팅 자동화
- **Component**: 함수형 컴포넌트 + React Hooks 사용
- **State Management**: Zustand 활용 (전역 상태), React Query (서버 상태)
### 8.3 API 통신
- **Axios**: HTTP 클라이언트
- **React Query**: 서버 상태 관리 및 캐싱
- **환경 변수**: `NEXT_PUBLIC_*` 접두사 사용
### 8.4 스타일링
- **Material UI**: 주요 UI 컴포넌트 라이브러리
- **Emotion**: CSS-in-JS (Material UI 기본 엔진)
- **반응형 디자인**: Mobile-first 접근 (375px 기준)
## 9. 참고 자료
### 9.1 내부 문서
- [유저스토리](design/userstory.md)
- [논리 아키텍처](design/backend/logical/logical-architecture.md)
- [UI/UX 설계서](design/frontend/uiux-design.md)
- [API 매핑 설계서](design/frontend/api-mapping.md)
- [스타일 가이드](design/frontend/style-guide.md)
- [Kubernetes 배포 가이드](deployment/k8s/deploy-k8s-guide.md)
### 9.2 기술 문서
- [Next.js Documentation](https://nextjs.org/docs)
- [React Documentation](https://react.dev/)
- [Material UI](https://mui.com/)
- [React Query](https://tanstack.com/query/latest)
- [Zustand](https://zustand-demo.pmnd.rs/)
## 10. 팀
- **Product Owner**: 김채리 (체리)
- **Scrum Master**: 김도연 (도다리)
- **DevOps Engineer**: 장원호 (티모)
- **Analytics Service Developer**: 양효원 (와와)
- **AI Service Developer**: 박세원 (뚜이)
- **Event Service Developer**: 송하영 (홍차)
- **Distribution Service Developer**: 이선민 (복치)

247
ROUTES.md
View File

@ -1,247 +0,0 @@
# 📍 KT 이벤트 마케팅 - 전체 페이지 라우트 목록
## 🔐 인증 라우트 (auth)
**Route Group**: `(auth)` - AuthGuard 미적용
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|--------|-----------|------|-------------|
| `/login` | `(auth)/login/page.tsx` | 로그인 페이지 | ✅ 완료 |
| `/register` | `(auth)/register/page.tsx` | 회원가입 페이지 | ✅ 완료 |
| `/logout` | `(auth)/logout/page.tsx` | 로그아웃 페이지 | ✅ 완료 |
---
## 🏠 메인 애플리케이션 라우트 (main)
**Route Group**: `(main)` - AuthGuard 적용, BottomNavigation 포함
### 대시보드 & 분석
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|--------|-----------|------|-------------|
| `/` | `(main)/page.tsx` | 메인 대시보드 | ✅ 완료 |
| `/analytics` | `(main)/analytics/page.tsx` | 전체 분석 페이지 | ✅ 완료 |
| `/profile` | `(main)/profile/page.tsx` | 사용자 프로필 | ✅ 완료 |
### 이벤트 관리
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|--------|-----------|------|-------------|
| `/events` | `(main)/events/page.tsx` | 이벤트 목록 | ✅ 완료 |
| `/events/create` | `(main)/events/create/page.tsx` | 이벤트 생성 (Funnel 방식) | ✅ 완료 |
| `/events/create/content-test` | `(main)/events/create/content-test/page.tsx` | 콘텐츠 테스트 페이지 | ✅ 완료 |
### 이벤트 상세 (동적 라우트)
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|--------|-----------|------|-------------|
| `/events/[eventId]` | `(main)/events/[eventId]/page.tsx` | 이벤트 상세 페이지 | ✅ 완료 |
| `/events/[eventId]/participate` | `(main)/events/[eventId]/participate/page.tsx` | 이벤트 참여 페이지 | ✅ 완료 |
| `/events/[eventId]/participants` | `(main)/events/[eventId]/participants/page.tsx` | 참여자 관리 페이지 | ✅ 완료 |
| `/events/[eventId]/draw` | `(main)/events/[eventId]/draw/page.tsx` | 추첨 페이지 | ✅ 완료 |
### 테스트 페이지
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|--------|-----------|------|-------------|
| `/test/analytics/[eventId]` | `(main)/test/analytics/[eventId]/page.tsx` | 분석 테스트 페이지 | ✅ 완료 |
---
## 🔌 API 라우트
### User API (v1)
| 라우트 | 파일 경로 | HTTP Method |
|--------|-----------|-------------|
| `/api/v1/users/login` | `api/v1/users/login/route.ts` | POST |
| `/api/v1/users/logout` | `api/v1/users/logout/route.ts` | POST |
| `/api/v1/users/register` | `api/v1/users/register/route.ts` | POST |
| `/api/v1/users/profile` | `api/v1/users/profile/route.ts` | GET, PUT |
| `/api/v1/users/password` | `api/v1/users/password/route.ts` | PUT |
### Event API (v1)
| 라우트 | 파일 경로 | HTTP Method |
|--------|-----------|-------------|
| `/api/v1/events/objectives` | `api/v1/events/objectives/route.ts` | POST |
| `/api/v1/events/[eventId]/participate` | `api/v1/events/[eventId]/participate/route.ts` | POST |
| `/api/v1/events/[eventId]/participants` | `api/v1/events/[eventId]/participants/route.ts` | GET |
| `/api/v1/events/[eventId]/participants/[participantId]` | `api/v1/events/[eventId]/participants/[participantId]/route.ts` | GET, PATCH |
| `/api/v1/events/[eventId]/draw-winners` | `api/v1/events/[eventId]/draw-winners/route.ts` | POST |
| `/api/v1/events/[eventId]/winners` | `api/v1/events/[eventId]/winners/route.ts` | GET |
### Participation API (프록시)
백엔드 Participation Service로 프록시하는 API 라우트
| 라우트 | 파일 경로 | HTTP Method |
|--------|-----------|-------------|
| `/api/participations/[eventId]/participate` | `api/participations/[eventId]/participate/route.ts` | POST |
| `/api/participations/[eventId]/participants` | `api/participations/[eventId]/participants/route.ts` | GET |
| `/api/participations/[eventId]/participants/[participantId]` | `api/participations/[eventId]/participants/[participantId]/route.ts` | GET, PATCH |
| `/api/participations/[eventId]/draw-winners` | `api/participations/[eventId]/draw-winners/route.ts` | POST |
| `/api/participations/[eventId]/winners` | `api/participations/[eventId]/winners/route.ts` | GET |
### Distribution API
| 라우트 | 파일 경로 | HTTP Method |
|--------|-----------|-------------|
| `/api/distribution/[eventId]/status` | `api/distribution/[eventId]/status/route.ts` | GET |
---
## 📊 이벤트 생성 Funnel Steps
`/events/create` 페이지는 `@use-funnel/browser`를 사용한 Funnel 방식으로 다음 단계들을 포함합니다:
### Step 흐름
```
objective → recommendation → channel → contentPreview/contentEdit → approval
```
### 각 단계 상세
| Step | Component | URL Query | 설명 |
|------|-----------|-----------|------|
| 1 | `ObjectiveStep` | `?event-creation.step=objective` | 이벤트 목적 선택 (신규고객/재방문/매출/인지도) |
| 2 | `RecommendationStep` | `?event-creation.step=recommendation` | AI 추천 이벤트 3가지 옵션 제시 및 선택 |
| 3 | `ChannelStep` | `?event-creation.step=channel` | 배포 채널 선택 (우리동네TV, 지니TV, SNS 등) |
| 4a | `ContentPreviewStep` | `?event-creation.step=contentPreview` | 콘텐츠 미리보기 (채널에 콘텐츠 필요 시) |
| 4b | `ContentEditStep` | `?event-creation.step=contentEdit` | 콘텐츠 편집 (사용자가 수정 선택 시) |
| 5 | `ApprovalStep` | `?event-creation.step=approval` | 최종 이벤트 검토 및 승인 |
### Step 분기 로직
- **우리동네TV, 지니TV, SNS** 채널 선택 시 → `contentPreview` 단계로 이동
- 기타 채널만 선택 시 → `approval` 단계로 바로 이동
- `contentPreview`에서 "수정하기" 선택 시 → `contentEdit` 단계로 이동
---
## 🔒 인증 보호 정책
### AuthGuard 적용
- **(main)** Route Group: **AuthGuard 적용**
- 로그인 필수
- `isAuthenticated && user` 체크
- 미인증 시 자동으로 `/login`으로 리다이렉트
- BottomNavigation 포함
### AuthGuard 미적용
- **(auth)** Route Group: 인증 없이 접근 가능
- `/login`, `/register`, `/logout`
### 구현 위치
- AuthGuard 컴포넌트: `src/features/auth/ui/AuthGuard.tsx`
- Layout 적용: `src/app/(main)/layout.tsx`
---
## 🗂️ 프로젝트 구조
```
src/app/
├── (auth)/ # 인증 관련 페이지 (AuthGuard 미적용)
│ ├── login/
│ ├── register/
│ └── logout/
├── (main)/ # 메인 애플리케이션 (AuthGuard 적용)
│ ├── page.tsx # 대시보드
│ ├── layout.tsx # AuthGuard + BottomNavigation
│ ├── analytics/
│ ├── profile/
│ ├── events/
│ │ ├── page.tsx
│ │ ├── create/
│ │ │ ├── page.tsx # Funnel 메인
│ │ │ ├── steps/ # Funnel 각 단계 컴포넌트
│ │ │ └── content-test/
│ │ └── [eventId]/
│ │ ├── page.tsx
│ │ ├── participate/
│ │ ├── participants/
│ │ └── draw/
│ └── test/
│ └── analytics/[eventId]/
└── api/ # API 라우트 (프록시)
├── v1/
│ ├── users/
│ └── events/
├── participations/
└── distribution/
```
---
## 📝 참고사항
### Next.js 14 App Router 특징
- Route Groups: `(auth)`, `(main)` - URL에 포함되지 않는 논리적 그룹
- Dynamic Routes: `[eventId]`, `[participantId]` - 동적 세그먼트
- API Routes: `route.ts` 파일로 API 엔드포인트 구현
- Layouts: 각 라우트 그룹별 공통 레이아웃 적용
### 최근 변경사항
- ✅ AI 추천 로직 변경: Job 폴링 방식 → POST `/events/{eventId}/ai-recommendations` 직접 호출
- ✅ AuthGuard 적용: (main) 그룹 전체에 로그인 필수 적용
- ✅ 로그인 페이지: 지원하지 않는 기능 Toast → Modal 변경
- ✅ 320px 모바일 최적화: 모든 페이지 완료 (2025-10-30)
---
## 📱 320px 모바일 최적화 내역
### 최적화 내용
1. **Container Padding 조정**: `px: { xs: 6 }``px: { xs: 2 }`
- 320px 화면에서 좌우 패딩 48px → 16px로 줄여 콘텐츠 영역 확보
- 실제 콘텐츠 영역: 224px → 288px (29% 증가)
2. **Grid Spacing 조정**: 큰 spacing 값을 반응형으로 조정
- `spacing={6}``spacing={{ xs: 2, sm: 6 }}`
3. **Typography 크기 조정**: 작은 화면에서 적절한 폰트 크기 적용
- 제목: `fontSize: '2rem'``fontSize: { xs: '1.5rem', sm: '2rem' }`
- 본문: `fontSize: '1rem'``fontSize: { xs: '0.875rem', sm: '1rem' }`
4. **Vertical Spacing 대폭 축소**: 320px에서 과도한 상하 여백 문제 해결
- **Container Padding**: `pt: 8, pb: 8``pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }` (64px → 32px)
- **Box Padding Bottom**: `pb: 10``pb: { xs: 4, sm: 10 }` (80px → 32px)
- **Large Margin Bottom**: `mb: 10``mb: { xs: 4, sm: 10 }` (80px → 32px)
- **Medium Margin Bottom**: `mb: 8``mb: { xs: 3, sm: 8 }` (64px → 24px)
- **CardContent Padding**:
- `p: 8``p: { xs: 3, sm: 8 }` (64px → 24px)
- `p: 6``p: { xs: 3, sm: 6 }` (48px → 24px)
- `p: 5``p: { xs: 2.5, sm: 5 }` (40px → 20px)
- `py: 6``pt: { xs: 3, sm: 6 }, pb: { xs: 3, sm: 6 }` (상하 각 48px → 24px)
- `py: { xs: 4, sm: 6 }``pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 }` (상하 각 32px → 16px)
- `py: { xs: 2, sm: 6 }``pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }` (상하 각 16px → 12px)
- **총 효과**: 상하 여백이 평균 50-60% 감소하여 320px 화면에서 콘텐츠 밀도 향상
5. **Margin/Gap 조정**: 불필요하게 큰 여백 줄임
- `gap: 3``gap: { xs: 2, sm: 3 }`
### 영향받은 페이지
- 모든 인증 페이지 (login, register, logout)
- 모든 이벤트 생성 단계 (ObjectiveStep, RecommendationStep, ChannelStep, ContentPreviewStep, ContentEditStep, ApprovalStep)
- 이벤트 상세 페이지들 (detail, participate, participants, draw)
- 대시보드 및 프로필 페이지
### 참여자 관리 페이지 추가 최적화 (2025-10-30)
**검색 및 필터 UI 최적화**:
- Search TextField: 아이콘 18px, 폰트 0.75rem, 패딩 8px
- FilterList 아이콘: 28px → 20px
- FormControl: minWidth 100px/90px, 폰트 0.75rem, 패딩 8px
- MenuItem: 폰트 0.75rem
**버튼 최적화**:
- 아이콘: 20px → 16px
- 폰트: 0.875rem → 0.7rem
- 패딩: px 4→1.5, py 1.5→0.75
- 텍스트 단축: "엑셀 다운로드"→"엑셀", "당첨자 추첨"→"추첨" (320px만)
**Pagination 최적화**:
- 폰트: 1rem → 0.75rem
- 버튼 크기: 32px → 26px
- 아이콘: 1.5rem → 1rem
- 여백: 4px → 2px
**통계 카드**:
- Grid: xs={6} → xs={4} (한 줄에 3개 표시)
- 카드 내부 요소 크기 축소 (아이콘, 폰트, 패딩)
### 빌드 확인
```bash
npm run build
# ✅ 빌드 성공 (2025-10-30)
```

View File

@ -1,357 +0,0 @@
# User API 타입 변경사항 (2025-01-30)
## 📋 주요 변경사항 요약
### **userId 및 storeId 타입 변경: `number``string` (UUID)**
백엔드 API 스펙에 따라 userId와 storeId가 UUID 형식의 문자열로 변경되었습니다.
| 항목 | 기존 (Old) | 변경 (New) |
|------|-----------|-----------|
| **userId** | `number` (예: `1`) | `string` (예: `"550e8400-e29b-41d4-a716-446655440000"`) |
| **storeId** | `number` (예: `1`) | `string` (예: `"6ba7b810-9dad-11d1-80b4-00c04fd430c8"`) |
---
## 🔄 영향을 받는 인터페이스
### LoginResponse
**Before:**
```typescript
interface LoginResponse {
token: string;
userId: number;
userName: string;
role: string;
email: string;
}
```
**After:**
```typescript
interface LoginResponse {
token: string;
userId: string; // UUID format
userName: string;
role: string;
email: string;
}
```
### RegisterResponse
**Before:**
```typescript
interface RegisterResponse {
token: string;
userId: number;
userName: string;
storeId: number;
storeName: string;
}
```
**After:**
```typescript
interface RegisterResponse {
token: string;
userId: string; // UUID format
userName: string;
storeId: string; // UUID format
storeName: string;
}
```
### ProfileResponse
**Before:**
```typescript
interface ProfileResponse {
userId: number;
userName: string;
phoneNumber: string;
email: string;
role: string;
storeId: number;
storeName: string;
industry: string;
address: string;
businessHours: string;
createdAt: string;
lastLoginAt: string;
}
```
**After:**
```typescript
interface ProfileResponse {
userId: string; // UUID format
userName: string;
phoneNumber: string;
email: string;
role: string;
storeId: string; // UUID format
storeName: string;
industry: string;
address: string;
businessHours: string;
createdAt: string;
lastLoginAt: string;
}
```
### User
**Before:**
```typescript
interface User {
userId: number;
userName: string;
email: string;
role: string;
phoneNumber?: string;
storeId?: number;
storeName?: string;
industry?: string;
address?: string;
businessHours?: string;
}
```
**After:**
```typescript
interface User {
userId: string; // UUID format
userName: string;
email: string;
role: string;
phoneNumber?: string;
storeId?: string; // UUID format
storeName?: string;
industry?: string;
address?: string;
businessHours?: string;
}
```
---
## 📝 수정된 파일 목록
### 1. Type Definitions
- ✅ `src/entities/user/model/types.ts`
- `LoginResponse.userId`: `number``string`
- `RegisterResponse.userId`: `number``string`
- `RegisterResponse.storeId`: `number``string`
- `ProfileResponse.userId`: `number``string`
- `ProfileResponse.storeId`: `number``string`
- `User.userId`: `number``string`
- `User.storeId`: `number``string`
### 2. Stores
- ✅ `src/stores/authStore.ts`
- `User.id`: UUID 주석 추가
### 3. Components
- ✅ No changes required (타입 추론 사용)
- `src/features/auth/model/useAuth.ts`
- `src/app/(auth)/login/page.tsx`
- `src/app/(auth)/register/page.tsx`
---
## 🧪 API 응답 예시
### 로그인 응답
**Before:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": 1,
"userName": "홍길동",
"role": "USER",
"email": "user@example.com"
}
```
**After:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"role": "USER",
"email": "user@example.com"
}
```
### 회원가입 응답
**Before:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": 1,
"userName": "홍길동",
"storeId": 1,
"storeName": "홍길동 고깃집"
}
```
**After:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"storeId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"storeName": "홍길동 고깃집"
}
```
### 프로필 조회 응답
**Before:**
```json
{
"userId": 1,
"userName": "홍길동",
"phoneNumber": "01012345678",
"email": "user@example.com",
"role": "USER",
"storeId": 1,
"storeName": "홍길동 고깃집",
"industry": "restaurant",
"address": "서울특별시 강남구",
"businessHours": "09:00-18:00",
"createdAt": "2025-01-01T00:00:00",
"lastLoginAt": "2025-01-10T12:00:00"
}
```
**After:**
```json
{
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"phoneNumber": "01012345678",
"email": "user@example.com",
"role": "USER",
"storeId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"storeName": "홍길동 고깃집",
"industry": "restaurant",
"address": "서울특별시 강남구",
"businessHours": "09:00-18:00",
"createdAt": "2025-01-01T00:00:00",
"lastLoginAt": "2025-01-10T12:00:00"
}
```
---
## 🚨 주의사항
### 1. localStorage 초기화 필요
기존에 number 타입으로 저장된 사용자 정보가 있다면 localStorage를 초기화해야 합니다:
```javascript
// 브라우저 콘솔에서 실행
localStorage.removeItem('accessToken');
localStorage.removeItem('user');
```
### 2. UUID 형식
- UUID는 표준 UUID v4 형식입니다: `550e8400-e29b-41d4-a716-446655440000`
- 하이픈(`-`)을 포함한 36자 문자열
- 비교 시 대소문자 구분 없음 (일반적으로 소문자 사용)
### 3. 기존 Mock 데이터
기존에 number 타입으로 작성된 Mock 데이터는 UUID 문자열로 변경해야 합니다:
**Before:**
```typescript
const mockUser = {
userId: 1,
storeId: 1,
// ...
};
```
**After:**
```typescript
const mockUser = {
userId: "550e8400-e29b-41d4-a716-446655440000",
storeId: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
// ...
};
```
### 4. 하위 호환성
- 이전 number 타입과는 호환되지 않습니다
- 기존 세션은 모두 무효화됩니다
- 사용자는 다시 로그인해야 합니다
---
## ✅ 마이그레이션 체크리스트
- [x] TypeScript 인터페이스 업데이트
- [x] 타입 정의 파일 수정 완료
- [x] 빌드 테스트 통과
- [ ] localStorage 초기화 (사용자)
- [ ] 개발 서버 테스트 (사용자)
- [ ] 실제 API 연동 테스트 (사용자)
---
## 🔗 관련 문서
- API 문서: http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/users/swagger-ui/index.html
- OpenAPI Spec: http://kt-event-marketing-api.20.214.196.128.nip.io/api/v1/users/v3/api-docs
---
## 📌 변경 이유
**백엔드 아키텍처 개선:**
- 분산 시스템에서 ID 충돌 방지
- 데이터베이스 독립적인 고유 식별자
- 보안 강화 (ID 추측 불가)
- 마이크로서비스 간 데이터 통합 용이
**UUID의 장점:**
- 전역적으로 고유한 식별자 (Globally Unique Identifier)
- Auto-increment ID의 한계 극복
- 분산 환경에서 중앙 조정 없이 생성 가능
- 보안성 향상 (순차적 ID 노출 방지)
---
## 🔄 롤백 방법
만약 이전 버전으로 돌아가야 한다면:
1. Git을 통한 코드 복원:
```bash
git log --oneline # 커밋 찾기
git revert <commit-hash> # 또는 특정 커밋으로 복원
```
2. localStorage 초기화:
```javascript
localStorage.removeItem('accessToken');
localStorage.removeItem('user');
```
3. 개발 서버 재시작:
```bash
npm run dev
```
---
**문서 작성일**: 2025-01-30
**마지막 업데이트**: 2025-01-30
**변경 적용 버전**: v1.1.0

View File

@ -1,597 +0,0 @@
# User API 연동 현황
## 📋 연동 완료 요약
User API는 **완전히 구현되어 있으며**, 로그인 및 회원가입 기능이 정상적으로 작동합니다.
### ✅ 구현 완료 항목
1. **API 클라이언트 설정**
- Gateway를 통한 백엔드 직접 연동
- 토큰 기반 인증 시스템
- Request/Response 인터셉터
2. **타입 정의**
- LoginRequest/Response
- RegisterRequest/Response
- ProfileResponse
- User 및 AuthState 인터페이스
3. **인증 로직**
- useAuth 커스텀 훅
- AuthProvider Context
- localStorage 기반 세션 관리
4. **UI 페이지**
- 로그인 페이지 (/login)
- 회원가입 페이지 (/register)
- 3단계 회원가입 플로우
---
## 🏗️ 아키텍처 구조
```
프론트엔드 Gateway 백엔드
┌─────────────┐ ┌────────┐ ┌──────────┐
│ │ HTTP │ │ HTTP │ │
│ Browser ├────────────>│Gateway ├─────────────>│ User API │
│ │<────────────┤ │<─────────────┤ │
└─────────────┘ JSON+JWT └────────┘ JSON+JWT └──────────┘
```
### API 클라이언트 설정
```typescript
// src/shared/api/client.ts
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
const API_HOSTS = {
user: GATEWAY_HOST,
event: GATEWAY_HOST,
content: GATEWAY_HOST,
ai: GATEWAY_HOST,
participation: GATEWAY_HOST,
distribution: GATEWAY_HOST,
analytics: GATEWAY_HOST,
};
// User API는 apiClient를 통해 직접 Gateway에 연결
export const apiClient: AxiosInstance = axios.create({
baseURL: API_HOSTS.user,
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
```
**💡 프록시 라우트 불필요**: User API는 Next.js 프록시를 거치지 않고 브라우저에서 Gateway로 직접 요청합니다.
---
## 📡 User API 엔드포인트
### 1. 로그인
```http
POST /api/v1/users/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password123"
}
```
**응답:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"role": "USER",
"email": "user@example.com"
}
```
### 2. 회원가입
```http
POST /api/v1/users/register
Content-Type: application/json
{
"name": "홍길동",
"phoneNumber": "01012345678",
"email": "user@example.com",
"password": "password123",
"storeName": "홍길동 고깃집",
"industry": "restaurant",
"address": "서울특별시 강남구",
"businessHours": "09:00-18:00"
}
```
**응답:**
```json
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"storeId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"storeName": "홍길동 고깃집"
}
```
### 3. 로그아웃
```http
POST /api/v1/users/logout
Authorization: Bearer {token}
```
**응답:**
```json
{
"success": true,
"message": "로그아웃되었습니다"
}
```
### 4. 프로필 조회
```http
GET /api/v1/users/profile
Authorization: Bearer {token}
```
**응답:**
```json
{
"userId": "550e8400-e29b-41d4-a716-446655440000",
"userName": "홍길동",
"phoneNumber": "01012345678",
"email": "user@example.com",
"role": "USER",
"storeId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"storeName": "홍길동 고깃집",
"industry": "restaurant",
"address": "서울특별시 강남구",
"businessHours": "09:00-18:00",
"createdAt": "2025-01-01T00:00:00",
"lastLoginAt": "2025-01-10T12:00:00"
}
```
### 5. 프로필 수정
```http
PUT /api/v1/users/profile
Authorization: Bearer {token}
Content-Type: application/json
{
"name": "홍길동",
"phoneNumber": "01012345678",
"storeName": "홍길동 고깃집",
"industry": "restaurant",
"address": "서울특별시 강남구",
"businessHours": "09:00-18:00"
}
```
### 6. 비밀번호 변경
```http
PUT /api/v1/users/password
Authorization: Bearer {token}
Content-Type: application/json
{
"currentPassword": "oldpassword",
"newPassword": "newpassword123"
}
```
---
## 🔐 인증 플로우
### 로그인 플로우
```
1. 사용자가 이메일/비밀번호 입력
2. userApi.login() 호출
3. 서버에서 JWT 토큰 발급
4. localStorage에 토큰 저장
5. userApi.getProfile() 호출 (storeId 포함된 전체 정보 획득)
6. localStorage에 User 정보 저장
7. AuthContext 상태 업데이트
8. 메인 페이지로 리디렉션
```
### 회원가입 플로우
```
1. 3단계 폼 작성
- Step 1: 계정 정보 (이메일, 비밀번호)
- Step 2: 개인 정보 (이름, 전화번호)
- Step 3: 사업장 정보 (상호명, 업종, 주소 등)
2. userApi.register() 호출
3. 서버에서 사용자 생성 및 JWT 토큰 발급
4. localStorage에 토큰 및 User 정보 저장
5. AuthContext 상태 업데이트
6. 회원가입 완료 다이얼로그 표시
7. 메인 페이지로 리디렉션
```
### 로그아웃 플로우
```
1. userApi.logout() 호출
2. 서버에서 세션 무효화 (실패해도 계속 진행)
3. localStorage에서 토큰 및 User 정보 삭제
4. AuthContext 상태 초기화
5. 로그인 페이지로 리디렉션
```
---
## 📂 파일 구조
### API Layer
```
src/entities/user/
├── api/
│ └── userApi.ts # User API 서비스 함수
├── model/
│ └── types.ts # TypeScript 타입 정의
└── index.ts # Public exports
```
### Features Layer
```
src/features/auth/
├── model/
│ ├── useAuth.ts # 인증 커스텀 훅
│ └── AuthProvider.tsx # Context Provider
└── index.ts # Public exports
```
### Pages
```
src/app/(auth)/
├── login/
│ └── page.tsx # 로그인 페이지
└── register/
└── page.tsx # 회원가입 페이지 (3단계 플로우)
```
### Shared
```
src/shared/api/
├── client.ts # Axios 클라이언트 설정
└── index.ts # Public exports
src/stores/
└── authStore.ts # Zustand 인증 스토어 (참고용)
```
---
## 🔑 주요 코드 구조
### 1. User API Service
**src/entities/user/api/userApi.ts:**
```typescript
const USER_API_BASE = '/api/v1/users';
export const userApi = {
login: async (data: LoginRequest): Promise<LoginResponse> => {...},
register: async (data: RegisterRequest): Promise<RegisterResponse> => {...},
logout: async (): Promise<LogoutResponse> => {...},
getProfile: async (): Promise<ProfileResponse> => {...},
updateProfile: async (data: UpdateProfileRequest): Promise<ProfileResponse> => {...},
changePassword: async (data: ChangePasswordRequest): Promise<void> => {...},
};
```
### 2. useAuth Hook
**src/features/auth/model/useAuth.ts:**
```typescript
export const useAuth = () => {
const [authState, setAuthState] = useState<AuthState>({
user: null,
token: null,
isAuthenticated: false,
isLoading: true,
});
// 초기 인증 상태 확인 (localStorage 기반)
useEffect(() => {
const token = localStorage.getItem(TOKEN_KEY);
const userStr = localStorage.getItem(USER_KEY);
if (token && userStr) {
const user = JSON.parse(userStr) as User;
setAuthState({
user,
token,
isAuthenticated: true,
isLoading: false,
});
}
}, []);
// 로그인 함수
const login = async (credentials: LoginRequest) => {
const response = await userApi.login(credentials);
localStorage.setItem(TOKEN_KEY, response.token);
const profile = await userApi.getProfile();
localStorage.setItem(USER_KEY, JSON.stringify(user));
setAuthState({...});
return { success: true, user };
};
// 회원가입, 로그아웃, 프로필 새로고침 함수들...
return {
...authState,
login,
register,
logout,
refreshProfile,
};
};
```
### 3. AuthProvider Context
**src/features/auth/model/AuthProvider.tsx:**
```typescript
const AuthContext = createContext<AuthContextType | null>(null);
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const auth = useAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
};
export const useAuthContext = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuthContext must be used within AuthProvider');
}
return context;
};
```
### 4. RootLayout 적용
**src/app/layout.tsx:**
```typescript
export default function RootLayout({ children }) {
return (
<html lang="ko">
<body>
<MUIThemeProvider>
<ReactQueryProvider>
<AuthProvider>
{children}
</AuthProvider>
</ReactQueryProvider>
</MUIThemeProvider>
</body>
</html>
);
}
```
---
## 🧪 테스트 방법
### 1. 회원가입 테스트
1. 개발 서버 실행
```bash
npm run dev
```
2. 브라우저에서 `/register` 접속
3. 3단계 폼 작성:
- **Step 1**: 이메일 및 비밀번호 입력
- **Step 2**: 이름 및 전화번호 입력 (010-1234-5678 형식)
- **Step 3**: 사업장 정보 입력 및 약관 동의
4. "가입완료" 버튼 클릭
5. 성공 시:
- 회원가입 완료 다이얼로그 표시
- localStorage에 토큰 및 사용자 정보 저장
- 메인 페이지로 리디렉션
### 2. 로그인 테스트
1. 브라우저에서 `/login` 접속
2. 이메일 및 비밀번호 입력
3. "로그인" 버튼 클릭
4. 성공 시:
- localStorage에 토큰 및 사용자 정보 저장
- 메인 페이지로 리디렉션
- 헤더에 사용자 정보 표시
### 3. 로그아웃 테스트
1. 로그인된 상태에서 프로필 페이지 또는 헤더 메뉴 접근
2. "로그아웃" 버튼 클릭
3. 성공 시:
- localStorage에서 토큰 및 사용자 정보 삭제
- 로그인 페이지로 리디렉션
### 4. 디버깅
브라우저 개발자 도구 Console에서 다음 로그 확인:
```
📞 전화번호 변환: 010-1234-5678 -> 01012345678
📦 회원가입 요청 데이터: {...}
🚀 registerUser 함수 호출
📥 registerUser 결과: {...}
✅ 회원가입 성공: {...}
💾 localStorage에 토큰과 사용자 정보 저장 완료
```
---
## 🚨 주의사항
### 1. 전화번호 형식 변환
- **UI 입력**: `010-1234-5678` (하이픈 포함)
- **API 전송**: `01012345678` (하이픈 제거)
- 회원가입 페이지에서 자동 변환 처리됨
### 2. 토큰 관리
- Access Token은 localStorage에 `accessToken` 키로 저장
- User 정보는 localStorage에 `user` 키로 저장
- 401 응답 시 자동으로 로그인 페이지로 리디렉션
### 3. 프록시 라우트 없음
- User API는 Next.js 프록시를 사용하지 않음
- 브라우저에서 Gateway로 직접 요청
- CORS 설정이 Gateway에서 처리되어야 함
### 4. 로그아웃 에러 처리
- 로그아웃 API 실패해도 로컬 상태는 정리됨
- 서버 에러 발생 시에도 사용자는 정상적으로 로그아웃됨
---
## 📝 타입 정의 요약
```typescript
// 로그인
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
token: string;
userId: string; // UUID format
userName: string;
role: string;
email: string;
}
// 회원가입
interface RegisterRequest {
name: string;
phoneNumber: string; // "01012345678" 형식
email: string;
password: string;
storeName: string;
industry?: string;
address: string;
businessHours?: string;
}
interface RegisterResponse {
token: string;
userId: string; // UUID format
userName: string;
storeId: string; // UUID format
storeName: string;
}
// 프로필
interface ProfileResponse {
userId: string; // UUID format
userName: string;
phoneNumber: string;
email: string;
role: string;
storeId: string; // UUID format
storeName: string;
industry: string;
address: string;
businessHours: string;
createdAt: string;
lastLoginAt: string;
}
// User 상태
interface User {
userId: string; // UUID format
userName: string;
email: string;
role: string;
phoneNumber?: string;
storeId?: string; // UUID format
storeName?: string;
industry?: string;
address?: string;
businessHours?: string;
}
// 인증 상태
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
isLoading: boolean;
}
```
---
## ✅ 체크리스트
- [x] API 클라이언트 설정 완료
- [x] TypeScript 타입 정의 완료
- [x] useAuth Hook 구현 완료
- [x] AuthProvider Context 구현 완료
- [x] 로그인 페이지 구현 완료
- [x] 회원가입 페이지 (3단계) 구현 완료
- [x] localStorage 세션 관리 완료
- [x] Request/Response 인터셉터 설정 완료
- [x] 401 에러 핸들링 완료
- [x] RootLayout에 AuthProvider 적용 완료
- [x] 빌드 테스트 통과 ✅
- [ ] 개발 서버 실행 및 실제 API 테스트 (사용자가 수행)
---
## 📚 관련 파일
### 핵심 파일
- `src/entities/user/api/userApi.ts` - User API 서비스
- `src/entities/user/model/types.ts` - TypeScript 타입 정의
- `src/features/auth/model/useAuth.ts` - 인증 Hook
- `src/features/auth/model/AuthProvider.tsx` - Context Provider
- `src/app/(auth)/login/page.tsx` - 로그인 페이지
- `src/app/(auth)/register/page.tsx` - 회원가입 페이지
- `src/shared/api/client.ts` - Axios 클라이언트 설정
### 참고 파일
- `src/stores/authStore.ts` - Zustand 인증 스토어 (참고용, 현재 미사용)
- `src/app/layout.tsx` - RootLayout with AuthProvider
---
## 🎯 다음 단계
User API 연동은 완료되었으므로 다음 작업을 진행할 수 있습니다:
1. **개발 서버 테스트**: `npm run dev` 실행 후 실제 회원가입/로그인 테스트
2. **프로필 페이지 개선**: 사용자 정보 수정 기능 강화
3. **비밀번호 찾기**: 비밀번호 재설정 플로우 구현 (현재 미구현)
4. **소셜 로그인**: 카카오톡, 네이버 소셜 로그인 구현 (현재 준비 중)
5. **권한 관리**: Role 기반 접근 제어 (ADMIN, USER) 구현
6. **세션 갱신**: Refresh Token 로직 추가 (필요시)
---
## 📞 문의
User API 관련 문제나 개선사항은 프로젝트 팀에 문의하세요.
**문서 작성일**: 2025-01-30
**마지막 업데이트**: 2025-01-30

View File

@ -1,88 +0,0 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
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;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# 헤더 쿠키 버퍼 크기 증가
large_client_header_buffers 4 32k;
client_header_buffer_size 32k;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript
application/json application/javascript application/xml+rss
application/rss+xml font/truetype font/opentype
application/vnd.ms-fontobject image/svg+xml;
server {
listen 8080;
server_name _;
# Health check endpoint
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Proxy to Next.js standalone server
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Timeouts - 이미지 생성은 시간이 오래 걸리므로 3분으로 설정
proxy_connect_timeout 180s;
proxy_send_timeout 180s;
proxy_read_timeout 180s;
# 프록시 버퍼 크기 증가
proxy_buffer_size 32k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Static files
location /_next/static {
proxy_pass http://localhost:3000;
proxy_cache_valid 200 60m;
add_header Cache-Control "public, max-age=3600, immutable";
}
# Public files
location /runtime-env.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
}
}
}

View File

@ -1,20 +0,0 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: cm-kt-event-marketing-frontend
namespace: kt-event-marketing
data:
runtime-env.js: |
// 런타임 환경 설정 (배포 시 동적으로 주입 가능)
window.__runtime_config__ = {
API_GROUP: "/api/v1",
// 7개 마이크로서비스 호스트
USER_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
EVENT_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
CONTENT_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
AI_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
PARTICIPATION_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
DISTRIBUTION_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
ANALYTICS_HOST: "http://kt-event-marketing-api.20.214.196.128.nip.io",
};

View File

@ -1,396 +0,0 @@
# KT 이벤트 마케팅 프론트엔드 Kubernetes 배포 가이드
## 1. 배포 정보
### 실행 정보
- **시스템명**: kt-event-marketing-frontend
- **서비스명**: kt-event-marketing-frontend
- **ACR명**: acrdigitalgarage01
- **k8s명**: aks-digitalgarage-01
- **네임스페이스**: kt-event-marketing
- **파드수**: 1
- **리소스(CPU)**: 256m/1024m
- **리소스(메모리)**: 256Mi/1024Mi
- **Gateway Host**: http://kt-event-marketing-api.20.214.196.128.nip.io
### 배포 URL
- **프론트엔드 접속 URL**: http://kt-event-marketing-frontend.20.214.196.128.nip.io
---
## 2. 배포 검증 결과
### ✅ 체크리스트 검증 완료
#### 객체 이름 네이밍 룰 준수
- ✅ Ingress: `kt-event-marketing-frontend`
- ✅ ConfigMap: `cm-kt-event-marketing-frontend`
- ✅ Service: `kt-event-marketing-frontend`
- ✅ Deployment: `kt-event-marketing-frontend`
#### Ingress Controller External IP 확인
```bash
kubectl get svc ingress-nginx-controller -n ingress-nginx
```
**결과**:
```
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ingress-nginx-controller LoadBalancer 10.0.76.134 20.214.196.128 80:32094/TCP,443:30210/TCP 204d
```
- ✅ External IP: `20.214.196.128`
- ✅ Ingress Host: `kt-event-marketing-frontend.20.214.196.128.nip.io`
#### 포트 일치성 확인
- ✅ Ingress의 backend.service.port.number: `8080`
- ✅ Service의 port: `8080`
- ✅ Service의 targetPort: `8080`
#### 이미지명 확인
- ✅ 이미지명: `acrdigitalgarage01.azurecr.io/kt-event-marketing-frontend/kt-event-marketing-frontend:latest`
#### ConfigMap 데이터 확인
- ✅ ConfigMap 이름: `cm-kt-event-marketing-frontend`
- ✅ Key: `runtime-env.js`
- ✅ Value: 모든 백엔드 API 호스트가 `http://kt-event-marketing-api.20.214.196.128.nip.io`로 설정됨
- USER_HOST
- EVENT_HOST
- CONTENT_HOST
- AI_HOST
- PARTICIPATION_HOST
- DISTRIBUTION_HOST
- ANALYTICS_HOST
---
## 3. 배포 전 사전 확인
### 3.1 Azure 로그인 상태 확인
```bash
az account show
```
**확인 사항**: 올바른 Azure 구독에 로그인되어 있는지 확인
### 3.2 AKS Credential 확인
```bash
kubectl cluster-info
```
**확인 사항**: aks-digitalgarage-01 클러스터에 연결되어 있는지 확인
AKS 자격 증명이 설정되어 있지 않다면 다음 명령 실행:
```bash
az aks get-credentials --resource-group <리소스그룹명> --name aks-digitalgarage-01
```
### 3.3 Namespace 존재 확인
```bash
kubectl get ns kt-event-marketing
```
**확인 사항**: kt-event-marketing 네임스페이스가 존재하는지 확인
네임스페이스가 없다면 생성:
```bash
kubectl create namespace kt-event-marketing
```
### 3.4 ImagePullSecret 확인
배포 전에 ACR 접근을 위한 ImagePullSecret이 네임스페이스에 존재하는지 확인:
```bash
kubectl get secret kt-event-marketing-frontend -n kt-event-marketing
```
ImagePullSecret이 없다면 생성:
```bash
kubectl create secret docker-registry kt-event-marketing-frontend \
--docker-server=acrdigitalgarage01.azurecr.io \
--docker-username=<ACR_USERNAME> \
--docker-password=<ACR_PASSWORD> \
--namespace=kt-event-marketing
```
---
## 4. 매니페스트 적용
### 4.1 모든 매니페스트 일괄 적용
```bash
kubectl apply -f deployment/k8s/
```
### 4.2 개별 매니페스트 적용 (선택 사항)
순서대로 적용하려면:
```bash
# 1. ConfigMap 적용
kubectl apply -f deployment/k8s/configmap.yaml
# 2. Service 적용
kubectl apply -f deployment/k8s/service.yaml
# 3. Deployment 적용
kubectl apply -f deployment/k8s/deployment.yaml
# 4. Ingress 적용
kubectl apply -f deployment/k8s/ingress.yaml
```
---
## 5. 배포 확인
### 5.1 ConfigMap 확인
```bash
kubectl get configmap cm-kt-event-marketing-frontend -n kt-event-marketing
```
ConfigMap 내용 상세 확인:
```bash
kubectl describe configmap cm-kt-event-marketing-frontend -n kt-event-marketing
```
### 5.2 Service 확인
```bash
kubectl get service kt-event-marketing-frontend -n kt-event-marketing
```
Service 상세 확인:
```bash
kubectl describe service kt-event-marketing-frontend -n kt-event-marketing
```
### 5.3 Deployment 확인
```bash
kubectl get deployment kt-event-marketing-frontend -n kt-event-marketing
```
Deployment 상세 확인:
```bash
kubectl describe deployment kt-event-marketing-frontend -n kt-event-marketing
```
### 5.4 Pod 상태 확인
```bash
kubectl get pods -n kt-event-marketing -l app=kt-event-marketing-frontend
```
Pod 상세 확인:
```bash
kubectl describe pod -n kt-event-marketing -l app=kt-event-marketing-frontend
```
Pod 로그 확인:
```bash
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --tail=100
```
### 5.5 Ingress 확인
```bash
kubectl get ingress kt-event-marketing-frontend -n kt-event-marketing
```
Ingress 상세 확인:
```bash
kubectl describe ingress kt-event-marketing-frontend -n kt-event-marketing
```
---
## 6. 배포 검증
### 6.1 Health Check 확인
```bash
kubectl exec -n kt-event-marketing -it $(kubectl get pod -n kt-event-marketing -l app=kt-event-marketing-frontend -o jsonpath='{.items[0].metadata.name}') -- curl http://localhost:8080/health
```
### 6.2 웹 브라우저 접속 테스트
브라우저에서 다음 URL로 접속:
```
http://kt-event-marketing-frontend.20.214.196.128.nip.io
```
### 6.3 Runtime Config 확인
브라우저 개발자 도구 콘솔에서 확인:
```javascript
console.log(window.__runtime_config__);
```
모든 API 호스트가 `http://kt-event-marketing-api.20.214.196.128.nip.io`로 설정되어 있는지 확인
---
## 7. 트러블슈팅
### Pod가 Running 상태가 아닌 경우
#### ImagePullBackOff 에러
```bash
# ImagePullSecret 확인
kubectl get secret kt-event-marketing-frontend -n kt-event-marketing
# Secret이 없으면 생성
kubectl create secret docker-registry kt-event-marketing-frontend \
--docker-server=acrdigitalgarage01.azurecr.io \
--docker-username=<ACR_USERNAME> \
--docker-password=<ACR_PASSWORD> \
--namespace=kt-event-marketing
```
#### CrashLoopBackOff 에러
```bash
# Pod 로그 확인
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --tail=100
# 이전 컨테이너 로그 확인
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend --previous
```
#### Probe Failure
```bash
# Pod 이벤트 확인
kubectl describe pod -n kt-event-marketing -l app=kt-event-marketing-frontend
# Health endpoint 직접 확인
kubectl exec -n kt-event-marketing -it $(kubectl get pod -n kt-event-marketing -l app=kt-event-marketing-frontend -o jsonpath='{.items[0].metadata.name}') -- curl http://localhost:8080/health
```
### Ingress로 접속이 안 되는 경우
#### Ingress Controller 확인
```bash
kubectl get svc ingress-nginx-controller -n ingress-nginx
```
#### Ingress 상태 확인
```bash
kubectl describe ingress kt-event-marketing-frontend -n kt-event-marketing
```
#### Service Endpoint 확인
```bash
kubectl get endpoints kt-event-marketing-frontend -n kt-event-marketing
```
### ConfigMap이 마운트되지 않는 경우
```bash
# Pod 내부 확인
kubectl exec -n kt-event-marketing -it $(kubectl get pod -n kt-event-marketing -l app=kt-event-marketing-frontend -o jsonpath='{.items[0].metadata.name}') -- ls -la /usr/share/nginx/html/runtime-env.js
# ConfigMap 마운트 확인
kubectl exec -n kt-event-marketing -it $(kubectl get pod -n kt-event-marketing -l app=kt-event-marketing-frontend -o jsonpath='{.items[0].metadata.name}') -- cat /usr/share/nginx/html/runtime-env.js
```
---
## 8. 재배포 방법
### 8.1 ConfigMap 업데이트 후 재배포
```bash
# ConfigMap 수정
kubectl edit configmap cm-kt-event-marketing-frontend -n kt-event-marketing
# Pod 재시작
kubectl rollout restart deployment kt-event-marketing-frontend -n kt-event-marketing
```
### 8.2 새 이미지 버전 배포
```bash
# 새 이미지로 업데이트
kubectl set image deployment/kt-event-marketing-frontend \
kt-event-marketing-frontend=acrdigitalgarage01.azurecr.io/kt-event-marketing-frontend/kt-event-marketing-frontend:새버전 \
-n kt-event-marketing
# 롤아웃 상태 확인
kubectl rollout status deployment/kt-event-marketing-frontend -n kt-event-marketing
```
### 8.3 매니페스트 파일 수정 후 재배포
```bash
# 매니페스트 적용
kubectl apply -f deployment/k8s/
# 롤아웃 상태 확인
kubectl rollout status deployment/kt-event-marketing-frontend -n kt-event-marketing
```
### 8.4 롤백
```bash
# 이전 버전으로 롤백
kubectl rollout undo deployment/kt-event-marketing-frontend -n kt-event-marketing
# 특정 리비전으로 롤백
kubectl rollout undo deployment/kt-event-marketing-frontend --to-revision=<리비전번호> -n kt-event-marketing
# 롤아웃 히스토리 확인
kubectl rollout history deployment/kt-event-marketing-frontend -n kt-event-marketing
```
---
## 9. 삭제 방법
### 9.1 전체 삭제
```bash
kubectl delete -f deployment/k8s/
```
### 9.2 개별 리소스 삭제
```bash
# Ingress 삭제
kubectl delete ingress kt-event-marketing-frontend -n kt-event-marketing
# Deployment 삭제
kubectl delete deployment kt-event-marketing-frontend -n kt-event-marketing
# Service 삭제
kubectl delete service kt-event-marketing-frontend -n kt-event-marketing
# ConfigMap 삭제
kubectl delete configmap cm-kt-event-marketing-frontend -n kt-event-marketing
```
---
## 10. 모니터링
### 10.1 실시간 Pod 로그 확인
```bash
kubectl logs -n kt-event-marketing -l app=kt-event-marketing-frontend -f
```
### 10.2 리소스 사용량 확인
```bash
kubectl top pod -n kt-event-marketing -l app=kt-event-marketing-frontend
```
### 10.3 이벤트 모니터링
```bash
kubectl get events -n kt-event-marketing --sort-by='.lastTimestamp'
```
---
## 부록: 생성된 매니페스트 파일
### A. ConfigMap (configmap.yaml)
- 파일 위치: `deployment/k8s/configmap.yaml`
- 용도: runtime-env.js 설정 주입
- 마운트 경로: `/usr/share/nginx/html/runtime-env.js`
### B. Service (service.yaml)
- 파일 위치: `deployment/k8s/service.yaml`
- 타입: ClusterIP
- 포트: 8080
### C. Deployment (deployment.yaml)
- 파일 위치: `deployment/k8s/deployment.yaml`
- 레플리카: 1
- 이미지: `acrdigitalgarage01.azurecr.io/kt-event-marketing-frontend/kt-event-marketing-frontend:latest`
- 리소스 제한: CPU 256m-1024m, Memory 256Mi-1024Mi
### D. Ingress (ingress.yaml)
- 파일 위치: `deployment/k8s/ingress.yaml`
- 호스트: `kt-event-marketing-frontend.20.214.196.128.nip.io`
- 백엔드: kt-event-marketing-frontend:8080
---
## 문의사항
배포 중 문제가 발생하면 위의 트러블슈팅 섹션을 참고하거나 관리자에게 문의하세요.

View File

@ -1,59 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kt-event-marketing-frontend
namespace: kt-event-marketing
spec:
replicas: 1
selector:
matchLabels:
app: kt-event-marketing-frontend
template:
metadata:
labels:
app: kt-event-marketing-frontend
spec:
imagePullSecrets:
- name: kt-event-marketing-frontend
containers:
- name: kt-event-marketing-frontend
image: acrdigitalgarage01.azurecr.io/kt-event-marketing/kt-event-marketing-frontend:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
resources:
requests:
cpu: "256m"
memory: "256Mi"
limits:
cpu: "1024m"
memory: "1024Mi"
startupProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 30
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 20
failureThreshold: 3
volumeMounts:
- name: runtime-config
mountPath: /usr/share/nginx/html/runtime-env.js
subPath: runtime-env.js
volumes:
- name: runtime-config
configMap:
name: cm-kt-event-marketing-frontend

View File

@ -1,20 +0,0 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: kt-event-marketing-frontend
namespace: kt-event-marketing
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: kt-event-marketing-frontend.20.214.196.128.nip.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kt-event-marketing-frontend
port:
number: 8080

View File

@ -1,13 +0,0 @@
apiVersion: v1
kind: Service
metadata:
name: kt-event-marketing-frontend
namespace: kt-event-marketing
spec:
type: ClusterIP
selector:
app: kt-event-marketing-frontend
ports:
- protocol: TCP
port: 8080
targetPort: 8080

View File

@ -11,10 +11,10 @@ info:
- Retry 패턴 및 Fallback 처리
## 배포 채널
- **우리동네TV** (URIDONGNETV): 영상 콘텐츠 업로드
- **링고비즈** (RINGOBIZ): 연결음 업데이트
- **지니TV** (GINITV): 광고 등록
- **SNS**: Instagram (INSTAGRAM), Naver Blog (NAVER), Kakao Channel (KAKAO)
- **우리동네TV**: 영상 콘텐츠 업로드
- **링고비즈**: 연결음 업데이트
- **지니TV**: 광고 등록
- **SNS**: Instagram, Naver Blog, Kakao Channel
## Resilience 패턴
- Circuit Breaker: 채널별 독립적 장애 격리
@ -79,21 +79,23 @@ paths:
summary: 다중 채널 배포 예시
value:
eventId: "evt-12345"
title: "신규 고객 환영 이벤트"
description: "신규 고객님을 위한 특별 할인 이벤트"
imageUrl: "https://cdn.example.com/images/event-main.jpg"
channels:
- "URIDONGNETV"
- "INSTAGRAM"
- "NAVER"
channelSettings:
URIDONGNETV:
radius: "1km"
timeSlot: "evening"
INSTAGRAM:
scheduledTime: "2025-11-01T10:00:00"
NAVER:
scheduledTime: "2025-11-01T10:30:00"
- type: "WOORIDONGNE_TV"
config:
radius: "1km"
timeSlots:
- "weekday_evening"
- "weekend_lunch"
- type: "INSTAGRAM"
config:
scheduledTime: "2025-11-01T10:00:00Z"
- type: "NAVER_BLOG"
config:
scheduledTime: "2025-11-01T10:30:00Z"
contentUrls:
instagram: "https://cdn.example.com/images/event-instagram.jpg"
naverBlog: "https://cdn.example.com/images/event-naver.jpg"
kakaoChannel: "https://cdn.example.com/images/event-kakao.jpg"
responses:
'200':
description: 배포 완료
@ -105,29 +107,25 @@ paths:
allSuccess:
summary: 모든 채널 배포 성공
value:
distributionId: "dist-12345"
eventId: "evt-12345"
success: true
channelResults:
- channel: "URIDONGNETV"
success: true
status: "COMPLETED"
completedAt: "2025-11-01T09:00:00Z"
results:
- channel: "WOORIDONGNE_TV"
status: "SUCCESS"
distributionId: "wtv-uuid-12345"
estimatedReach: 1000
executionTimeMs: 234
estimatedViews: 1000
message: "배포 완료"
- channel: "INSTAGRAM"
success: true
distributionId: "ig-uuid-12345"
estimatedReach: 500
executionTimeMs: 456
- channel: "NAVER"
success: true
distributionId: "naver-uuid-12345"
estimatedReach: 300
executionTimeMs: 123
successCount: 3
failureCount: 0
completedAt: "2025-11-01T09:00:00"
totalExecutionTimeMs: 1234
message: "배포가 성공적으로 완료되었습니다"
status: "SUCCESS"
postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345"
message: "게시 완료"
- channel: "NAVER_BLOG"
status: "SUCCESS"
postUrl: "https://blog.naver.com/store123/generated-post"
message: "게시 완료"
'400':
description: 잘못된 요청
content:
@ -219,77 +217,67 @@ paths:
value:
eventId: "evt-12345"
overallStatus: "COMPLETED"
startedAt: "2025-11-01T08:58:00"
completedAt: "2025-11-01T09:00:00"
completedAt: "2025-11-01T09:00:00Z"
channels:
- channel: "URIDONGNETV"
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
completedAt: "2025-11-01T09:00:00"
- channel: "RINGOBIZ"
completedAt: "2025-11-01T09:00:00Z"
- channel: "RINGO_BIZ"
status: "COMPLETED"
updateTimestamp: "2025-11-01T09:00:00"
completedAt: "2025-11-01T09:00:00"
- channel: "GINITV"
updateTimestamp: "2025-11-01T09:00:00Z"
- channel: "GENIE_TV"
status: "COMPLETED"
adId: "gtv-uuid-12345"
impressionSchedule:
- "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00"
completedAt: "2025-11-01T09:00:00"
- channel: "INSTAGRAM"
status: "COMPLETED"
postUrl: "https://instagram.com/p/generated-post-id"
postId: "ig-post-12345"
completedAt: "2025-11-01T09:00:00"
- channel: "NAVER"
- channel: "NAVER_BLOG"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
completedAt: "2025-11-01T09:00:00"
- channel: "KAKAO"
- channel: "KAKAO_CHANNEL"
status: "COMPLETED"
messageId: "kakao-msg-12345"
completedAt: "2025-11-01T09:00:00"
inProgress:
summary: 배포 진행중 상태
value:
eventId: "evt-12345"
overallStatus: "IN_PROGRESS"
startedAt: "2025-11-01T08:58:00"
startedAt: "2025-11-01T08:58:00Z"
channels:
- channel: "URIDONGNETV"
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM"
status: "IN_PROGRESS"
progress: 50
- channel: "NAVER"
- channel: "NAVER_BLOG"
status: "PENDING"
partialFailure:
summary: 일부 채널 실패 상태
value:
eventId: "evt-12345"
overallStatus: "PARTIAL_FAILURE"
startedAt: "2025-11-01T08:58:00"
completedAt: "2025-11-01T09:00:00"
completedAt: "2025-11-01T09:00:00Z"
channels:
- channel: "URIDONGNETV"
- channel: "WOORIDONGNE_TV"
status: "COMPLETED"
distributionId: "wtv-uuid-12345"
estimatedViews: 1500
completedAt: "2025-11-01T08:59:00"
- channel: "INSTAGRAM"
status: "FAILED"
errorMessage: "Instagram API 타임아웃"
retries: 3
lastRetryAt: "2025-11-01T08:59:30"
- channel: "NAVER"
lastRetryAt: "2025-11-01T08:59:30Z"
- channel: "NAVER_BLOG"
status: "COMPLETED"
postUrl: "https://blog.naver.com/store123/generated-post"
completedAt: "2025-11-01T09:00:00"
'404':
description: 배포 이력을 찾을 수 없음
content:
@ -317,133 +305,196 @@ components:
required:
- eventId
- channels
- contentUrls
properties:
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
title:
type: string
description: 이벤트 제목
example: "신규 고객 환영 이벤트"
description:
type: string
description: 이벤트 설명
example: "신규 고객님을 위한 특별 할인 이벤트"
imageUrl:
type: string
description: 이미지 URL (CDN)
example: "https://cdn.example.com/images/event-main.jpg"
channels:
type: array
description: 배포할 채널 목록
minItems: 1
items:
type: string
enum:
- URIDONGNETV
- RINGOBIZ
- GINITV
- INSTAGRAM
- NAVER
- KAKAO
example: ["URIDONGNETV", "INSTAGRAM", "NAVER"]
channelSettings:
$ref: '#/components/schemas/ChannelConfig'
contentUrls:
type: object
description: 채널별 추가 설정 (Optional)
additionalProperties:
type: object
additionalProperties: true
description: 플랫폼별 콘텐츠 URL
properties:
wooridongneTV:
type: string
description: 우리동네TV 영상 URL (15초)
example: "https://cdn.example.com/videos/event-15s.mp4"
ringoBiz:
type: string
description: 링고비즈 연결음 파일 URL
example: "https://cdn.example.com/audio/ringtone.mp3"
genieTV:
type: string
description: 지니TV 광고 영상 URL
example: "https://cdn.example.com/videos/event-ad.mp4"
instagram:
type: string
description: Instagram 이미지 URL (1080x1080)
example: "https://cdn.example.com/images/event-instagram.jpg"
naverBlog:
type: string
description: Naver Blog 이미지 URL (800x600)
example: "https://cdn.example.com/images/event-naver.jpg"
kakaoChannel:
type: string
description: Kakao Channel 이미지 URL (800x800)
example: "https://cdn.example.com/images/event-kakao.jpg"
ChannelConfig:
type: object
required:
- type
properties:
type:
type: string
description: 채널 타입
enum:
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
config:
type: object
description: 채널별 설정 (채널에 따라 다름)
additionalProperties: true
example:
URIDONGNETV:
radius: "1km"
timeSlot: "evening"
INSTAGRAM:
scheduledTime: "2025-11-01T10:00:00"
scheduledTime: "2025-11-01T10:00:00Z"
caption: "이벤트 안내"
hashtags:
- "이벤트"
- "할인"
DistributionResponse:
type: object
required:
- distributionId
- eventId
- success
- channelResults
- successCount
- failureCount
- status
- results
properties:
distributionId:
type: string
description: 배포 ID
example: "dist-12345"
eventId:
type: string
description: 이벤트 ID
example: "evt-12345"
success:
type: boolean
description: 배포 성공 여부 (모든 채널 또는 일부 채널 성공)
example: true
channelResults:
type: array
description: 채널별 배포 결과
items:
$ref: '#/components/schemas/ChannelDistributionResult'
successCount:
type: integer
description: 성공한 채널 수
example: 3
failureCount:
type: integer
description: 실패한 채널 수
example: 0
status:
type: string
description: 전체 배포 상태
enum:
- PENDING
- IN_PROGRESS
- COMPLETED
- PARTIAL_FAILURE
- FAILED
example: "COMPLETED"
startedAt:
type: string
format: date-time
description: 배포 시작 시각
example: "2025-11-01T08:59:00Z"
completedAt:
type: string
format: date-time
description: 배포 완료 시각
example: "2025-11-01T09:00:00"
totalExecutionTimeMs:
type: integer
format: int64
description: 전체 배포 소요 시간 (ms)
example: 1234
message:
type: string
description: 메시지
example: "배포가 성공적으로 완료되었습니다"
example: "2025-11-01T09:00:00Z"
results:
type: array
description: 채널별 배포 결과
items:
$ref: '#/components/schemas/ChannelResult'
ChannelDistributionResult:
ChannelResult:
type: object
required:
- channel
- success
- status
properties:
channel:
type: string
description: 채널 타입
enum:
- URIDONGNETV
- RINGOBIZ
- GINITV
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER
- KAKAO
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
success:
type: boolean
description: 배포 성공 여부
example: true
status:
type: string
description: 채널별 배포 상태
enum:
- PENDING
- IN_PROGRESS
- SUCCESS
- FAILED
example: "SUCCESS"
distributionId:
type: string
description: 배포 ID (성공 시)
example: "dist-uuid-12345"
estimatedReach:
description: 채널별 배포 ID (우리동네TV, 지니TV)
example: "wtv-uuid-12345"
estimatedViews:
type: integer
description: 예상 노출 수 (성공 시)
description: 예상 노출 수 (우리동네TV, 지니TV)
example: 1500
updateTimestamp:
type: string
format: date-time
description: 업데이트 완료 시각 (링고비즈)
example: "2025-11-01T09:00:00Z"
adId:
type: string
description: 광고 ID (지니TV)
example: "gtv-uuid-12345"
impressionSchedule:
type: array
description: 노출 스케줄 (지니TV)
items:
type: string
example:
- "2025-11-01 18:00-20:00"
- "2025-11-02 12:00-14:00"
postUrl:
type: string
description: 게시물 URL (Instagram, Naver Blog)
example: "https://instagram.com/p/generated-post-id"
postId:
type: string
description: 게시물 ID (Instagram)
example: "ig-post-12345"
messageId:
type: string
description: 메시지 ID (Kakao Channel)
example: "kakao-msg-12345"
message:
type: string
description: 결과 메시지
example: "배포 완료"
errorMessage:
type: string
description: 에러 메시지 (실패 시)
description: 오류 메시지 (실패 시)
example: "Instagram API 타임아웃"
executionTimeMs:
retries:
type: integer
format: int64
description: 배포 소요 시간 (ms)
example: 234
description: 재시도 횟수
example: 0
lastRetryAt:
type: string
format: date-time
description: 마지막 재시도 시각
example: "2025-11-01T08:59:30Z"
DistributionStatusResponse:
type: object
@ -493,12 +544,12 @@ components:
type: string
description: 채널 타입
enum:
- URIDONGNETV
- RINGOBIZ
- GINITV
- WOORIDONGNE_TV
- RINGO_BIZ
- GENIE_TV
- INSTAGRAM
- NAVER
- KAKAO
- NAVER_BLOG
- KAKAO_CHANNEL
example: "INSTAGRAM"
status:
type: string
@ -518,7 +569,7 @@ components:
distributionId:
type: string
description: 채널별 배포 ID
example: "dist-uuid-12345"
example: "wtv-uuid-12345"
estimatedViews:
type: integer
description: 예상 노출 수
@ -527,35 +578,35 @@ components:
type: string
format: date-time
description: 업데이트 완료 시각
example: "2025-11-01T09:00:00"
example: "2025-11-01T09:00:00Z"
adId:
type: string
description: 광고 ID (지니TV)
description: 광고 ID
example: "gtv-uuid-12345"
impressionSchedule:
type: array
description: 노출 스케줄 (지니TV)
description: 노출 스케줄
items:
type: string
example:
- "2025-11-01 18:00-20:00"
postUrl:
type: string
description: 게시물 URL (Instagram, Naver Blog)
description: 게시물 URL
example: "https://instagram.com/p/generated-post-id"
postId:
type: string
description: 게시물 ID (Instagram)
description: 게시물 ID
example: "ig-post-12345"
messageId:
type: string
description: 메시지 ID (Kakao Channel)
description: 메시지 ID
example: "kakao-msg-12345"
completedAt:
type: string
format: date-time
description: 완료 시각
example: "2025-11-01T09:00:00"
example: "2025-11-01T09:00:00Z"
errorMessage:
type: string
description: 오류 메시지
@ -568,7 +619,7 @@ components:
type: string
format: date-time
description: 마지막 재시도 시각
example: "2025-11-01T08:59:30"
example: "2025-11-01T08:59:30Z"
ErrorResponse:
type: object

View File

@ -2,7 +2,6 @@
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
output: 'standalone',
compiler: {
emotion: true,
},

View File

@ -17,20 +17,21 @@ import {
Paper,
InputAdornment,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { Visibility, VisibilityOff, Email, Lock, ChatBubble, Info } from '@mui/icons-material';
import { Visibility, VisibilityOff, Email, Lock, ChatBubble } from '@mui/icons-material';
import { useAuthContext } from '@/features/auth';
import { useUIStore } from '@/stores/uiStore';
import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles';
// 유효성 검사 스키마
const loginSchema = z.object({
email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'),
password: z.string().min(1, '비밀번호를 입력해주세요'),
email: z
.string()
.min(1, '이메일을 입력해주세요')
.email('올바른 이메일 형식이 아닙니다'),
password: z
.string()
.min(1, '비밀번호를 입력해주세요'),
rememberMe: z.boolean().optional(),
});
@ -41,7 +42,6 @@ export default function LoginPage() {
const { login } = useAuthContext();
const { showToast, setLoading } = useUIStore();
const [showPassword, setShowPassword] = useState(false);
const [openUnavailableModal, setOpenUnavailableModal] = useState(false);
const {
control,
@ -74,10 +74,7 @@ export default function LoginPage() {
router.push('/');
} else {
console.error('❌ 로그인 실패:', result.error);
showToast(
result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.',
'error'
);
showToast(result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error');
}
} catch (error) {
console.error('💥 로그인 예외:', error);
@ -87,13 +84,9 @@ export default function LoginPage() {
}
};
const handleUnavailableFeature = (e: React.MouseEvent) => {
e.preventDefault();
setOpenUnavailableModal(true);
};
const handleCloseModal = () => {
setOpenUnavailableModal(false);
const handleSocialLogin = (provider: 'kakao' | 'naver') => {
// TODO: 소셜 로그인 구현
showToast(`${provider === 'kakao' ? '카카오톡' : '네이버'} 로그인은 준비 중입니다`, 'info');
};
return (
@ -104,8 +97,8 @@ export default function LoginPage() {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
px: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
py: { xs: 4, sm: 8 }, // 320px: 32px, 600px+: 64px
px: 3,
py: 8,
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
}}
>
@ -114,49 +107,38 @@ export default function LoginPage() {
sx={{
width: '100%',
maxWidth: 440,
p: { xs: 2.5, sm: 4, md: 6 }, // 320px: 20px, 600px: 32px, 960px+: 48px
p: { xs: 4, sm: 6 },
borderRadius: 3,
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
}}
>
{/* 로고 및 타이틀 */}
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 6 } }}> {/* 320px: 32px, 600px+: 48px */}
<Box sx={{ textAlign: 'center', mb: 6 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: { xs: 56, sm: 64 }, // 320px: 56px, 600px+: 64px
height: { xs: 56, sm: 64 },
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.main',
mb: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
mb: 3,
}}
>
<Typography sx={{ fontSize: { xs: 28, sm: 32 } }}>🎉</Typography> {/* 320px: 28px, 600px+: 32px */}
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
</Box>
<Typography
variant="h4"
sx={{
...responsiveText.h2,
mb: 1,
fontSize: { xs: '1.25rem', sm: '1.5rem' } // 320px: 20px, 600px+: 24px
}}
>
KT
<Typography variant="h4" sx={{ ...responsiveText.h2, mb: 1 }}>
KT AI
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
{/* 로그인 폼 */}
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
<Box sx={{ mb: 3 }}>
<Controller
name="email"
control={control}
@ -172,21 +154,16 @@ export default function LoginPage() {
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} /> {/* 320px: 20px, 600px+: 24px */}
<Email color="action" />
</InputAdornment>
),
}}
sx={{
'& .MuiInputBase-input': {
fontSize: { xs: '0.875rem', sm: '1rem' } // 320px: 14px, 600px+: 16px
}
}}
/>
)}
/>
</Box>
<Box sx={{ mb: { xs: 1.5, sm: 2 } }}> {/* 320px: 12px, 600px+: 16px */}
<Box sx={{ mb: 2 }}>
<Controller
name="password"
control={control}
@ -202,7 +179,7 @@ export default function LoginPage() {
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} />
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
@ -217,29 +194,20 @@ export default function LoginPage() {
</InputAdornment>
),
}}
sx={{
'& .MuiInputBase-input': {
fontSize: { xs: '0.875rem', sm: '1rem' }
}
}}
/>
)}
/>
</Box>
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
<Box sx={{ mb: 3 }}>
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox {...field} checked={field.value} size="small" />}
control={<Checkbox {...field} checked={field.value} />}
label={
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
>
<Typography variant="body2" color="text.secondary">
</Typography>
}
@ -255,54 +223,33 @@ export default function LoginPage() {
size="large"
sx={{
mb: 2,
py: { xs: 1.25, sm: 1.5, md: 1.75 }, // 320px: 10px, 600px: 12px, 960px+: 14px
fontSize: { xs: '0.938rem', sm: 15, md: 16 }, // 320px: 15px, 600px: 15px, 960px+: 16px
py: { xs: 1.5, sm: 1.75 },
fontSize: { xs: 15, sm: 16 },
...getGradientButtonStyle('primary'),
}}
>
</Button>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
gap: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
mb: { xs: 3, sm: 4 }, // 320px: 24px, 600px+: 32px
flexWrap: 'wrap'
}}
>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mb: 4 }}>
<Link
href="#"
onClick={handleUnavailableFeature}
href="/forgot-password"
variant="body2"
color="text.secondary"
underline="hover"
sx={{
cursor: 'pointer',
fontSize: { xs: '0.813rem', sm: '0.875rem' } // 320px: 13px, 600px+: 14px
}}
sx={{ cursor: 'pointer' }}
>
</Link>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }}
>
<Typography variant="body2" color="text.secondary">
|
</Typography>
<Link
href="#"
onClick={handleUnavailableFeature}
href="/register"
variant="body2"
color="primary"
underline="hover"
sx={{
cursor: 'pointer',
fontWeight: 600,
fontSize: { xs: '0.813rem', sm: '0.875rem' }
}}
sx={{ cursor: 'pointer', fontWeight: 600 }}
>
</Link>
@ -310,32 +257,20 @@ export default function LoginPage() {
</form>
{/* 소셜 로그인 */}
<Divider sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
>
<Divider sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Divider>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
mb: { xs: 2, sm: 3 } // 320px: 16px, 600px+: 24px
}}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={handleUnavailableFeature}
onClick={() => handleSocialLogin('kakao')}
sx={{
py: { xs: 1.25, sm: 1.5 }, // 320px: 10px, 600px+: 12px
fontSize: { xs: '0.875rem', sm: '0.938rem' }, // 320px: 14px, 600px+: 15px
py: 1.5,
borderColor: '#FEE500',
bgcolor: '#FEE500',
color: '#000000',
@ -345,7 +280,7 @@ export default function LoginPage() {
borderColor: '#FDD835',
},
}}
startIcon={<ChatBubble sx={{ fontSize: { xs: 20, sm: 24 } }} />}
startIcon={<ChatBubble />}
>
</Button>
@ -354,10 +289,9 @@ export default function LoginPage() {
fullWidth
variant="outlined"
size="large"
onClick={handleUnavailableFeature}
onClick={() => handleSocialLogin('naver')}
sx={{
py: { xs: 1.25, sm: 1.5 },
fontSize: { xs: '0.875rem', sm: '0.938rem' },
py: 1.5,
borderColor: '#03C75A',
bgcolor: '#03C75A',
color: '#FFFFFF',
@ -370,8 +304,8 @@ export default function LoginPage() {
startIcon={
<Box
sx={{
width: { xs: 18, sm: 20 }, // 320px: 18px, 600px+: 20px
height: { xs: 18, sm: 20 },
width: 20,
height: 20,
borderRadius: '50%',
bgcolor: 'white',
display: 'flex',
@ -379,7 +313,7 @@ export default function LoginPage() {
justifyContent: 'center',
fontWeight: 700,
color: '#03C75A',
fontSize: { xs: 12, sm: 14 }, // 320px: 12px, 600px+: 14px
fontSize: 14,
}}
>
N
@ -391,106 +325,18 @@ export default function LoginPage() {
</Box>
{/* 약관 동의 안내 */}
<Typography
variant="caption"
color="text.secondary"
sx={{
textAlign: 'center',
display: 'block',
fontSize: { xs: '0.75rem', sm: '0.813rem' }, // 320px: 12px, 600px+: 13px
lineHeight: 1.5
}}
>
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block' }}>
{' '}
<Link
href="#"
onClick={handleUnavailableFeature}
underline="hover"
sx={{
color: 'text.secondary',
fontSize: 'inherit'
}}
>
<Link href="/terms" underline="hover" sx={{ color: 'text.secondary' }}>
</Link>{' '}
{' '}
<Link
href="#"
onClick={handleUnavailableFeature}
underline="hover"
sx={{
color: 'text.secondary',
fontSize: 'inherit'
}}
>
<Link href="/privacy" underline="hover" sx={{ color: 'text.secondary' }}>
</Link>
.
</Typography>
</Paper>
{/* 지원하지 않는 기능 모달 */}
<Dialog
open={openUnavailableModal}
onClose={handleCloseModal}
maxWidth="xs"
fullWidth
PaperProps={{
sx: {
borderRadius: 3,
px: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
py: { xs: 0.5, sm: 1 }, // 320px: 4px, 600px+: 8px
m: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: { xs: 1, sm: 1.5 }, // 320px: 8px, 600px+: 12px
pb: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
px: { xs: 1, sm: 2 }, // 320px: 8px, 600px+: 16px
}}
>
<Info color="info" sx={{ fontSize: { xs: 24, sm: 28 } }} /> {/* 320px: 24px, 600px+: 28px */}
<Typography
variant="h6"
sx={{
fontWeight: 600,
fontSize: { xs: '1rem', sm: '1.125rem' } // 320px: 16px, 600px+: 18px
}}
>
</Typography>
</DialogTitle>
<DialogContent sx={{ px: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
<Typography
variant="body1"
sx={{
color: 'text.secondary',
lineHeight: 1.7,
fontSize: { xs: '0.875rem', sm: '1rem' } // 320px: 14px, 600px+: 16px
}}
>
.
</Typography>
</DialogContent>
<DialogActions sx={{ px: { xs: 2, sm: 3 }, pb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
<Button
onClick={handleCloseModal}
variant="contained"
fullWidth
sx={{
py: { xs: 1.25, sm: 1.5 }, // 320px: 10px, 600px+: 12px
fontSize: { xs: '0.875rem', sm: '1rem' }, // 320px: 14px, 600px+: 16px
...getGradientButtonStyle('primary'),
}}
>
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@ -1,57 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useAuthContext } from '@/features/auth';
import { useUIStore } from '@/stores/uiStore';
export default function LogoutPage() {
const router = useRouter();
const { logout } = useAuthContext();
const { showToast } = useUIStore();
useEffect(() => {
const handleLogout = async () => {
try {
console.log('🚪 로그아웃 시작');
await logout();
console.log('✅ 로그아웃 완료');
showToast('로그아웃되었습니다', 'success');
// 로그인 페이지로 리디렉션
setTimeout(() => {
router.push('/login');
}, 500);
} catch (error) {
console.error('❌ 로그아웃 에러:', error);
showToast('로그아웃 중 오류가 발생했습니다', 'error');
// 에러가 발생해도 로그인 페이지로 이동
setTimeout(() => {
router.push('/login');
}, 1000);
}
};
handleLogout();
}, [logout, router, showToast]);
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
}}
>
<CircularProgress size={60} sx={{ mb: 3 }} />
<Typography variant="h6" color="text.secondary">
...
</Typography>
</Box>
);
}

View File

@ -97,15 +97,7 @@ export default function AnalyticsPage() {
setLastUpdate(new Date());
} catch (error: any) {
console.error('❌ Analytics 데이터 로드 실패:', error);
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
if (error.response?.status === 404 || error.response?.status === 400) {
console.log(' Analytics 데이터가 아직 생성되지 않았습니다.');
// 에러 상태를 설정하지 않고 빈 데이터로 표시
} else {
// 다른 에러는 에러로 처리
console.error('❌ 예상치 못한 에러:', error);
}
// 에러 발생 시에도 로딩 상태 해제
} finally {
setLoading(false);
setRefreshing(false);
@ -153,7 +145,7 @@ export default function AnalyticsPage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
display: 'flex',
@ -175,7 +167,7 @@ export default function AnalyticsPage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
display: 'flex',
@ -334,7 +326,7 @@ export default function AnalyticsPage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
}}
@ -611,7 +603,7 @@ export default function AnalyticsPage() {
}}
/>
) : (
<Box sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
<Box sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
.
</Typography>

View File

@ -248,8 +248,8 @@ export default function DrawPage() {
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
{/* 에러 메시지 */}
{error && (
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
@ -261,7 +261,7 @@ export default function DrawPage() {
{!showResults && (
<>
{/* Page Header */}
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
<Box sx={{ mb: 8 }}>
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
🎲
</Typography>
@ -271,7 +271,7 @@ export default function DrawPage() {
</Box>
{/* Event Info Summary Cards */}
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid container spacing={6} sx={{ mb: 10 }}>
<Grid item xs={6} md={6}>
<Card
elevation={0}
@ -281,12 +281,12 @@ export default function DrawPage() {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<EventNote sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 1, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<EventNote sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
{eventName}
</Typography>
</CardContent>
@ -301,12 +301,12 @@ export default function DrawPage() {
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<People sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 1, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1rem', sm: '1.75rem' } }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{totalParticipants}
</Typography>
</CardContent>
@ -315,8 +315,8 @@ export default function DrawPage() {
</Grid>
{/* Drawing Settings */}
<Card elevation={0} sx={{ mb: { xs: 4, sm: 10 }, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Card elevation={0} sx={{ mb: 10, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
<Tune sx={{ fontSize: 32, color: colors.pink }} />
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
@ -428,7 +428,7 @@ export default function DrawPage() {
startIcon={<Casino sx={{ fontSize: 28 }} />}
onClick={handleStartDrawing}
sx={{
mb: { xs: 4, sm: 10 },
mb: 10,
py: 3,
borderRadius: 4,
fontWeight: 700,
@ -452,7 +452,7 @@ export default function DrawPage() {
{showResults && (
<>
{/* Results Header */}
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
<Box sx={{ textAlign: 'center', mb: 10 }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
🎉 !
</Typography>
@ -467,7 +467,7 @@ export default function DrawPage() {
</Box>
{/* Winner List */}
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
<Box sx={{ mb: 10 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
🏆
</Typography>
@ -482,7 +482,7 @@ export default function DrawPage() {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: { xs: 2.5, sm: 5 } }}>
<CardContent sx={{ p: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Box
sx={{

View File

@ -159,10 +159,10 @@ export default function EventDetailPage() {
const fetchAnalytics = async (forceRefresh = false) => {
try {
if (forceRefresh) {
console.log('🔄 Analytics 데이터 새로고침...');
console.log('🔄 데이터 새로고침 시작...');
setRefreshing(true);
} else {
console.log('📊 Analytics 데이터 로딩...');
console.log('📊 Analytics 데이터 로딩 시작...');
setLoading(true);
}
setError(null);
@ -172,47 +172,38 @@ export default function EventDetailPage() {
analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
analyticsApi.getEventTimelineAnalytics(eventId, {
interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
refresh: forceRefresh
}),
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true }),
analyticsApi.getEventChannelAnalytics(eventId, {}),
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true, refresh: forceRefresh }),
analyticsApi.getEventChannelAnalytics(eventId, { refresh: forceRefresh }),
]);
console.log('✅ Dashboard 데이터:', dashboard);
console.log('✅ Timeline 데이터:', timeline);
console.log('✅ ROI 데이터:', roi);
console.log('✅ Channels 데이터:', channels);
console.log('✅ Channel 데이터:', channels);
// Analytics 데이터 저장
const formattedAnalyticsData = {
setAnalyticsData({
dashboard,
timeline,
roi,
channels,
};
});
setAnalyticsData(formattedAnalyticsData);
// Event 객체 업데이트 - Analytics 데이터 반영
// Event 기본 정보 업데이트
setEvent(prev => ({
...prev,
participants: dashboard.summary.participants,
views: dashboard.summary.totalViews,
conversion: dashboard.summary.conversionRate * 100,
roi: dashboard.roi.roi,
title: dashboard.eventTitle,
roi: Math.round(dashboard.roi.roi),
conversion: Math.round(dashboard.summary.conversionRate * 100),
}));
console.log('✅ Analytics 데이터 로딩 완료');
} catch (err: any) {
console.error('❌ Analytics 데이터 로딩 실패:', err);
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
if (err.response?.status === 404 || err.response?.status === 400) {
console.log(' Analytics 데이터가 아직 생성되지 않았습니다.');
setError('이벤트의 Analytics 데이터가 아직 생성되지 않았습니다. 참여자가 생기면 자동으로 생성됩니다.');
} else {
setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.');
}
setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
setRefreshing(false);
@ -569,8 +560,7 @@ export default function EventDetailPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 },
pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
display: 'flex',
flexDirection: 'column',
@ -603,8 +593,7 @@ export default function EventDetailPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 },
pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
display: 'flex',
flexDirection: 'column',
@ -634,8 +623,7 @@ export default function EventDetailPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 },
pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
display: 'flex',
flexDirection: 'column',
@ -665,8 +653,7 @@ export default function EventDetailPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 },
pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
display: 'flex',
flexDirection: 'column',

View File

@ -168,82 +168,82 @@ export default function ParticipantsPage() {
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
{/* Page Header */}
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.5rem', sm: '2rem' }, mb: 2 }}>
<Box sx={{ mb: 8 }}>
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
👥
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: { xs: 3, sm: 6 } }} icon={<ErrorIcon />} onClose={() => setError('')}>
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* Statistics Cards */}
<Grid container spacing={{ xs: 1, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid item xs={4} md={4}>
<Grid container spacing={6} sx={{ mb: 10 }}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<People sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{loading ? '...' : stats.total}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={4} md={4}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.yellow} 0%, #FCD34D 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<AccessTime sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<AccessTime sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{loading ? '...' : stats.waiting}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={4} md={4}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
borderRadius: { xs: 2, sm: 4 },
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<TrendingUp sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<TrendingUp sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{loading ? '...' : stats.winner}
</Typography>
</CardContent>
@ -252,7 +252,7 @@ export default function ParticipantsPage() {
</Grid>
{/* Search Section */}
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
<Box sx={{ mb: 6 }}>
<TextField
fullWidth
placeholder="이름, 전화번호 또는 이메일 검색..."
@ -261,7 +261,7 @@ export default function ParticipantsPage() {
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search sx={{ fontSize: { xs: 18, sm: 24 } }} />
<Search />
</InputAdornment>
),
}}
@ -269,10 +269,6 @@ export default function ParticipantsPage() {
'& .MuiOutlinedInput-root': {
borderRadius: 3,
bgcolor: 'white',
fontSize: { xs: '0.75rem', sm: '1rem' },
},
'& .MuiOutlinedInput-input': {
padding: { xs: '8px 14px', sm: '16.5px 14px' },
},
}}
disabled={loading}
@ -280,11 +276,11 @@ export default function ParticipantsPage() {
</Box>
{/* Filters */}
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, flexWrap: 'wrap' }}>
<FilterList sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.pink }} />
<FormControl sx={{ flex: 1, minWidth: { xs: 100, sm: 160 } }}>
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}> </InputLabel>
<Box sx={{ mb: 8 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
<FormControl sx={{ flex: 1, minWidth: 160 }}>
<InputLabel> </InputLabel>
<Select
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
label="매장 방문"
@ -295,60 +291,47 @@ export default function ParticipantsPage() {
);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}}
sx={{
borderRadius: 2,
fontSize: { xs: '0.75rem', sm: '1rem' },
'& .MuiSelect-select': {
padding: { xs: '8px 14px', sm: '16.5px 14px' },
},
}}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></MenuItem>
<MenuItem value="visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></MenuItem>
<MenuItem value="not_visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></MenuItem>
<MenuItem value="all"></MenuItem>
<MenuItem value="visited"></MenuItem>
<MenuItem value="not_visited"></MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flex: 1, minWidth: { xs: 90, sm: 140 } }}>
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></InputLabel>
<FormControl sx={{ flex: 1, minWidth: 140 }}>
<InputLabel></InputLabel>
<Select
value={statusFilter}
label="상태"
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
sx={{
borderRadius: 2,
fontSize: { xs: '0.75rem', sm: '1rem' },
'& .MuiSelect-select': {
padding: { xs: '8px 14px', sm: '16.5px 14px' },
},
}}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></MenuItem>
<MenuItem value="waiting" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}> </MenuItem>
<MenuItem value="winner" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}></MenuItem>
<MenuItem value="all"></MenuItem>
<MenuItem value="waiting"> </MenuItem>
<MenuItem value="winner"></MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Total Count & Drawing Button */}
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: { xs: 2, sm: 4 } }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '0.875rem', sm: '1.5rem' } }}>
<Box sx={{ mb: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
<span style={{ color: colors.pink }}>{filteredParticipants.length}</span>
</Typography>
<Box sx={{ display: 'flex', gap: { xs: 1.5, sm: 3 } }}>
<Box sx={{ display: 'flex', gap: 3 }}>
<Button
variant="outlined"
startIcon={<Download sx={{ fontSize: { xs: 16, sm: 20 } }} />}
startIcon={<Download />}
onClick={handleDownloadClick}
disabled={loading}
sx={{
borderRadius: 3,
px: { xs: 1.5, sm: 4 },
py: { xs: 0.75, sm: 1.5 },
fontSize: { xs: '0.7rem', sm: '0.875rem' },
px: 4,
py: 1.5,
borderColor: colors.blue,
color: colors.blue,
'&:hover': {
@ -357,23 +340,17 @@ export default function ParticipantsPage() {
},
}}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
</Box>
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
</Box>
</Button>
<Button
variant="contained"
startIcon={<Casino sx={{ fontSize: { xs: 16, sm: 20 } }} />}
startIcon={<Casino />}
onClick={handleDrawClick}
disabled={loading}
sx={{
borderRadius: 3,
px: { xs: 1.5, sm: 4 },
py: { xs: 0.75, sm: 1.5 },
fontSize: { xs: '0.7rem', sm: '0.875rem' },
px: 4,
py: 1.5,
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
@ -381,12 +358,7 @@ export default function ParticipantsPage() {
},
}}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
</Box>
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
</Box>
</Button>
</Box>
</Box>
@ -419,7 +391,7 @@ export default function ParticipantsPage() {
{/* Participant List */}
{!loading && filteredParticipants.length > 0 && (
<>
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredParticipants.map((participant) => (
<Card
@ -437,23 +409,21 @@ export default function ParticipantsPage() {
}}
onClick={() => handleParticipantClick(participant)}
>
<CardContent sx={{ p: { xs: 3, sm: 5 } }}>
<CardContent sx={{ p: 5 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: { xs: 2, sm: 4 },
flexWrap: { xs: 'wrap', sm: 'nowrap' },
gap: { xs: 2, sm: 0 },
mb: 4,
}}
>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 } }}>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
<Box
sx={{
width: { xs: 48, sm: 56 },
height: { xs: 48, sm: 56 },
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: colors.purpleLight,
display: 'flex',
@ -461,16 +431,16 @@ export default function ParticipantsPage() {
justifyContent: 'center',
}}
>
<Person sx={{ fontSize: { xs: 28, sm: 32 }, color: colors.purple }} />
<Person sx={{ fontSize: 32, color: colors.purple }} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
#{participant.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
{participant.name}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
<Typography variant="body1" color="text.secondary">
{participant.phoneNumber}
</Typography>
</Box>
@ -479,7 +449,7 @@ export default function ParticipantsPage() {
label={getStatusText(participant.isWinner)}
color={getStatusColor(participant.isWinner) as any}
size="medium"
sx={{ fontWeight: 600, px: { xs: 1.5, sm: 2 }, py: { xs: 2, sm: 2.5 }, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
/>
</Box>
@ -488,15 +458,15 @@ export default function ParticipantsPage() {
sx={{
borderTop: '1px solid',
borderColor: colors.gray[100],
pt: { xs: 2, sm: 4 },
pt: 4,
display: 'flex',
flexDirection: 'column',
gap: { xs: 1.5, sm: 2 },
gap: 2,
}}
>
{participant.channel && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Chip
@ -506,25 +476,24 @@ export default function ParticipantsPage() {
bgcolor: colors.purpleLight,
color: colors.purple,
fontWeight: 600,
fontSize: { xs: '0.625rem', sm: '0.75rem' },
}}
/>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.75rem', sm: '1rem' }, textAlign: 'right' }}>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
</Typography>
</Box>
{participant.storeVisited && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Chip label="방문" size="small" color="success" sx={{ fontSize: { xs: '0.625rem', sm: '0.75rem' } }} />
<Chip label="방문" size="small" color="success" />
</Box>
)}
</Box>
@ -536,7 +505,7 @@ export default function ParticipantsPage() {
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: { xs: 4, sm: 10 } }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 10 }}>
<Pagination
count={totalPages}
page={currentPage}
@ -545,14 +514,8 @@ export default function ParticipantsPage() {
size="large"
sx={{
'& .MuiPaginationItem-root': {
fontSize: { xs: '0.75rem', sm: '1rem' },
fontSize: '1rem',
fontWeight: 600,
minWidth: { xs: '26px', sm: '32px' },
height: { xs: '26px', sm: '32px' },
margin: { xs: '0 2px', sm: '0 4px' },
},
'& .MuiPaginationItem-icon': {
fontSize: { xs: '1rem', sm: '1.5rem' },
},
}}
/>
@ -595,7 +558,7 @@ export default function ParticipantsPage() {
mb: 3,
}}
>
<Person sx={{ fontSize: { xs: 32, sm: 40 }, color: colors.purple }} />
<Person sx={{ fontSize: 40, color: colors.purple }} />
</Box>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
{selectedParticipant.name}

View File

@ -1,81 +0,0 @@
'use client';
import { useState } from 'react';
import ContentPreviewStep from '../steps/ContentPreviewStep';
import { useRouter } from 'next/navigation';
/**
* Content Service
* ContentPreviewStep을 .
*/
export default function ContentTestPage() {
const router = useRouter();
const [result, setResult] = useState<any>(null);
// 테스트용 이벤트 데이터
const testEventId = 'test-event-' + Date.now();
const testEventTitle = 'Content API 테스트 이벤트';
const testEventDescription = '콘텐츠 생성 기능을 테스트합니다';
const handleNext = (imageStyle: string, images: any[]) => {
console.log('ContentPreview 완료:', { imageStyle, images });
setResult({ imageStyle, images });
alert(`콘텐츠 생성 완료!\n스타일: ${imageStyle}\n이미지 수: ${images.length}`);
};
const handleSkip = () => {
console.log('ContentPreview 건너뛰기');
alert('콘텐츠 생성을 건너뛰었습니다.');
};
const handleBack = () => {
router.push('/events/create');
};
return (
<div>
<ContentPreviewStep
eventId={testEventId}
eventTitle={testEventTitle}
eventDescription={testEventDescription}
onNext={handleNext}
onSkip={handleSkip}
onBack={handleBack}
/>
{/* 결과 표시 (디버깅용) */}
{result && (
<div style={{
position: 'fixed',
bottom: 20,
right: 20,
background: 'white',
border: '2px solid #4CAF50',
borderRadius: 8,
padding: 16,
maxWidth: 400,
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
zIndex: 9999
}}>
<h4 style={{ margin: '0 0 10px 0', color: '#4CAF50' }}> </h4>
<p style={{ margin: '5px 0' }}>
<strong>:</strong> {result.imageStyle}
</p>
<p style={{ margin: '5px 0' }}>
<strong> :</strong> {result.images.length}
</p>
<pre style={{
background: '#f5f5f5',
padding: 10,
borderRadius: 4,
fontSize: 12,
overflow: 'auto',
maxHeight: 200
}}>
{JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
);
}

View File

@ -16,7 +16,6 @@ export type EventMethod = 'online' | 'offline';
export interface EventData {
eventDraftId?: number;
eventId?: string;
objective?: EventObjective;
recommendation?: {
recommendation: {
@ -96,14 +95,14 @@ export default function EventCreatePage() {
<funnel.Render
objective={({ history }) => (
<ObjectiveStep
onNext={({ objective, eventId }) => {
history.push('recommendation', { objective, eventId });
onNext={(objective) => {
history.push('recommendation', { objective });
}}
/>
)}
recommendation={({ context, history }) => (
<RecommendationStep
eventId={context.eventId}
objective={context.objective}
onNext={(recommendation) => {
history.push('channel', { ...context, recommendation });
}}

View File

@ -23,7 +23,6 @@ import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
import { eventApi } from '@/entities/event/api/eventApi';
import type { EventObjective } from '@/entities/event/model/types';
interface ApprovalStepProps {
eventData: EventData;
onApprove: () => void;
@ -35,7 +34,6 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
const [termsDialogOpen, setTermsDialogOpen] = useState(false);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [isDeploying, setIsDeploying] = useState(false);
const DISTRIBUTION_API_BASE_URL = process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://kt-event-marketing-api.20.214.196.128.nip.io';
const handleApprove = async () => {
if (!agreeTerms) return;
@ -88,46 +86,39 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
});
console.log('✅ Event details updated');
// 채널명 매핑 (Frontend → Backend)
const channelMap: Record<string, string[]> = {
uriTV: ['URIDONGNETV'],
ringoBiz: ['RINGOBIZ'],
genieTV: ['GINITV'],
sns: ['INSTAGRAM', 'NAVER', 'KAKAO'],
};
// 3. 배포 채널 선택
if (eventData.channels && eventData.channels.length > 0) {
console.log('📞 Selecting channels:', eventData.channels);
const apiChannels = eventData.channels?.flatMap(ch => channelMap[ch] || []) || [];
// 채널명 매핑 (Frontend → Backend)
const channelMap: Record<string, string> = {
'uriTV': 'WEBSITE',
'ringoBiz': 'EMAIL',
'genieTV': 'KAKAO',
'sns': 'INSTAGRAM',
};
const distributionRequest = {
eventId: eventId,
title: eventName,
description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '',
imageUrl: '', // TODO: 이미지 URL 연동 필요
channels: apiChannels,
channelSettings: {},
};
const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase());
console.log('🚀 Distributing event:', distributionRequest);
const response = await fetch(`${DISTRIBUTION_API_BASE_URL}/api/v1/distribution/distribute`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(distributionRequest),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || '배포 중 오류가 발생했습니다');
await eventApi.selectChannels(eventId, {
channels: backendChannels,
});
console.log('✅ Channels selected');
}
const data = await response.json();
console.log('✅ Distribution completed:', data);
// 4. TODO: 이미지 선택
// 현재 frontend에서 selectedImageId를 추적하지 않음
// 향후 contentPreview 단계에서 선택된 이미지 ID를 eventData에 저장 필요
console.log('⚠️ Image selection skipped - imageId not tracked in frontend');
// 5. 이벤트 배포 API 호출
console.log('📞 Publishing event:', eventId);
const publishResponse = await eventApi.publishEvent(eventId);
console.log('✅ Event published:', publishResponse);
// 성공 다이얼로그 표시
setIsDeploying(false);
setSuccessDialogOpen(true);
} else {
throw new Error('Event creation failed: No event ID returned');
}
@ -138,7 +129,6 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
}
};
const handleSaveDraft = () => {
// TODO: 임시저장 API 연동
alert('임시저장되었습니다');
@ -156,10 +146,10 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: { xs: 4, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack}>
<ArrowBack />
</IconButton>
@ -169,7 +159,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Box>
{/* Title Section */}
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
<Box sx={{ textAlign: 'center', mb: 10 }}>
<CheckCircle sx={{ fontSize: 64, color: colors.purple, mb: 2 }} />
<Typography variant="h5" sx={{ ...responsiveText.h3, fontWeight: 700, mb: 2 }}>
@ -180,7 +170,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Box>
{/* Event Summary Statistics */}
<Grid container spacing={4} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid container spacing={4} sx={{ mb: 10 }}>
<Grid item xs={12} sm={6} md={3}>
<Card
elevation={0}
@ -392,7 +382,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</CardContent>
</Card>
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Box sx={{ flex: 1 }}>
@ -412,7 +402,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography>
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 4 }}>
{getChannelNames(eventData.channels).map((channel) => (
@ -445,7 +435,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Card>
{/* Terms Agreement */}
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: { xs: 4, sm: 10 } }}>
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<FormControlLabel
control={
@ -616,7 +606,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
<Typography variant="h5" sx={{ fontSize: '1.5rem', fontWeight: 700, mb: 3 }}>
!
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: { xs: 3, sm: 8 } }}>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: 8 }}>
.
<br />
.

View File

@ -108,9 +108,9 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
@ -119,7 +119,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
( 1 )
</Typography>
@ -136,7 +136,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
transition: 'all 0.3s',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<CardContent sx={{ p: 6 }}>
<FormControlLabel
control={
<Checkbox
@ -211,7 +211,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
transition: 'all 0.3s',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<CardContent sx={{ p: 6 }}>
<FormControlLabel
control={
<Checkbox
@ -270,7 +270,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
transition: 'all 0.3s',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<CardContent sx={{ p: 6 }}>
<FormControlLabel
control={
<Checkbox
@ -347,7 +347,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
<Card
elevation={0}
sx={{
mb: { xs: 4, sm: 10 },
mb: 10,
borderRadius: 4,
border: channels[3].selected ? 2 : 1,
borderColor: channels[3].selected ? colors.purple : 'divider',
@ -356,7 +356,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
transition: 'all 0.3s',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<CardContent sx={{ p: 6 }}>
<FormControlLabel
control={
<Checkbox
@ -465,13 +465,13 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
<Card
elevation={0}
sx={{
mb: { xs: 4, sm: 10 },
mb: 10,
borderRadius: 4,
bgcolor: 'grey.50',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
<CardContent sx={{ p: 8 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>

View File

@ -40,10 +40,10 @@ export default function ContentEditStep({
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: { xs: 4, sm: 10 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 4, sm: 10 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 10 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>

View File

@ -310,8 +310,8 @@ export default function ContentPreviewStep({
if (loading) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack}>
<ArrowBack />
</IconButton>
@ -442,9 +442,9 @@ export default function ContentPreviewStep({
},
}}
>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack}>
<ArrowBack />
</IconButton>
@ -453,7 +453,7 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: { xs: 3, sm: 8 } }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
{generatedImages.size > 0 && (
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
@ -477,12 +477,12 @@ export default function ContentPreviewStep({
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
</Typography>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{imageStyles.map((style) => (
<Grid item xs={12} md={4} key={style.id}>
<Card

View File

@ -67,81 +67,35 @@ const objectives: ObjectiveOption[] = [
];
interface ObjectiveStepProps {
onNext: (data: { objective: EventObjective; eventId: string }) => void;
onNext: (objective: EventObjective) => void;
}
// eventId 생성 함수
const generateEventId = () => {
return `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`;
};
// 쿠키 저장 함수
const setCookie = (name: string, value: string, days: number = 1) => {
const expires = new Date();
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
};
// 쿠키 삭제 함수
const deleteCookie = (name: string) => {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/`;
};
export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
const [selected, setSelected] = useState<EventObjective | null>(null);
const handleNext = () => {
if (selected) {
// 이전 쿠키 삭제 (깨끗한 상태에서 시작)
deleteCookie('eventId');
deleteCookie('jobId');
// 새로운 eventId 생성
const eventId = generateEventId();
console.log('🎉 ========================================');
console.log('✅ 새로운 이벤트 ID 생성:', eventId);
console.log('📋 선택된 목적:', selected);
console.log('🎉 ========================================');
// 쿠키에 저장
setCookie('eventId', eventId, 1); // 1일 동안 유지
console.log('🍪 쿠키에 eventId 저장 완료:', eventId);
// localStorage에도 저장
try {
localStorage.setItem('eventId', eventId);
console.log('💾 localStorage에 eventId 저장 완료:', eventId);
console.log('📦 저장된 데이터 확인:', {
eventId: eventId,
objective: selected,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('❌ localStorage 저장 실패:', error);
}
// objective와 eventId를 함께 전달
onNext({ objective: selected, eventId });
onNext(selected);
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="md" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Container maxWidth="md" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Title Section */}
<Box sx={{ mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
<AutoAwesome sx={{ fontSize: { xs: 60, sm: 80 }, color: colors.purple, mb: { xs: 2, sm: 4 } }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: { xs: 2, sm: 4 }, fontSize: { xs: '1.5rem', sm: '2rem' } }}>
<Box sx={{ mb: 10, textAlign: 'center' }}>
<AutoAwesome sx={{ fontSize: 80, color: colors.purple, mb: 4 }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.125rem' }}>
AI가
</Typography>
</Box>
{/* Purpose Options */}
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{objectives.map((objective) => (
<Grid item xs={12} sm={6} key={objective.id}>
<Card
@ -162,14 +116,14 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
}}
onClick={() => setSelected(objective.id)}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 2, sm: 3 }, mb: { xs: 2, sm: 3 } }}>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, mb: 3 }}>
<Box sx={{ color: colors.purple }}>{objective.icon}</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 1, sm: 2 }, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
{objective.title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
{objective.description}
</Typography>
</Box>
@ -191,15 +145,15 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
<Card
elevation={0}
sx={{
mb: { xs: 4, sm: 10 },
mb: 10,
background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blue}20 100%)`,
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ display: 'flex', gap: { xs: 2, sm: 3 }, p: { xs: 3, sm: 6 } }}>
<AutoAwesome sx={{ color: colors.purple, fontSize: { xs: 24, sm: 28 } }} />
<Typography variant="body2" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' }, lineHeight: 1.8, color: colors.gray[700] }}>
<CardContent sx={{ display: 'flex', gap: 3, p: 6 }}>
<AutoAwesome sx={{ color: colors.purple, fontSize: 28 }} />
<Typography variant="body2" sx={{ fontSize: '1rem', lineHeight: 1.8, color: colors.gray[700] }}>
AI가 , , .
</Typography>
</CardContent>
@ -214,9 +168,9 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
disabled={!selected}
onClick={handleNext}
sx={{
py: { xs: 2, sm: 3 },
py: 3,
borderRadius: 3,
fontSize: { xs: '0.875rem', sm: '1rem' },
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import {
Box,
Container,
@ -19,8 +19,8 @@ import {
Alert,
} from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { eventApi } from '@/shared/api';
import type { AiRecommendationResult, EventRecommendation } from '@/shared/api/eventApi';
import { EventObjective, BudgetLevel, EventMethod } from '../page';
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
// 디자인 시스템 색상
const colors = {
@ -41,88 +41,132 @@ const colors = {
};
interface RecommendationStepProps {
objective?: EventObjective;
eventId?: string; // 이전 단계에서 생성된 eventId
onNext: (data: { recommendation: EventRecommendation; eventId: string }) => void;
onNext: (data: {
recommendation: EventRecommendation;
eventId: string;
}) => void;
onBack: () => void;
}
// 쿠키에서 값 가져오기
const getCookie = (name: string): string | null => {
if (typeof document === 'undefined') return null;
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) {
return parts.pop()?.split(';').shift() || null;
}
return null;
};
export default function RecommendationStep({
objective,
eventId: initialEventId,
onNext,
onBack,
onBack
}: RecommendationStepProps) {
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
const [jobId, setJobId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [polling, setPolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [aiResult, setAiResult] = useState<AiRecommendationResult | null>(null);
const [aiResult, setAiResult] = useState<AIRecommendationResult | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<
Record<number, { title: string; description: string }>
>({});
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
// 중복 호출 방지를 위한 ref
const requestedEventIdRef = useRef<string | null>(null);
// 컴포넌트 마운트 시 AI 추천 결과 조회
// 컴포넌트 마운트 시 AI 추천 요청
useEffect(() => {
// props에서만 eventId를 받음
if (initialEventId) {
// 이미 요청한 eventId면 중복 요청하지 않음
if (requestedEventIdRef.current === initialEventId) {
console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId);
return;
}
requestedEventIdRef.current = initialEventId;
setEventId(initialEventId);
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
// eventId가 있으면 바로 AI 추천 결과 조회
fetchAIRecommendations(initialEventId);
} else {
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
if (!eventId && objective) {
// Step 1: 이벤트 생성
createEventAndRequestAI();
} else if (eventId) {
// 이미 eventId가 있으면 AI 추천 요청
requestAIRecommendations(eventId);
}
}, [initialEventId]);
}, []);
const fetchAIRecommendations = async (evtId: string) => {
const createEventAndRequestAI = async () => {
try {
setLoading(true);
setError(null);
console.log('📡 AI 추천 요청 시작, eventId:', evtId);
// Step 1: 이벤트 목적 선택 및 생성
const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치');
const newEventId = eventResponse.eventId;
setEventId(newEventId);
// POST /events/{eventId}/ai-recommendations 엔드포인트로 AI 추천 요청
const recommendations = await eventApi.requestAiRecommendations(evtId);
console.log('✅ AI 추천 요청 성공:', recommendations);
setAiResult(recommendations);
setLoading(false);
// Step 2: AI 추천 요청
await requestAIRecommendations(newEventId);
} catch (err: any) {
console.error('❌ AI 추천 요청 실패:', err);
const errorMessage =
err.response?.data?.message ||
err.response?.data?.error ||
'AI 추천을 생성하는데 실패했습니다';
setError(errorMessage);
console.error('이벤트 생성 실패:', err);
setError(err.response?.data?.message || err.message || '이벤트 생성에 실패했습니다');
setLoading(false);
}
};
const requestAIRecommendations = async (evtId: string) => {
try {
setLoading(true);
setError(null);
// 사용자 정보에서 매장 정보 가져오기
const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}');
const storeInfo = {
storeId: userProfile.storeId || '1',
storeName: userProfile.storeName || '내 매장',
category: userProfile.industry || '음식점',
description: userProfile.businessHours || '',
};
// AI 추천 요청
const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo);
setJobId(jobResponse.jobId);
// Job 폴링 시작
pollJobStatus(jobResponse.jobId, evtId);
} catch (err: any) {
console.error('AI 추천 요청 실패:', err);
setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다');
setLoading(false);
}
};
const pollJobStatus = async (jId: string, evtId: string) => {
setPolling(true);
const maxAttempts = 60; // 최대 5분 (5초 간격)
let attempts = 0;
const poll = async () => {
try {
const status = await eventApi.getJobStatus(jId);
console.log('Job 상태:', status);
if (status.status === 'COMPLETED') {
// AI 추천 결과 조회
const recommendations = await aiApi.getRecommendations(evtId);
setAiResult(recommendations);
setLoading(false);
setPolling(false);
return;
} else if (status.status === 'FAILED') {
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
setLoading(false);
setPolling(false);
return;
}
// 계속 폴링
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // 5초 후 재시도
} else {
setError('AI 추천 생성 시간이 초과되었습니다');
setLoading(false);
setPolling(false);
}
} catch (err: any) {
console.error('Job 상태 조회 실패:', err);
setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
setLoading(false);
setPolling(false);
}
};
poll();
};
const handleNext = async () => {
if (selected === null || !aiResult || !eventId) return;
@ -163,7 +207,7 @@ export default function RecommendationStep({
...prev,
[optionNumber]: {
...prev[optionNumber],
title,
title
},
}));
};
@ -173,33 +217,31 @@ export default function RecommendationStep({
...prev,
[optionNumber]: {
...prev[optionNumber],
description,
description
},
}));
};
// 로딩 상태 표시
if (loading) {
if (loading || polling) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 4, sm: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '1.5rem' } }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
<Box
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { xs: 2, sm: 4 }, py: { xs: 6, sm: 12 } }}
>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
<CircularProgress size={60} sx={{ color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
AI가 ...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
, ,
</Typography>
</Box>
@ -212,8 +254,8 @@ export default function RecommendationStep({
if (error) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
@ -247,13 +289,10 @@ export default function RecommendationStep({
size="large"
onClick={() => {
setError(null);
// props에서 eventId가 없으면 쿠키에서 읽어오기
const evtId = initialEventId || getCookie('eventId');
if (evtId) {
fetchAIRecommendations(evtId);
if (eventId) {
requestAIRecommendations(eventId);
} else {
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
createEventAndRequestAI();
}
}}
sx={{
@ -276,7 +315,7 @@ export default function RecommendationStep({
if (!aiResult) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<CircularProgress />
</Container>
</Box>
@ -285,9 +324,9 @@ export default function RecommendationStep({
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
@ -300,12 +339,12 @@ export default function RecommendationStep({
<Card
elevation={0}
sx={{
mb: { xs: 4, sm: 10 },
mb: 10,
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
<CardContent sx={{ p: 8 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
<Insights sx={{ fontSize: 32, color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
@ -318,12 +357,7 @@ export default function RecommendationStep({
📍
</Typography>
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.95rem', mb: 1 }}
>
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
@ -333,12 +367,7 @@ export default function RecommendationStep({
🗺
</Typography>
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.95rem', mb: 1 }}
>
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
@ -348,12 +377,7 @@ export default function RecommendationStep({
</Typography>
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
<Typography
key={idx}
variant="body2"
color="text.secondary"
sx={{ fontSize: '0.95rem', mb: 1 }}
>
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
@ -363,19 +387,18 @@ export default function RecommendationStep({
</Card>
{/* AI Recommendations */}
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
<Box sx={{ mb: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
AI ({aiResult.recommendations.length} )
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
.
.
. .
</Typography>
</Box>
{/* Recommendations */}
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{aiResult.recommendations.map((rec) => (
<Grid item xs={12} key={rec.optionNumber}>
<Card
@ -385,15 +408,9 @@ export default function RecommendationStep({
borderRadius: 4,
border: selected === rec.optionNumber ? 2 : 1,
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
bgcolor:
selected === rec.optionNumber
? `${colors.purpleLight}40`
: 'background.paper',
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
transition: 'all 0.2s',
boxShadow:
selected === rec.optionNumber
? '0 4px 12px rgba(0, 0, 0, 0.15)'
: '0 2px 8px rgba(0, 0, 0, 0.08)',
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:hover': {
borderColor: colors.purple,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
@ -402,15 +419,8 @@ export default function RecommendationStep({
}}
onClick={() => setSelected(rec.optionNumber)}
>
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 4,
}}
>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Chip
label={`옵션 ${rec.optionNumber}`}
@ -462,73 +472,39 @@ export default function RecommendationStep({
<Grid container spacing={4} sx={{ mt: 2 }}>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.875rem' }}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.targetAudience}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.875rem' }}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
>
{(rec.estimatedCost.min / 10000).toFixed(0)}~
{(rec.estimatedCost.max / 10000).toFixed(0)}
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.875rem' }}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
>
{rec.expectedMetrics.newCustomers.min}~
{rec.expectedMetrics.newCustomers.max}
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.875rem' }}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
ROI
</Typography>
<Typography
variant="body2"
sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}
>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
</Typography>
</Grid>
<Grid item xs={12}>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: '0.875rem' }}
>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>

View File

@ -57,74 +57,13 @@ export default function EventsPage() {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 목업 데이터
const mockEvents = [
{
eventId: 'evt_2025012301',
eventName: '신규 고객 환영 이벤트',
status: 'PUBLISHED' as ApiEventStatus,
startDate: '2025-01-23',
endDate: '2025-02-23',
participants: 1250,
targetParticipants: 2000,
roi: 320,
createdAt: '2025-01-15T00:00:00',
aiRecommendations: [{
reward: '스타벅스 아메리카노 (5명)',
participationMethod: '전화번호 입력'
}]
},
{
eventId: 'evt_2025011502',
eventName: '재방문 고객 감사 이벤트',
status: 'PUBLISHED' as ApiEventStatus,
startDate: '2025-01-15',
endDate: '2025-02-15',
participants: 890,
targetParticipants: 1000,
roi: 280,
createdAt: '2025-01-10T00:00:00',
aiRecommendations: [{
reward: '커피 쿠폰 (10명)',
participationMethod: 'SNS 팔로우'
}]
},
{
eventId: 'evt_2025010803',
eventName: '신년 특별 할인 이벤트',
status: 'ENDED' as ApiEventStatus,
startDate: '2025-01-01',
endDate: '2025-01-08',
participants: 2500,
targetParticipants: 2000,
roi: 450,
createdAt: '2024-12-28T00:00:00',
aiRecommendations: [{
reward: '10% 할인 쿠폰 (선착순 100명)',
participationMethod: '구매 인증'
}]
},
{
eventId: 'evt_2025020104',
eventName: '2월 신메뉴 출시 기념',
status: 'DRAFT' as ApiEventStatus,
startDate: '2025-02-01',
endDate: '2025-02-28',
participants: 0,
targetParticipants: 1500,
roi: 0,
createdAt: '2025-01-25T00:00:00',
aiRecommendations: [{
reward: '신메뉴 무료 쿠폰 (20명)',
participationMethod: '이메일 등록'
}]
},
];
const loading = false;
const error = null;
const apiEvents = mockEvents;
const refetch = () => {};
// API 데이터 가져오기
const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({
page: currentPage - 1,
size: itemsPerPage,
sort: 'createdAt',
order: 'desc'
});
// API 상태를 UI 상태로 매핑
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
@ -276,7 +215,7 @@ export default function EventsPage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
}}
@ -302,6 +241,41 @@ export default function EventsPage() {
</Box>
)}
{/* Error State */}
{error && (
<Card elevation={0} sx={{ ...cardStyles.default, mb: 4, bgcolor: '#FEE2E2' }}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Warning sx={{ fontSize: 48, color: '#DC2626', mb: 2 }} />
<Typography
variant="h6"
sx={{ mb: 1, color: '#991B1B', fontSize: { xs: '1rem', sm: '1.25rem' } }}
>
</Typography>
<Typography variant="body2" sx={{ color: '#7F1D1D', mb: 2 }}>
{error.message}
</Typography>
<Box
component="button"
onClick={() => refetch()}
sx={{
px: 3,
py: 1.5,
borderRadius: 2,
border: 'none',
bgcolor: '#DC2626',
color: 'white',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
'&:hover': { bgcolor: '#B91C1C' },
}}
>
</Box>
</CardContent>
</Card>
)}
{/* Summary Statistics */}
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>

View File

@ -1,16 +1,11 @@
'use client';
import { Box } from '@mui/material';
import BottomNavigation from '@/shared/ui/BottomNavigation';
import { AuthGuard } from '@/features/auth';
export default function MainLayout({ children }: { children: React.ReactNode }) {
return (
<AuthGuard>
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
{children}
<BottomNavigation />
</Box>
</AuthGuard>
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
{children}
<BottomNavigation />
</Box>
);
}

View File

@ -1,8 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab, CircularProgress, Alert } from '@mui/material';
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab } from '@mui/material';
import {
Add,
Celebration,
@ -20,9 +19,34 @@ import {
cardStyles,
colors,
} from '@/shared/lib/button-styles';
import { useAuth } from '@/features/auth/model/useAuth';
import { analyticsApi } from '@/entities/analytics/api/analyticsApi';
import type { UserAnalyticsDashboardResponse } from '@/entities/analytics/model/types';
// Mock 사용자 데이터 (API 연동 전까지 임시 사용)
const mockUser = {
name: '홍길동',
email: 'test@example.com',
};
// Mock 데이터 (추후 API 연동 시 교체)
const mockEvents = [
{
id: '1',
title: 'SNS 팔로우 이벤트',
status: '진행중',
startDate: '2025-01-20',
endDate: '2025-02-28',
participants: 1245,
roi: 320,
},
{
id: '2',
title: '설 맞이 할인 이벤트',
status: '진행중',
startDate: '2025-01-25',
endDate: '2025-02-10',
participants: 856,
roi: 280,
},
];
const mockActivities = [
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
@ -32,47 +56,14 @@ const mockActivities = [
export default function HomePage() {
const router = useRouter();
const { user } = useAuth();
const [analyticsData, setAnalyticsData] = useState<UserAnalyticsDashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Analytics API 호출
useEffect(() => {
const fetchAnalytics = async () => {
if (!user?.userId) {
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const data = await analyticsApi.getUserAnalytics(user.userId, { refresh: false });
setAnalyticsData(data);
} catch (err: any) {
console.error('Failed to fetch analytics:', err);
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
if (err.response?.status === 404 || err.response?.status === 400) {
console.log(' Analytics 데이터가 아직 생성되지 않았습니다.');
setError('아직 분석 데이터가 없습니다. 이벤트를 생성하고 참여자가 생기면 자동으로 생성됩니다.');
} else {
setError('분석 데이터를 불러오는데 실패했습니다.');
}
} finally {
setLoading(false);
}
};
fetchAnalytics();
}, [user?.userId]);
// KPI 계산 - Analytics API 데이터 사용
const activeEventsCount = analyticsData?.activeEvents ?? 0;
const totalParticipants = analyticsData?.overallSummary?.participants ?? 0;
const avgROI = Math.round((analyticsData?.overallRoi?.roi ?? 0) * 100) / 100;
const eventPerformances = analyticsData?.eventPerformances ?? [];
// KPI 계산
const activeEvents = mockEvents.filter((e) => e.status === '진행중');
const totalParticipants = mockEvents.reduce((sum, e) => sum + e.participants, 0);
const avgROI =
mockEvents.length > 0
? Math.round(mockEvents.reduce((sum, e) => sum + e.roi, 0) / mockEvents.length)
: 0;
const handleCreateEvent = () => {
router.push('/events/create');
@ -92,7 +83,7 @@ export default function HomePage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
}}
@ -107,30 +98,16 @@ export default function HomePage() {
mb: { xs: 2, sm: 4 },
}}
>
, {user?.userName || '사용자'}! 👋
, {mockUser.name}! 👋
</Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1 }}>
</Typography>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{/* Loading State */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
)}
{/* KPI Cards */}
<Grid container spacing={{ xs: 1.5, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
<Grid item xs={4} sm={4}>
<Grid container spacing={{ xs: 3, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
<Grid item xs={12} sm={4}>
<Card
elevation={0}
sx={{
@ -139,22 +116,22 @@ export default function HomePage() {
borderColor: 'transparent',
}}
>
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<Box
sx={{
width: { xs: 32, sm: 64 },
height: { xs: 32, sm: 64 },
width: { xs: 48, sm: 64 },
height: { xs: 48, sm: 64 },
borderRadius: '50%',
bgcolor: 'rgba(0, 0, 0, 0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
mb: { xs: 0.75, sm: 3 },
mb: { xs: 2, sm: 3 },
}}
>
<Celebration sx={{
fontSize: { xs: 18, sm: 32 },
fontSize: { xs: 24, sm: 32 },
color: colors.gray[900],
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
}} />
@ -165,9 +142,8 @@ export default function HomePage() {
mb: 0.5,
color: colors.gray[700],
fontWeight: 500,
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
fontSize: { xs: '0.75rem', sm: '0.875rem' },
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
lineHeight: 1.2,
}}
>
@ -177,16 +153,16 @@ export default function HomePage() {
sx={{
fontWeight: 700,
color: colors.gray[900],
fontSize: { xs: '1.375rem', sm: '2.25rem' },
fontSize: { xs: '1.5rem', sm: '2.25rem' },
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
{activeEventsCount}
{activeEvents.length}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={4} sm={4}>
<Grid item xs={12} sm={4}>
<Card
elevation={0}
sx={{
@ -195,22 +171,22 @@ export default function HomePage() {
borderColor: 'transparent',
}}
>
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<Box
sx={{
width: { xs: 32, sm: 64 },
height: { xs: 32, sm: 64 },
width: { xs: 48, sm: 64 },
height: { xs: 48, sm: 64 },
borderRadius: '50%',
bgcolor: 'rgba(0, 0, 0, 0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
mb: { xs: 0.75, sm: 3 },
mb: { xs: 2, sm: 3 },
}}
>
<Group sx={{
fontSize: { xs: 18, sm: 32 },
fontSize: { xs: 24, sm: 32 },
color: colors.gray[900],
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
}} />
@ -221,9 +197,8 @@ export default function HomePage() {
mb: 0.5,
color: colors.gray[700],
fontWeight: 500,
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
fontSize: { xs: '0.75rem', sm: '0.875rem' },
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
lineHeight: 1.2,
}}
>
@ -233,7 +208,7 @@ export default function HomePage() {
sx={{
fontWeight: 700,
color: colors.gray[900],
fontSize: { xs: '1.375rem', sm: '2.25rem' },
fontSize: { xs: '1.5rem', sm: '2.25rem' },
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
@ -242,7 +217,7 @@ export default function HomePage() {
</CardContent>
</Card>
</Grid>
<Grid item xs={4} sm={4}>
<Grid item xs={12} sm={4}>
<Card
elevation={0}
sx={{
@ -251,22 +226,22 @@ export default function HomePage() {
borderColor: 'transparent',
}}
>
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<Box
sx={{
width: { xs: 32, sm: 64 },
height: { xs: 32, sm: 64 },
width: { xs: 48, sm: 64 },
height: { xs: 48, sm: 64 },
borderRadius: '50%',
bgcolor: 'rgba(0, 0, 0, 0.05)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto',
mb: { xs: 0.75, sm: 3 },
mb: { xs: 2, sm: 3 },
}}
>
<TrendingUp sx={{
fontSize: { xs: 18, sm: 32 },
fontSize: { xs: 24, sm: 32 },
color: colors.gray[900],
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
}} />
@ -277,9 +252,8 @@ export default function HomePage() {
mb: 0.5,
color: colors.gray[700],
fontWeight: 500,
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
fontSize: { xs: '0.75rem', sm: '0.875rem' },
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
lineHeight: 1.2,
}}
>
ROI
@ -289,7 +263,7 @@ export default function HomePage() {
sx={{
fontWeight: 700,
color: colors.gray[900],
fontSize: { xs: '1.375rem', sm: '2.25rem' },
fontSize: { xs: '1.5rem', sm: '2.25rem' },
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
@ -314,7 +288,7 @@ export default function HomePage() {
}}
onClick={handleCreateEvent}
>
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
<Box
sx={{
width: { xs: 56, sm: 72 },
@ -345,7 +319,7 @@ export default function HomePage() {
}}
onClick={handleViewAnalytics}
>
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
<Box
sx={{
width: { xs: 56, sm: 72 },
@ -394,7 +368,7 @@ export default function HomePage() {
</Button>
</Box>
{!loading && eventPerformances.length === 0 ? (
{activeEvents.length === 0 ? (
<Card
elevation={0}
sx={{
@ -428,16 +402,16 @@ export default function HomePage() {
</Button>
</CardContent>
</Card>
) : !loading && (
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 3, sm: 6 } }}>
{eventPerformances.slice(0, 2).map((event) => (
{activeEvents.map((event) => (
<Card
key={event.eventId}
key={event.id}
elevation={0}
sx={{
...cardStyles.clickable,
}}
onClick={() => handleEventClick(event.eventId)}
onClick={() => handleEventClick(event.id)}
>
<CardContent sx={{ p: { xs: 4, sm: 6, md: 8 } }}>
<Box
@ -450,7 +424,7 @@ export default function HomePage() {
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
{event.eventTitle}
{event.title}
</Typography>
<Box
sx={{
@ -467,7 +441,23 @@ export default function HomePage() {
{event.status}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: { xs: 6, sm: 12 }, mt: { xs: 3, sm: 6 } }}>
<Typography
variant="body2"
sx={{
mb: { xs: 3, sm: 6 },
color: colors.gray[600],
display: 'flex',
alignItems: 'center',
gap: 1,
fontSize: { xs: '0.75rem', sm: '0.875rem' },
}}
>
<span>📅</span>
<span>
{event.startDate} ~ {event.endDate}
</span>
</Typography>
<Box sx={{ display: 'flex', gap: { xs: 6, sm: 12 } }}>
<Box>
<Typography
variant="body2"
@ -489,27 +479,6 @@ export default function HomePage() {
</Typography>
</Typography>
</Box>
<Box>
<Typography
variant="body2"
sx={{ mb: 0.5, color: colors.gray[600], fontWeight: 500, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
<Typography
variant="h5"
sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1.125rem', sm: '1.5rem' } }}
>
{event.views.toLocaleString()}
<Typography
component="span"
variant="body2"
sx={{ ml: 0.5, color: colors.gray[600], fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
>
</Typography>
</Typography>
</Box>
<Box>
<Typography
variant="body2"
@ -518,7 +487,7 @@ export default function HomePage() {
ROI
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
{Math.round(event.roi * 100) / 100}%
{event.roi}%
</Typography>
</Box>
</Box>

View File

@ -13,23 +13,24 @@ import {
Typography,
Card,
CardContent,
Avatar,
Select,
MenuItem,
FormControl,
InputLabel,
InputAdornment,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { CheckCircle } from '@mui/icons-material';
import { Person, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
import { useAuthContext } from '@/features/auth';
import { useUIStore } from '@/stores/uiStore';
import { userApi } from '@/entities/user';
import Header from '@/shared/ui/Header';
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
import Image from 'next/image';
import userImage from '@/shared/ui/user_img.png';
// 기본 정보 스키마
const basicInfoSchema = z.object({
@ -49,13 +50,32 @@ const businessInfoSchema = z.object({
businessHours: z.string().optional(),
});
// 비밀번호 변경 스키마
const passwordSchema = z
.object({
currentPassword: z.string().min(1, '현재 비밀번호를 입력해주세요'),
newPassword: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.max(100, '비밀번호는 100자 이하여야 합니다'),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: '새 비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
});
type BasicInfoData = z.infer<typeof basicInfoSchema>;
type BusinessInfoData = z.infer<typeof businessInfoSchema>;
type PasswordData = z.infer<typeof passwordSchema>;
export default function ProfilePage() {
const router = useRouter();
const { user, logout, refreshProfile } = useAuthContext();
const { showToast, setLoading } = useUIStore();
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
const [profileLoaded, setProfileLoaded] = useState(false);
@ -85,12 +105,26 @@ export default function ProfilePage() {
resolver: zodResolver(businessInfoSchema),
defaultValues: {
businessName: '',
businessType: 'restaurant',
businessType: '',
businessLocation: '',
businessHours: '',
},
});
// 비밀번호 변경 폼
const {
control: passwordControl,
handleSubmit: handlePasswordSubmit,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
// 프로필 데이터 로드
useEffect(() => {
@ -130,7 +164,7 @@ export default function ProfilePage() {
// 사업장 정보 폼 초기화
resetBusiness({
businessName: profile.storeName || '',
businessType: profile.industry || 'restaurant',
businessType: profile.industry || '',
businessLocation: profile.address || '',
businessHours: profile.businessHours || '',
});
@ -210,6 +244,40 @@ export default function ProfilePage() {
}
};
const onChangePassword = async (data: PasswordData) => {
console.log('🔐 비밀번호 변경 시작');
try {
setLoading(true);
const passwordData = {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
};
console.log('📡 비밀번호 변경 API 호출');
await userApi.changePassword(passwordData);
console.log('✅ 비밀번호 변경 성공');
showToast('비밀번호가 변경되었습니다', 'success');
resetPassword();
} catch (error: any) {
console.error('❌ 비밀번호 변경 실패:', error);
let errorMessage = '비밀번호 변경에 실패했습니다';
if (error.response) {
errorMessage = error.response.data?.message ||
error.response.data?.error ||
`서버 오류 (${error.response.status})`;
} else if (error.request) {
errorMessage = '서버로부터 응답이 없습니다';
}
showToast(errorMessage, 'error');
} finally {
setLoading(false);
}
};
const handleSave = () => {
handleBasicSubmit((basicData) => {
@ -242,34 +310,27 @@ export default function ProfilePage() {
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: { xs: 4, sm: 10 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
}}
>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
{/* 사용자 정보 섹션 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10, textAlign: 'center' }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Box
<Avatar
sx={{
width: 100,
height: 100,
mx: 'auto',
mb: 3,
borderRadius: '50%',
overflow: 'hidden',
position: 'relative',
bgcolor: colors.purple,
color: 'white',
}}
>
<Image
src={userImage}
alt="User Profile"
fill
style={{ objectFit: 'cover' }}
priority
/>
</Box>
<Person sx={{ fontSize: 56 }} />
</Avatar>
<Typography variant="h5" sx={{ ...responsiveText.h3, mb: 1 }}>
{user?.userName}
</Typography>
@ -280,7 +341,7 @@ export default function ProfilePage() {
</Card>
{/* 기본 정보 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
@ -338,7 +399,7 @@ export default function ProfilePage() {
</Card>
{/* 매장 정보 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
@ -408,6 +469,121 @@ export default function ProfilePage() {
</CardContent>
</Card>
{/* 비밀번호 변경 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Controller
name="currentPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showCurrentPassword ? 'text' : 'password'}
label="현재 비밀번호"
placeholder="현재 비밀번호를 입력하세요"
error={!!passwordErrors.currentPassword}
helperText={passwordErrors.currentPassword?.message}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
edge="end"
>
{showCurrentPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="newPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showNewPassword ? 'text' : 'password'}
label="새 비밀번호"
placeholder="새 비밀번호를 입력하세요"
error={!!passwordErrors.newPassword}
helperText={passwordErrors.newPassword?.message || '8자 이상 입력해주세요'}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowNewPassword(!showNewPassword)}
edge="end"
>
{showNewPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="confirmPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showConfirmPassword ? 'text' : 'password'}
label="비밀번호 확인"
placeholder="비밀번호를 다시 입력하세요"
error={!!passwordErrors.confirmPassword}
helperText={passwordErrors.confirmPassword?.message}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Button
fullWidth
variant="outlined"
size="large"
onClick={handlePasswordSubmit(onChangePassword)}
sx={{
mt: 1,
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
</Box>
</CardContent>
</Card>
{/* 액션 버튼 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Button
@ -458,12 +634,12 @@ export default function ProfilePage() {
},
}}
>
<DialogContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
<CheckCircle sx={{ fontSize: { xs: 48, sm: 64 }, color: 'success.main', mb: { xs: 1, sm: 2 } }} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
<DialogContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
.
</Typography>
</DialogContent>

View File

@ -519,7 +519,7 @@ export default function TestAnalyticsPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
@ -544,7 +544,7 @@ export default function TestAnalyticsPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
@ -569,7 +569,7 @@ export default function TestAnalyticsPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
@ -594,7 +594,7 @@ export default function TestAnalyticsPage() {
>
<CardContent sx={{
textAlign: 'center',
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
py: { xs: 2, sm: 6 },
px: { xs: 1, sm: 4 },
}}>
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/channels${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] Event channels error:', error);
return NextResponse.json(
{ message: 'Event Channels 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/roi${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] Event ROI error:', error);
return NextResponse.json(
{ message: 'Event ROI 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] Event analytics error:', error);
return NextResponse.json(
{ message: 'Event Analytics 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/timeline${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] Event timeline error:', error);
return NextResponse.json(
{ message: 'Event Timeline 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
try {
const { userId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/channels${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] User channels error:', error);
return NextResponse.json(
{ message: 'Channels 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
try {
const { userId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/roi${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] User ROI error:', error);
return NextResponse.json(
{ message: 'ROI 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
/**
* GET /api/analytics/users/{userId}
* Proxy for User Analytics Dashboard
*/
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
try {
const { userId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
console.log('📊 [Analytics Proxy] Get user analytics request:', {
userId,
hasToken: !!token,
params: Object.fromEntries(searchParams),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
console.log('✅ [Analytics Proxy] User analytics response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] User analytics error:', error);
return NextResponse.json(
{ message: 'Analytics 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { userId: string } }
) {
try {
const { userId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/timeline${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Analytics Proxy] User timeline error:', error);
return NextResponse.json(
{ message: 'Timeline 데이터 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ eventDraftId: string }> }
) {
try {
const { eventDraftId } = await context.params;
const { searchParams } = new URL(request.url);
const style = searchParams.get('style');
const platform = searchParams.get('platform');
// eventDraftId is now eventId in the API
let url = `${CONTENT_API_BASE_URL}/api/v1/content/events/${eventDraftId}/images`;
const queryParams = [];
if (style) queryParams.push(`style=${style}`);
if (platform) queryParams.push(`platform=${platform}`);
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`;
}
console.log('🔄 Proxying images request to Content API:', { url });
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log('🔄 Proxying image generation request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/generate`,
body,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to generate images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Image generation job created:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ jobId: string }> }
) {
try {
const { jobId } = await context.params;
console.log('🔄 Proxying job status request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get job status', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,44 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const DISTRIBUTION_API_BASE_URL = process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
console.log('🔄 Proxying distribution status request to Distribution API:', {
url: `${DISTRIBUTION_API_BASE_URL}/api/v1/distribution/${eventId}/status`,
eventId,
});
const response = await fetch(`${DISTRIBUTION_API_BASE_URL}/distribution/${eventId}/status`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Distribution API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get distribution status', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Distribution status retrieved:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,58 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const body = await request.json();
console.log('🎰 [Proxy] Draw winners request:', {
eventId,
hasToken: !!token,
timestamp: new Date().toISOString(),
});
if (!token) {
return NextResponse.json(
{ message: '인증 토큰이 필요합니다.' },
{ status: 401 }
);
}
const response = await fetch(
`${GATEWAY_HOST}/api/v1/participations/events/${eventId}/draw-winners`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
}
);
const data = await response.json();
console.log('📥 [Proxy] Draw winners response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Draw winners error:', error);
return NextResponse.json(
{ message: '당첨자 추첨 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,55 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string; participantId: string } }
) {
try {
const { eventId, participantId } = params;
const token = request.headers.get('Authorization');
console.log('👤 [Proxy] Get participant request:', {
eventId,
participantId,
hasToken: !!token,
timestamp: new Date().toISOString(),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const response = await fetch(
`${GATEWAY_HOST}/api/v1/participations/events/${eventId}/participants/${participantId}`,
{
method: 'GET',
headers,
}
);
const data = await response.json();
console.log('📥 [Proxy] Get participant response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Get participant error:', error);
return NextResponse.json(
{ message: '참여자 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
console.log('📋 [Proxy] Get participants request:', {
eventId,
hasToken: !!token,
params: Object.fromEntries(searchParams),
timestamp: new Date().toISOString(),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${GATEWAY_HOST}/api/v1/participations/events/${eventId}/participants${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
console.log('📥 [Proxy] Get participants response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Get participants error:', error);
return NextResponse.json(
{ message: '참여자 목록 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(
request: NextRequest,
context: { params: Promise<{ eventId: string }> }
) {
try {
const { eventId } = await context.params;
const token = request.headers.get('Authorization');
const body = await request.json();
console.log('🎫 [Proxy] Participate request:', {
eventId,
hasToken: !!token,
timestamp: new Date().toISOString(),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const response = await fetch(
`${GATEWAY_HOST}/api/v1/participations/events/${eventId}/participate`,
{
method: 'POST',
headers,
body: JSON.stringify(body),
}
);
const data = await response.json();
console.log('📥 [Proxy] Participate response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Participate error:', error);
return NextResponse.json(
{ message: '참여 요청 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const token = request.headers.get('Authorization');
const { searchParams } = new URL(request.url);
console.log('🏆 [Proxy] Get winners request:', {
eventId,
hasToken: !!token,
params: Object.fromEntries(searchParams),
timestamp: new Date().toISOString(),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const queryString = searchParams.toString();
const url = `${GATEWAY_HOST}/api/v1/participations/events/${eventId}/winners${queryString ? `?${queryString}` : ''}`;
const response = await fetch(url, {
method: 'GET',
headers,
});
const data = await response.json();
console.log('📥 [Proxy] Get winners response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Get winners error:', error);
return NextResponse.json(
{ message: '당첨자 목록 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,47 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const PARTICIPATION_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const body = await request.json();
console.log('🔄 Proxying draw winners request to Participation API:', {
url: `${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/draw-winners`,
eventId,
body,
});
const response = await fetch(`${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/draw-winners`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Participation API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to draw winners', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Winners drawn:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,48 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const PARTICIPATION_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string; participantId: string } }
) {
try {
const { eventId, participantId } = params;
console.log('🔄 Proxying get participant request to Participation API:', {
url: `${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/participants/${participantId}`,
eventId,
participantId,
});
const response = await fetch(
`${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/participants/${participantId}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Participation API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get participant', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Participant retrieved:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,61 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const PARTICIPATION_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const { searchParams } = new URL(request.url);
// Extract query parameters
const storeVisited = searchParams.get('storeVisited');
const page = searchParams.get('page') || '0';
const size = searchParams.get('size') || '20';
const sort = searchParams.getAll('sort');
// Build query string
const queryParams = new URLSearchParams();
if (storeVisited !== null) queryParams.append('storeVisited', storeVisited);
queryParams.append('page', page);
queryParams.append('size', size);
sort.forEach(s => queryParams.append('sort', s));
const url = `${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/participants?${queryParams.toString()}`;
console.log('🔄 Proxying participants list request to Participation API:', {
url,
eventId,
params: { storeVisited, page, size, sort },
});
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Participation API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get participants', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Participants retrieved:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,47 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const PARTICIPATION_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const body = await request.json();
console.log('🔄 Proxying participation request to Participation API:', {
url: `${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/participate`,
eventId,
body,
});
const response = await fetch(`${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/participate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Participation API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to participate in event', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Participation created:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -1,59 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const PARTICIPATION_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(
request: NextRequest,
{ params }: { params: { eventId: string } }
) {
try {
const { eventId } = params;
const { searchParams } = new URL(request.url);
// Extract query parameters
const page = searchParams.get('page') || '0';
const size = searchParams.get('size') || '20';
const sort = searchParams.getAll('sort');
// Build query string
const queryParams = new URLSearchParams();
queryParams.append('page', page);
queryParams.append('size', size);
sort.forEach(s => queryParams.append('sort', s));
const url = `${PARTICIPATION_API_BASE_URL}/api/v1/participations/api/v1/events/${eventId}/winners?${queryParams.toString()}`;
console.log('🔄 Proxying winners list request to Participation API:', {
url,
eventId,
params: { page, size, sort },
});
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Participation API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get winners', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Winners retrieved:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@ -12,8 +12,7 @@ export async function POST(request: NextRequest) {
const { objective } = body;
// 백엔드 API 호출 시도
const EVENT_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
const backendUrl = `${EVENT_HOST}/api/events/objectives`;
const backendUrl = 'http://localhost:8080/api/events/objectives';
try {
const backendResponse = await fetch(backendUrl, {

View File

@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log('🔐 [Proxy] Login request:', {
email: body.email,
timestamp: new Date().toISOString(),
});
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
console.log('📥 [Proxy] Login response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Login error:', error);
return NextResponse.json(
{ message: '로그인 요청 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,47 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(request: NextRequest) {
try {
const token = request.headers.get('Authorization');
console.log('🚪 [Proxy] Logout request:', {
hasToken: !!token,
timestamp: new Date().toISOString(),
});
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = token;
}
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/logout`, {
method: 'POST',
headers,
body: JSON.stringify({}),
});
const data = await response.json();
console.log('📥 [Proxy] Logout response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Logout error:', error);
return NextResponse.json(
{ message: '로그아웃 요청 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,53 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function PUT(request: NextRequest) {
try {
const token = request.headers.get('Authorization');
const body = await request.json();
console.log('🔑 [Proxy] Change password request:', {
hasToken: !!token,
timestamp: new Date().toISOString(),
});
if (!token) {
return NextResponse.json(
{ message: '인증 토큰이 필요합니다.' },
{ status: 401 }
);
}
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const data = await response.json();
console.log('📥 [Proxy] Change password response:', {
status: response.status,
success: false,
});
return NextResponse.json(data, { status: response.status });
}
console.log('📥 [Proxy] Change password response:', {
status: response.status,
success: true,
});
return new NextResponse(null, { status: 200 });
} catch (error) {
console.error('❌ [Proxy] Change password error:', error);
return NextResponse.json(
{ message: '비밀번호 변경 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,95 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function GET(request: NextRequest) {
try {
const token = request.headers.get('Authorization');
console.log('👤 [Proxy] Get profile request:', {
hasToken: !!token,
timestamp: new Date().toISOString(),
});
if (!token) {
return NextResponse.json(
{ message: '인증 토큰이 필요합니다.' },
{ status: 401 }
);
}
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/profile`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
});
const data = await response.json();
console.log('📥 [Proxy] Get profile response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Get profile error:', error);
return NextResponse.json(
{ message: '프로필 조회 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}
export async function PUT(request: NextRequest) {
try {
const token = request.headers.get('Authorization');
const body = await request.json();
console.log('✏️ [Proxy] Update profile request:', {
hasToken: !!token,
timestamp: new Date().toISOString(),
});
if (!token) {
return NextResponse.json(
{ message: '인증 토큰이 필요합니다.' },
{ status: 401 }
);
}
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/profile`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify(body),
});
const data = await response.json();
console.log('📥 [Proxy] Update profile response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Update profile error:', error);
return NextResponse.json(
{ message: '프로필 수정 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,42 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log('📝 [Proxy] Register request:', {
email: body.email,
name: body.name,
timestamp: new Date().toISOString(),
});
const response = await fetch(`${GATEWAY_HOST}/api/v1/users/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
const data = await response.json();
console.log('📥 [Proxy] Register response:', {
status: response.status,
success: response.ok,
});
if (!response.ok) {
return NextResponse.json(data, { status: response.status });
}
return NextResponse.json(data);
} catch (error) {
console.error('❌ [Proxy] Register error:', error);
return NextResponse.json(
{ message: '회원가입 요청 중 오류가 발생했습니다.' },
{ status: 500 }
);
}
}

View File

@ -1,5 +1,4 @@
import type { Metadata, Viewport } from 'next';
import Script from 'next/script';
import { MUIThemeProvider } from '@/shared/lib/theme-provider';
import { ReactQueryProvider } from '@/shared/lib/react-query-provider';
import { AuthProvider } from '@/features/auth';
@ -35,7 +34,6 @@ export default function RootLayout({
/>
</head>
<body>
<Script src="/runtime-env.js" strategy="beforeInteractive" />
<MUIThemeProvider>
<ReactQueryProvider>
<AuthProvider>

View File

@ -15,11 +15,11 @@ import type {
RoiQueryParams,
} from '../model/types';
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
/**
* Analytics API Service
* API
*
* Note: Proxy routes handle /api/v1 prefix, so paths start with /users or /events
*/
export const analyticsApi = {
// ============= User Analytics (사용자 전체 이벤트 통합) =============
@ -32,7 +32,7 @@ export const analyticsApi = {
params?: AnalyticsQueryParams
): Promise<UserAnalyticsDashboardResponse> => {
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
`/users/${userId}`,
`/api/${API_VERSION}/users/${userId}/analytics`,
{ params }
);
return response.data.data;
@ -46,7 +46,7 @@ export const analyticsApi = {
params?: TimelineQueryParams
): Promise<UserTimelineAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
`/users/${userId}/timeline`,
`/api/${API_VERSION}/users/${userId}/analytics/timeline`,
{ params }
);
return response.data.data;
@ -60,7 +60,7 @@ export const analyticsApi = {
params?: AnalyticsQueryParams & RoiQueryParams
): Promise<UserRoiAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
`/users/${userId}/roi`,
`/api/${API_VERSION}/users/${userId}/analytics/roi`,
{ params }
);
return response.data.data;
@ -74,7 +74,7 @@ export const analyticsApi = {
params?: ChannelQueryParams
): Promise<UserChannelAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
`/users/${userId}/channels`,
`/api/${API_VERSION}/users/${userId}/analytics/channels`,
{ params }
);
return response.data.data;
@ -90,7 +90,7 @@ export const analyticsApi = {
params?: AnalyticsQueryParams
): Promise<AnalyticsDashboardResponse> => {
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
`/events/${eventId}`,
`/api/${API_VERSION}/events/${eventId}/analytics`,
{ params }
);
return response.data.data;
@ -104,7 +104,7 @@ export const analyticsApi = {
params?: TimelineQueryParams
): Promise<TimelineAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
`/events/${eventId}/timeline`,
`/api/${API_VERSION}/events/${eventId}/analytics/timeline`,
{ params }
);
return response.data.data;
@ -118,7 +118,7 @@ export const analyticsApi = {
params?: RoiQueryParams
): Promise<RoiAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
`/events/${eventId}/roi`,
`/api/${API_VERSION}/events/${eventId}/analytics/roi`,
{ params }
);
return response.data.data;
@ -132,7 +132,7 @@ export const analyticsApi = {
params?: ChannelQueryParams
): Promise<ChannelAnalyticsResponse> => {
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
`/events/${eventId}/channels`,
`/api/${API_VERSION}/events/${eventId}/analytics/channels`,
{ params }
);
return response.data.data;

View File

@ -1,10 +1,10 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
// Next.js API Proxy를 통해 Analytics API 호출 (CORS 회피)
const ANALYTICS_PROXY_BASE = '/api/analytics';
const ANALYTICS_HOST =
process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086';
export const analyticsClient: AxiosInstance = axios.create({
baseURL: ANALYTICS_PROXY_BASE,
baseURL: ANALYTICS_HOST,
timeout: 30000,
headers: {
'Content-Type': 'application/json',

View File

@ -29,18 +29,21 @@ const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'
/**
* Event Service용 API
* Event Service는 (8080)
*
* 환경: Next.js rewrites (CORS )
* 환경: 환경
*/
import axios from 'axios';
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
const eventApiClient = axios.create({
baseURL: `${EVENT_HOST}/api/${API_VERSION}`,
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: false, // CORS 설정
});
// Request interceptor - JWT 토큰 추가

View File

@ -1 +0,0 @@
export { participationApi, default } from './participationApi';

View File

@ -1,142 +0,0 @@
import axios, { AxiosInstance } from 'axios';
import type {
ParticipationRequest,
ParticipationResponse,
ApiResponse,
PageResponse,
DrawWinnersRequest,
DrawWinnersResponse,
} from '../model/types';
// Use Next.js API proxy to bypass CORS issues
const PARTICIPATION_API_BASE = '/api/participations';
const participationApiClient: AxiosInstance = axios.create({
baseURL: PARTICIPATION_API_BASE,
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
participationApiClient.interceptors.request.use(
(config) => {
console.log('🎫 Participation API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ Participation API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
participationApiClient.interceptors.response.use(
(response) => {
console.log('✅ Participation API Response:', {
status: response.status,
url: response.config.url,
});
return response;
},
(error) => {
console.error('❌ Participation API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
});
return Promise.reject(error);
}
);
/**
* Participation API Service
* API
*/
export const participationApi = {
/**
*
*/
participate: async (
eventId: string,
data: ParticipationRequest
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await participationApiClient.post<
ApiResponse<ParticipationResponse>
>(`/${eventId}/participate`, data);
return response.data;
},
/**
*
*/
getParticipants: async (
eventId: string,
params?: {
storeVisited?: boolean;
page?: number;
size?: number;
sort?: string[];
}
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const response = await participationApiClient.get<
ApiResponse<PageResponse<ParticipationResponse>>
>(`/${eventId}/participants`, { params });
return response.data;
},
/**
*
*/
getParticipant: async (
eventId: string,
participantId: string
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await participationApiClient.get<
ApiResponse<ParticipationResponse>
>(`/${eventId}/participants/${participantId}`);
return response.data;
},
/**
*
*/
drawWinners: async (
eventId: string,
data: DrawWinnersRequest
): Promise<ApiResponse<DrawWinnersResponse>> => {
const response = await participationApiClient.post<
ApiResponse<DrawWinnersResponse>
>(`/${eventId}/draw-winners`, data);
return response.data;
},
/**
*
*/
getWinners: async (
eventId: string,
params?: {
page?: number;
size?: number;
sort?: string[];
}
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const response = await participationApiClient.get<
ApiResponse<PageResponse<ParticipationResponse>>
>(`/${eventId}/winners`, { params });
return response.data;
},
};
export default participationApi;

View File

@ -1,10 +0,0 @@
export { participationApi } from './api';
export type {
ParticipationRequest,
ParticipationResponse,
ApiResponse,
PageResponse,
DrawWinnersRequest,
DrawWinnersResponse,
WinnerSummary,
} from './model';

View File

@ -1,9 +0,0 @@
export type {
ParticipationRequest,
ParticipationResponse,
ApiResponse,
PageResponse,
DrawWinnersRequest,
DrawWinnersResponse,
WinnerSummary,
} from './types';

View File

@ -1,114 +0,0 @@
/**
* Participation API Types
*
*/
/**
*
*/
export interface ParticipationRequest {
/** 이름 (2-50자, 필수) */
name: string;
/** 전화번호 (형식: "010-1234-5678", 필수) */
phoneNumber: string;
/** 이메일 (선택) */
email?: string;
/** 채널 (선택) */
channel?: string;
/** 마케팅 동의 (선택) */
agreeMarketing?: boolean;
/** 개인정보 동의 (필수) */
agreePrivacy: boolean;
/** 매장 방문 여부 (선택) */
storeVisited?: boolean;
}
/**
*
*/
export interface ParticipationResponse {
/** 참여자 ID (UUID) */
participantId: string;
/** 이벤트 ID */
eventId: string;
/** 이름 */
name: string;
/** 전화번호 */
phoneNumber: string;
/** 이메일 */
email?: string;
/** 채널 */
channel?: string;
/** 참여 일시 */
participatedAt: string;
/** 매장 방문 여부 */
storeVisited?: boolean;
/** 보너스 응모권 수 */
bonusEntries: number;
/** 당첨 여부 */
isWinner: boolean;
}
/**
* API
*/
export interface ApiResponse<T> {
success: boolean;
data: T;
errorCode?: string;
message?: string;
timestamp: string;
}
/**
*
*/
export interface PageResponse<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
first: boolean;
last: boolean;
}
/**
*
*/
export interface DrawWinnersRequest {
/** 당첨자 수 (최소 1명, 필수) */
winnerCount: number;
/** 매장 방문 보너스 적용 여부 (선택) */
applyStoreVisitBonus?: boolean;
}
/**
*
*/
export interface WinnerSummary {
/** 참여자 ID */
participantId: string;
/** 이름 */
name: string;
/** 전화번호 */
phoneNumber: string;
/** 등수 */
rank: number;
}
/**
*
*/
export interface DrawWinnersResponse {
/** 이벤트 ID */
eventId: string;
/** 총 참여자 수 */
totalParticipants: number;
/** 당첨자 수 */
winnerCount: number;
/** 추첨 일시 */
drawnAt: string;
/** 당첨자 목록 */
winners: WinnerSummary[];
}

View File

@ -1,4 +1,4 @@
import axios, { AxiosInstance } from 'axios';
import { apiClient } from '@/shared/api';
import type {
LoginRequest,
LoginResponse,
@ -10,57 +10,8 @@ import type {
ChangePasswordRequest,
} from '../model/types';
// Use Next.js API proxy to bypass CORS issues
const USER_API_BASE = '/api/v1/users';
const userApiClient: AxiosInstance = axios.create({
baseURL: USER_API_BASE,
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
userApiClient.interceptors.request.use(
(config) => {
console.log('👤 User API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ User API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
userApiClient.interceptors.response.use(
(response) => {
console.log('✅ User API Response:', {
status: response.status,
url: response.config.url,
});
return response;
},
(error) => {
console.error('❌ User API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
});
return Promise.reject(error);
}
);
/**
* User API Service
* API
@ -70,8 +21,8 @@ export const userApi = {
*
*/
login: async (data: LoginRequest): Promise<LoginResponse> => {
const response = await userApiClient.post<LoginResponse>(
'/login',
const response = await apiClient.post<LoginResponse>(
`${USER_API_BASE}/login`,
data
);
return response.data;
@ -82,14 +33,15 @@ export const userApi = {
*/
register: async (data: RegisterRequest): Promise<RegisterResponse> => {
console.log('📞 userApi.register 호출');
console.log('🎯 URL:', `${USER_API_BASE}/register`);
console.log('📦 요청 데이터:', {
...data,
password: '***'
});
try {
const response = await userApiClient.post<RegisterResponse>(
'/register',
const response = await apiClient.post<RegisterResponse>(
`${USER_API_BASE}/register`,
data
);
console.log('✅ userApi.register 성공:', response.data);
@ -104,9 +56,15 @@ export const userApi = {
*
*/
logout: async (): Promise<LogoutResponse> => {
const response = await userApiClient.post<LogoutResponse>(
'/logout',
{}
const token = localStorage.getItem('accessToken');
const response = await apiClient.post<LogoutResponse>(
`${USER_API_BASE}/logout`,
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
return response.data;
},
@ -115,8 +73,8 @@ export const userApi = {
*
*/
getProfile: async (): Promise<ProfileResponse> => {
const response = await userApiClient.get<ProfileResponse>(
'/profile'
const response = await apiClient.get<ProfileResponse>(
`${USER_API_BASE}/profile`
);
return response.data;
},
@ -127,8 +85,8 @@ export const userApi = {
updateProfile: async (
data: UpdateProfileRequest
): Promise<ProfileResponse> => {
const response = await userApiClient.put<ProfileResponse>(
'/profile',
const response = await apiClient.put<ProfileResponse>(
`${USER_API_BASE}/profile`,
data
);
return response.data;
@ -138,7 +96,7 @@ export const userApi = {
*
*/
changePassword: async (data: ChangePasswordRequest): Promise<void> => {
await userApiClient.put('/password', data);
await apiClient.put(`${USER_API_BASE}/password`, data);
},
};

View File

@ -11,7 +11,7 @@ export interface LoginRequest {
export interface LoginResponse {
token: string;
userId: string; // UUID format
userId: number;
userName: string;
role: string;
email: string;
@ -31,9 +31,9 @@ export interface RegisterRequest {
export interface RegisterResponse {
token: string;
userId: string; // UUID format
userId: number;
userName: string;
storeId: string; // UUID format
storeId: number;
storeName: string;
}
@ -45,12 +45,12 @@ export interface LogoutResponse {
// 프로필 조회/수정
export interface ProfileResponse {
userId: string; // UUID format
userId: number;
userName: string;
phoneNumber: string;
email: string;
role: string;
storeId: string; // UUID format
storeId: number;
storeName: string;
industry: string;
address: string;
@ -77,12 +77,12 @@ export interface ChangePasswordRequest {
// User 상태
export interface User {
userId: string; // UUID format
userId: number;
userName: string;
email: string;
role: string;
phoneNumber?: string;
storeId?: string; // UUID format
storeId?: number;
storeName?: string;
industry?: string;
address?: string;

View File

@ -1,3 +1,2 @@
export { useAuth } from './model/useAuth';
export { AuthProvider, useAuthContext } from './model/AuthProvider';
export { AuthGuard } from './ui/AuthGuard';

View File

@ -57,25 +57,14 @@ export const useAuth = () => {
try {
const response = await userApi.login(credentials);
// 토큰을 먼저 저장 (프로필 조회에 필요)
localStorage.setItem(TOKEN_KEY, response.token);
// 프로필 조회하여 storeId 포함한 전체 정보 가져오기
const profile = await userApi.getProfile();
const user: User = {
userId: profile.userId,
userName: profile.userName,
email: profile.email,
role: profile.role,
phoneNumber: profile.phoneNumber,
storeId: profile.storeId,
storeName: profile.storeName,
industry: profile.industry,
address: profile.address,
businessHours: profile.businessHours,
userId: response.userId,
userName: response.userName,
email: response.email,
role: response.role,
};
localStorage.setItem(TOKEN_KEY, response.token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
setAuthState({
@ -87,8 +76,6 @@ export const useAuth = () => {
return { success: true, user };
} catch (error) {
// 로그인 실패 시 저장된 토큰 삭제
localStorage.removeItem(TOKEN_KEY);
return {
success: false,
error: error instanceof Error ? error.message : '로그인에 실패했습니다.',

View File

@ -1,70 +0,0 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Box, CircularProgress, Typography } from '@mui/material';
import { useAuthContext } from '../model/AuthProvider';
import { colors } from '@/shared/lib/button-styles';
interface AuthGuardProps {
children: React.ReactNode;
}
/**
*
* -
* - user
*/
export const AuthGuard = ({ children }: AuthGuardProps) => {
const { isAuthenticated, user, isLoading } = useAuthContext();
const router = useRouter();
useEffect(() => {
// 로딩이 완료되고 인증되지 않았거나 사용자 정보가 없으면 로그인 페이지로 리다이렉트
if (!isLoading && (!isAuthenticated || !user)) {
console.log('🚫 인증되지 않은 접근 시도 - 로그인 페이지로 리다이렉트');
router.push('/login');
}
}, [isLoading, isAuthenticated, user, router]);
// 로딩 중
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: colors.gray[50],
gap: 3,
}}
>
<CircularProgress
size={60}
sx={{
color: colors.purple,
}}
/>
<Typography
sx={{
color: colors.gray[600],
fontSize: { xs: '0.875rem', sm: '1rem' },
fontWeight: 500,
}}
>
...
</Typography>
</Box>
);
}
// 인증되지 않았거나 사용자 정보가 없으면 아무것도 렌더링하지 않음 (리다이렉트 처리 중)
if (!isAuthenticated || !user) {
return null;
}
// 인증된 사용자만 children 렌더링
return <>{children}</>;
};

View File

@ -2,15 +2,13 @@ import axios, { AxiosInstance } from 'axios';
// AI Service API 클라이언트
const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083';
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
export const aiApiClient: AxiosInstance = axios.create({
baseURL: `${AI_API_BASE_URL}/api/${API_VERSION}`,
baseURL: AI_API_BASE_URL,
timeout: 300000, // AI 생성은 최대 5분
headers: {
'Content-Type': 'application/json',
},
withCredentials: false, // CORS 설정
});
// Request interceptor
@ -56,7 +54,78 @@ aiApiClient.interceptors.response.use(
}
);
// Types (eventApi.ts로 이동됨 - import해서 사용)
// Types
export interface TrendKeyword {
keyword: string;
relevance: number;
description: string;
}
export interface TrendAnalysis {
industryTrends: TrendKeyword[];
regionalTrends: TrendKeyword[];
seasonalTrends: TrendKeyword[];
}
export interface ExpectedMetrics {
newCustomers: {
min: number;
max: number;
};
repeatVisits?: {
min: number;
max: number;
};
revenueIncrease: {
min: number;
max: number;
};
roi: {
min: number;
max: number;
};
socialEngagement?: {
estimatedPosts: number;
estimatedReach: number;
};
}
export interface EventRecommendation {
optionNumber: number;
concept: string;
title: string;
description: string;
targetAudience: string;
duration: {
recommendedDays: number;
recommendedPeriod?: string;
};
mechanics: {
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
details: string;
};
promotionChannels: string[];
estimatedCost: {
min: number;
max: number;
breakdown?: {
material?: number;
promotion?: number;
discount?: number;
};
};
expectedMetrics: ExpectedMetrics;
differentiator: string;
}
export interface AIRecommendationResult {
eventId: string;
trendAnalysis: TrendAnalysis;
recommendations: EventRecommendation[];
generatedAt: string;
expiresAt: string;
aiProvider: 'CLAUDE' | 'GPT4';
}
export interface JobStatusResponse {
jobId: string;
@ -99,11 +168,11 @@ export const aiApi = {
return response.data;
},
// AI 추천 결과 조회 (Internal API) - Deprecated: eventApi.getAiRecommendations 사용
// getRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
// const response = await aiApiClient.get<AiRecommendationResult>(`/internal/recommendations/${eventId}`);
// return response.data;
// },
// AI 추천 결과 조회 (Internal API)
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
return response.data;
},
};
export default aiApi;

View File

@ -1,20 +1,19 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
// 마이크로서비스별 호스트 설정
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
const API_HOSTS = {
user: GATEWAY_HOST,
event: GATEWAY_HOST,
content: GATEWAY_HOST,
ai: GATEWAY_HOST,
participation: GATEWAY_HOST,
distribution: GATEWAY_HOST,
analytics: GATEWAY_HOST,
user: process.env.NEXT_PUBLIC_USER_HOST || 'http://localhost:8081',
event: process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080',
content: process.env.NEXT_PUBLIC_CONTENT_HOST || 'http://localhost:8082',
ai: process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083',
participation: process.env.NEXT_PUBLIC_PARTICIPATION_HOST || 'http://localhost:8084',
distribution: process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://localhost:8085',
analytics: process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086',
};
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
// 기본 User API 클라이언트 (Gateway 직접 연결)
// 기본 User API 클라이언트 (기존 호환성 유지)
const API_BASE_URL = API_HOSTS.user;
export const apiClient: AxiosInstance = axios.create({
@ -34,15 +33,6 @@ export const participationClient: AxiosInstance = axios.create({
},
});
// Distribution API 전용 클라이언트
export const distributionClient: AxiosInstance = axios.create({
baseURL: `${API_HOSTS.distribution}`,
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
// 공통 Request interceptor 함수
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
console.log('🚀 API Request:', {
@ -102,8 +92,4 @@ apiClient.interceptors.response.use(responseInterceptor, responseErrorIntercepto
participationClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
participationClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
// Distribution API Client 인터셉터 적용
distributionClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
distributionClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
export default apiClient;

View File

@ -4,13 +4,17 @@ import axios, { AxiosInstance } from 'axios';
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
// 개발 환경에서는 상대 경로 사용 (Next.js rewrites 프록시 또는 Mock API 사용)
// 프로덕션 환경에서는 환경 변수의 호스트 사용
const isProduction = process.env.NODE_ENV === 'production';
const BASE_URL = isProduction ? `${EVENT_API_BASE_URL}/api/${API_VERSION}` : `/api/${API_VERSION}`;
export const eventApiClient: AxiosInstance = axios.create({
baseURL: `${EVENT_API_BASE_URL}/api/${API_VERSION}`,
baseURL: BASE_URL,
timeout: 30000, // Job 폴링 고려
headers: {
'Content-Type': 'application/json',
},
withCredentials: false, // CORS 설정
});
// Request interceptor
@ -20,15 +24,9 @@ eventApiClient.interceptors.request.use(
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
fullURL: `${config.baseURL}${config.url}`,
data: config.data,
});
// POST/PUT 요청일 경우 payload를 JSON 형태로 출력
if (config.data) {
console.log('📦 Request Payload (JSON):', JSON.stringify(config.data, null, 2));
console.log('📦 Request Payload (Object):', config.data);
}
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
@ -56,18 +54,8 @@ eventApiClient.interceptors.response.use(
message: error.message,
status: error.response?.status,
url: error.config?.url,
requestData: error.config?.data,
responseData: error.response?.data,
data: error.response?.data,
});
// 400 에러일 경우 더 상세한 정보 출력
if (error.response?.status === 400) {
console.error('🚨 400 Bad Request 상세 정보:');
console.error(' 요청 URL:', `${error.config?.baseURL}${error.config?.url}`);
console.error(' 요청 본문:', JSON.stringify(error.config?.data, null, 2));
console.error(' 응답 본문:', JSON.stringify(error.response?.data, null, 2));
}
return Promise.reject(error);
}
);
@ -85,7 +73,6 @@ export interface EventCreatedResponse {
}
export interface AiRecommendationRequest {
objective: string;
storeInfo: {
storeId: string;
storeName: string;
@ -111,78 +98,6 @@ export interface EventJobStatusResponse {
completedAt?: string;
}
export interface TrendKeyword {
keyword: string;
relevance: number;
description: string;
}
export interface TrendAnalysis {
industryTrends: TrendKeyword[];
regionalTrends: TrendKeyword[];
seasonalTrends: TrendKeyword[];
}
export interface ExpectedMetrics {
newCustomers: {
min: number;
max: number;
};
repeatVisits?: {
min: number;
max: number;
};
revenueIncrease: {
min: number;
max: number;
};
roi: {
min: number;
max: number;
};
socialEngagement?: {
estimatedPosts: number;
estimatedReach: number;
};
}
export interface EventRecommendation {
optionNumber: number;
concept: string;
title: string;
description: string;
targetAudience: string;
duration: {
recommendedDays: number;
recommendedPeriod?: string;
};
mechanics: {
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
details: string;
};
promotionChannels: string[];
estimatedCost: {
min: number;
max: number;
breakdown?: {
material?: number;
promotion?: number;
discount?: number;
};
};
expectedMetrics: ExpectedMetrics;
differentiator: string;
}
export interface AiRecommendationResult {
eventId: string;
trendAnalysis: TrendAnalysis;
recommendations: EventRecommendation[];
generatedAt: string;
expiresAt?: string;
aiProvider: 'CLAUDE' | 'GPT4';
}
export interface SelectRecommendationRequest {
recommendationId: string;
customizations?: {
@ -305,21 +220,21 @@ export const eventApi = {
return response.data;
},
// Step 2: AI 추천 요청 (POST)
requestAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
const response = await eventApiClient.post<AiRecommendationResult>(
`/events/${eventId}/ai-recommendations`
// Step 2: AI 추천 요청
requestAiRecommendations: async (
eventId: string,
storeInfo: AiRecommendationRequest['storeInfo']
): Promise<JobAcceptedResponse> => {
const response = await eventApiClient.post<JobAcceptedResponse>(
`/events/${eventId}/ai-recommendations`,
{ storeInfo }
);
console.log('✅ AI 추천 요청 성공:', response.data);
return response.data;
},
// AI 추천 결과 조회 (GET)
getAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
const response = await eventApiClient.get<AiRecommendationResult>(
`/events/${eventId}/ai-recommendations`
);
console.log('✅ AI 추천 결과 조회:', response.data);
// Job 상태 폴링
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
return response.data;
},

View File

@ -1,4 +1,4 @@
import axios from 'axios';
import { participationClient } from './client';
import type {
ApiResponse,
PageResponse,
@ -10,19 +10,18 @@ import type {
/**
* Participation API Service
* API
* Next.js API Routes를 CORS
*/
/**
*
* POST /api/participations/{eventId}/participate
* POST /v1/events/{eventId}/participate
*/
export const participate = async (
eventId: string,
data: ParticipationRequest
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await axios.post<ApiResponse<ParticipationResponse>>(
`/api/participations/${eventId}/participate`,
const response = await participationClient.post<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participate`,
data
);
return response.data;
@ -30,35 +29,37 @@ export const participate = async (
/**
* ()
* GET /api/participations/{eventId}/participants
* GET /v1/events/{eventId}/participants
*/
export const getParticipants = async (
params: GetParticipantsParams
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params;
const queryParams = new URLSearchParams();
if (storeVisited !== undefined) queryParams.append('storeVisited', String(storeVisited));
queryParams.append('page', String(page));
queryParams.append('size', String(size));
sort.forEach(s => queryParams.append('sort', s));
const response = await axios.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/api/participations/${eventId}/participants?${queryParams.toString()}`
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/participants`,
{
params: {
storeVisited,
page,
size,
sort,
},
}
);
return response.data;
};
/**
*
* GET /api/participations/{eventId}/participants/{participantId}
* GET /v1/events/{eventId}/participants/{participantId}
*/
export const getParticipant = async (
eventId: string,
participantId: string
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await axios.get<ApiResponse<ParticipationResponse>>(
`/api/participations/${eventId}/participants/${participantId}`
const response = await participationClient.get<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participants/${participantId}`
);
return response.data;
};
@ -111,15 +112,15 @@ export const searchParticipants = async (
/**
*
* POST /api/participations/{eventId}/draw-winners
* POST /v1/events/{eventId}/draw-winners
*/
export const drawWinners = async (
eventId: string,
winnerCount: number,
applyStoreVisitBonus?: boolean
): Promise<ApiResponse<import('../types/api.types').DrawWinnersResponse>> => {
const response = await axios.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
`/api/participations/${eventId}/draw-winners`,
const response = await participationClient.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
`/v1/events/${eventId}/draw-winners`,
{
winnerCount,
applyStoreVisitBonus,
@ -130,7 +131,7 @@ export const drawWinners = async (
/**
*
* GET /api/participations/{eventId}/winners
* GET /v1/events/{eventId}/winners
*/
export const getWinners = async (
eventId: string,
@ -138,13 +139,15 @@ export const getWinners = async (
size = 20,
sort: string[] = ['winnerRank,ASC']
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const queryParams = new URLSearchParams();
queryParams.append('page', String(page));
queryParams.append('size', String(size));
sort.forEach(s => queryParams.append('sort', s));
const response = await axios.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/api/participations/${eventId}/winners?${queryParams.toString()}`
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/winners`,
{
params: {
page,
size,
sort,
},
}
);
return response.data;
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string; // UUID format
id: string;
name: string;
email: string;
phone: string;