mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 10:56:23 +00:00
Compare commits
24 Commits
ac9e7125d1
...
edc189a7e0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edc189a7e0 | ||
|
|
6331ab3fde | ||
|
|
a58ca4ece1 | ||
|
|
517cac7c75 | ||
|
|
974961e1bd | ||
|
|
86ae038a31 | ||
|
|
e3f1e2e3c7 | ||
|
|
e50cc86ece | ||
|
|
b09fac2396 | ||
|
|
4b52623f07 | ||
|
|
950d0284d9 | ||
|
|
f6f6e450cd | ||
|
|
0117b64e0e | ||
|
|
e1287806a7 | ||
|
|
d8084b3815 | ||
|
|
446501d036 | ||
|
|
a23f201bdf | ||
|
|
b774a8fda8 | ||
|
|
8f0d002d82 | ||
|
|
bdcf23e693 | ||
|
|
e65ee14d61 | ||
|
|
d02cfaa5fc | ||
|
|
b6d6859050 | ||
|
|
5028a68465 |
47
.dockerignore
Normal file
47
.dockerignore
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 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
Normal file
3
.idea/.gitignore
generated
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# 디폴트 무시된 파일
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
9
.idea/kt-event-marketing-fe.iml
generated
Normal file
9
.idea/kt-event-marketing-fe.iml
generated
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?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
Normal file
6
.idea/misc.xml
generated
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?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
Normal file
8
.idea/modules.xml
generated
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<?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
Normal file
7
.idea/vcs.xml
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?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
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
83
.serena/project.yml
Normal file
83
.serena/project.yml
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
# 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"
|
||||||
47
Dockerfile
Normal file
47
Dockerfile
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
# 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
Normal file
449
README.md
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
# 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
Normal file
247
ROUTES.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# 📍 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)
|
||||||
|
```
|
||||||
357
USER_API_CHANGES.md
Normal file
357
USER_API_CHANGES.md
Normal file
@ -0,0 +1,357 @@
|
|||||||
|
# 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
|
||||||
597
USER_API_STATUS.md
Normal file
597
USER_API_STATUS.md
Normal file
@ -0,0 +1,597 @@
|
|||||||
|
# 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
|
||||||
88
deployment/container/nginx.conf
Normal file
88
deployment/container/nginx.conf
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
deployment/k8s/configmap.yaml
Normal file
20
deployment/k8s/configmap.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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",
|
||||||
|
};
|
||||||
396
deployment/k8s/deploy-k8s-guide.md
Normal file
396
deployment/k8s/deploy-k8s-guide.md
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문의사항
|
||||||
|
배포 중 문제가 발생하면 위의 트러블슈팅 섹션을 참고하거나 관리자에게 문의하세요.
|
||||||
59
deployment/k8s/deployment.yaml
Normal file
59
deployment/k8s/deployment.yaml
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
20
deployment/k8s/ingress.yaml
Normal file
20
deployment/k8s/ingress.yaml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
13
deployment/k8s/service.yaml
Normal file
13
deployment/k8s/service.yaml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
@ -11,10 +11,10 @@ info:
|
|||||||
- Retry 패턴 및 Fallback 처리
|
- Retry 패턴 및 Fallback 처리
|
||||||
|
|
||||||
## 배포 채널
|
## 배포 채널
|
||||||
- **우리동네TV**: 영상 콘텐츠 업로드
|
- **우리동네TV** (URIDONGNETV): 영상 콘텐츠 업로드
|
||||||
- **링고비즈**: 연결음 업데이트
|
- **링고비즈** (RINGOBIZ): 연결음 업데이트
|
||||||
- **지니TV**: 광고 등록
|
- **지니TV** (GINITV): 광고 등록
|
||||||
- **SNS**: Instagram, Naver Blog, Kakao Channel
|
- **SNS**: Instagram (INSTAGRAM), Naver Blog (NAVER), Kakao Channel (KAKAO)
|
||||||
|
|
||||||
## Resilience 패턴
|
## Resilience 패턴
|
||||||
- Circuit Breaker: 채널별 독립적 장애 격리
|
- Circuit Breaker: 채널별 독립적 장애 격리
|
||||||
@ -79,23 +79,21 @@ paths:
|
|||||||
summary: 다중 채널 배포 예시
|
summary: 다중 채널 배포 예시
|
||||||
value:
|
value:
|
||||||
eventId: "evt-12345"
|
eventId: "evt-12345"
|
||||||
|
title: "신규 고객 환영 이벤트"
|
||||||
|
description: "신규 고객님을 위한 특별 할인 이벤트"
|
||||||
|
imageUrl: "https://cdn.example.com/images/event-main.jpg"
|
||||||
channels:
|
channels:
|
||||||
- type: "WOORIDONGNE_TV"
|
- "URIDONGNETV"
|
||||||
config:
|
- "INSTAGRAM"
|
||||||
|
- "NAVER"
|
||||||
|
channelSettings:
|
||||||
|
URIDONGNETV:
|
||||||
radius: "1km"
|
radius: "1km"
|
||||||
timeSlots:
|
timeSlot: "evening"
|
||||||
- "weekday_evening"
|
INSTAGRAM:
|
||||||
- "weekend_lunch"
|
scheduledTime: "2025-11-01T10:00:00"
|
||||||
- type: "INSTAGRAM"
|
NAVER:
|
||||||
config:
|
scheduledTime: "2025-11-01T10:30:00"
|
||||||
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:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: 배포 완료
|
description: 배포 완료
|
||||||
@ -107,25 +105,29 @@ paths:
|
|||||||
allSuccess:
|
allSuccess:
|
||||||
summary: 모든 채널 배포 성공
|
summary: 모든 채널 배포 성공
|
||||||
value:
|
value:
|
||||||
distributionId: "dist-12345"
|
|
||||||
eventId: "evt-12345"
|
eventId: "evt-12345"
|
||||||
status: "COMPLETED"
|
success: true
|
||||||
completedAt: "2025-11-01T09:00:00Z"
|
channelResults:
|
||||||
results:
|
- channel: "URIDONGNETV"
|
||||||
- channel: "WOORIDONGNE_TV"
|
success: true
|
||||||
status: "SUCCESS"
|
|
||||||
distributionId: "wtv-uuid-12345"
|
distributionId: "wtv-uuid-12345"
|
||||||
estimatedViews: 1000
|
estimatedReach: 1000
|
||||||
message: "배포 완료"
|
executionTimeMs: 234
|
||||||
- channel: "INSTAGRAM"
|
- channel: "INSTAGRAM"
|
||||||
status: "SUCCESS"
|
success: true
|
||||||
postUrl: "https://instagram.com/p/generated-post-id"
|
distributionId: "ig-uuid-12345"
|
||||||
postId: "ig-post-12345"
|
estimatedReach: 500
|
||||||
message: "게시 완료"
|
executionTimeMs: 456
|
||||||
- channel: "NAVER_BLOG"
|
- channel: "NAVER"
|
||||||
status: "SUCCESS"
|
success: true
|
||||||
postUrl: "https://blog.naver.com/store123/generated-post"
|
distributionId: "naver-uuid-12345"
|
||||||
message: "게시 완료"
|
estimatedReach: 300
|
||||||
|
executionTimeMs: 123
|
||||||
|
successCount: 3
|
||||||
|
failureCount: 0
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
|
totalExecutionTimeMs: 1234
|
||||||
|
message: "배포가 성공적으로 완료되었습니다"
|
||||||
'400':
|
'400':
|
||||||
description: 잘못된 요청
|
description: 잘못된 요청
|
||||||
content:
|
content:
|
||||||
@ -217,67 +219,77 @@ paths:
|
|||||||
value:
|
value:
|
||||||
eventId: "evt-12345"
|
eventId: "evt-12345"
|
||||||
overallStatus: "COMPLETED"
|
overallStatus: "COMPLETED"
|
||||||
completedAt: "2025-11-01T09:00:00Z"
|
startedAt: "2025-11-01T08:58:00"
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
channels:
|
channels:
|
||||||
- channel: "WOORIDONGNE_TV"
|
- channel: "URIDONGNETV"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
distributionId: "wtv-uuid-12345"
|
distributionId: "wtv-uuid-12345"
|
||||||
estimatedViews: 1500
|
estimatedViews: 1500
|
||||||
completedAt: "2025-11-01T09:00:00Z"
|
completedAt: "2025-11-01T09:00:00"
|
||||||
- channel: "RINGO_BIZ"
|
- channel: "RINGOBIZ"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
updateTimestamp: "2025-11-01T09:00:00Z"
|
updateTimestamp: "2025-11-01T09:00:00"
|
||||||
- channel: "GENIE_TV"
|
completedAt: "2025-11-01T09:00:00"
|
||||||
|
- channel: "GINITV"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
adId: "gtv-uuid-12345"
|
adId: "gtv-uuid-12345"
|
||||||
impressionSchedule:
|
impressionSchedule:
|
||||||
- "2025-11-01 18:00-20:00"
|
- "2025-11-01 18:00-20:00"
|
||||||
- "2025-11-02 12:00-14:00"
|
- "2025-11-02 12:00-14:00"
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
- channel: "INSTAGRAM"
|
- channel: "INSTAGRAM"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
postUrl: "https://instagram.com/p/generated-post-id"
|
postUrl: "https://instagram.com/p/generated-post-id"
|
||||||
postId: "ig-post-12345"
|
postId: "ig-post-12345"
|
||||||
- channel: "NAVER_BLOG"
|
completedAt: "2025-11-01T09:00:00"
|
||||||
|
- channel: "NAVER"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
postUrl: "https://blog.naver.com/store123/generated-post"
|
postUrl: "https://blog.naver.com/store123/generated-post"
|
||||||
- channel: "KAKAO_CHANNEL"
|
completedAt: "2025-11-01T09:00:00"
|
||||||
|
- channel: "KAKAO"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
messageId: "kakao-msg-12345"
|
messageId: "kakao-msg-12345"
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
inProgress:
|
inProgress:
|
||||||
summary: 배포 진행중 상태
|
summary: 배포 진행중 상태
|
||||||
value:
|
value:
|
||||||
eventId: "evt-12345"
|
eventId: "evt-12345"
|
||||||
overallStatus: "IN_PROGRESS"
|
overallStatus: "IN_PROGRESS"
|
||||||
startedAt: "2025-11-01T08:58:00Z"
|
startedAt: "2025-11-01T08:58:00"
|
||||||
channels:
|
channels:
|
||||||
- channel: "WOORIDONGNE_TV"
|
- channel: "URIDONGNETV"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
distributionId: "wtv-uuid-12345"
|
distributionId: "wtv-uuid-12345"
|
||||||
estimatedViews: 1500
|
estimatedViews: 1500
|
||||||
|
completedAt: "2025-11-01T08:59:00"
|
||||||
- channel: "INSTAGRAM"
|
- channel: "INSTAGRAM"
|
||||||
status: "IN_PROGRESS"
|
status: "IN_PROGRESS"
|
||||||
progress: 50
|
progress: 50
|
||||||
- channel: "NAVER_BLOG"
|
- channel: "NAVER"
|
||||||
status: "PENDING"
|
status: "PENDING"
|
||||||
partialFailure:
|
partialFailure:
|
||||||
summary: 일부 채널 실패 상태
|
summary: 일부 채널 실패 상태
|
||||||
value:
|
value:
|
||||||
eventId: "evt-12345"
|
eventId: "evt-12345"
|
||||||
overallStatus: "PARTIAL_FAILURE"
|
overallStatus: "PARTIAL_FAILURE"
|
||||||
completedAt: "2025-11-01T09:00:00Z"
|
startedAt: "2025-11-01T08:58:00"
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
channels:
|
channels:
|
||||||
- channel: "WOORIDONGNE_TV"
|
- channel: "URIDONGNETV"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
distributionId: "wtv-uuid-12345"
|
distributionId: "wtv-uuid-12345"
|
||||||
estimatedViews: 1500
|
estimatedViews: 1500
|
||||||
|
completedAt: "2025-11-01T08:59:00"
|
||||||
- channel: "INSTAGRAM"
|
- channel: "INSTAGRAM"
|
||||||
status: "FAILED"
|
status: "FAILED"
|
||||||
errorMessage: "Instagram API 타임아웃"
|
errorMessage: "Instagram API 타임아웃"
|
||||||
retries: 3
|
retries: 3
|
||||||
lastRetryAt: "2025-11-01T08:59:30Z"
|
lastRetryAt: "2025-11-01T08:59:30"
|
||||||
- channel: "NAVER_BLOG"
|
- channel: "NAVER"
|
||||||
status: "COMPLETED"
|
status: "COMPLETED"
|
||||||
postUrl: "https://blog.naver.com/store123/generated-post"
|
postUrl: "https://blog.naver.com/store123/generated-post"
|
||||||
|
completedAt: "2025-11-01T09:00:00"
|
||||||
'404':
|
'404':
|
||||||
description: 배포 이력을 찾을 수 없음
|
description: 배포 이력을 찾을 수 없음
|
||||||
content:
|
content:
|
||||||
@ -305,196 +317,133 @@ components:
|
|||||||
required:
|
required:
|
||||||
- eventId
|
- eventId
|
||||||
- channels
|
- channels
|
||||||
- contentUrls
|
|
||||||
properties:
|
properties:
|
||||||
eventId:
|
eventId:
|
||||||
type: string
|
type: string
|
||||||
description: 이벤트 ID
|
description: 이벤트 ID
|
||||||
example: "evt-12345"
|
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:
|
channels:
|
||||||
type: array
|
type: array
|
||||||
description: 배포할 채널 목록
|
description: 배포할 채널 목록
|
||||||
minItems: 1
|
minItems: 1
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/ChannelConfig'
|
|
||||||
contentUrls:
|
|
||||||
type: object
|
|
||||||
description: 플랫폼별 콘텐츠 URL
|
|
||||||
properties:
|
|
||||||
wooridongneTV:
|
|
||||||
type: string
|
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:
|
enum:
|
||||||
- WOORIDONGNE_TV
|
- URIDONGNETV
|
||||||
- RINGO_BIZ
|
- RINGOBIZ
|
||||||
- GENIE_TV
|
- GINITV
|
||||||
- INSTAGRAM
|
- INSTAGRAM
|
||||||
- NAVER_BLOG
|
- NAVER
|
||||||
- KAKAO_CHANNEL
|
- KAKAO
|
||||||
example: "INSTAGRAM"
|
example: ["URIDONGNETV", "INSTAGRAM", "NAVER"]
|
||||||
config:
|
channelSettings:
|
||||||
|
type: object
|
||||||
|
description: 채널별 추가 설정 (Optional)
|
||||||
|
additionalProperties:
|
||||||
type: object
|
type: object
|
||||||
description: 채널별 설정 (채널에 따라 다름)
|
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
example:
|
example:
|
||||||
scheduledTime: "2025-11-01T10:00:00Z"
|
URIDONGNETV:
|
||||||
caption: "이벤트 안내"
|
radius: "1km"
|
||||||
hashtags:
|
timeSlot: "evening"
|
||||||
- "이벤트"
|
INSTAGRAM:
|
||||||
- "할인"
|
scheduledTime: "2025-11-01T10:00:00"
|
||||||
|
|
||||||
DistributionResponse:
|
DistributionResponse:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- distributionId
|
|
||||||
- eventId
|
- eventId
|
||||||
- status
|
- success
|
||||||
- results
|
- channelResults
|
||||||
|
- successCount
|
||||||
|
- failureCount
|
||||||
properties:
|
properties:
|
||||||
distributionId:
|
|
||||||
type: string
|
|
||||||
description: 배포 ID
|
|
||||||
example: "dist-12345"
|
|
||||||
eventId:
|
eventId:
|
||||||
type: string
|
type: string
|
||||||
description: 이벤트 ID
|
description: 이벤트 ID
|
||||||
example: "evt-12345"
|
example: "evt-12345"
|
||||||
status:
|
success:
|
||||||
type: string
|
type: boolean
|
||||||
description: 전체 배포 상태
|
description: 배포 성공 여부 (모든 채널 또는 일부 채널 성공)
|
||||||
enum:
|
example: true
|
||||||
- PENDING
|
channelResults:
|
||||||
- IN_PROGRESS
|
type: array
|
||||||
- COMPLETED
|
description: 채널별 배포 결과
|
||||||
- PARTIAL_FAILURE
|
items:
|
||||||
- FAILED
|
$ref: '#/components/schemas/ChannelDistributionResult'
|
||||||
example: "COMPLETED"
|
successCount:
|
||||||
startedAt:
|
type: integer
|
||||||
type: string
|
description: 성공한 채널 수
|
||||||
format: date-time
|
example: 3
|
||||||
description: 배포 시작 시각
|
failureCount:
|
||||||
example: "2025-11-01T08:59:00Z"
|
type: integer
|
||||||
|
description: 실패한 채널 수
|
||||||
|
example: 0
|
||||||
completedAt:
|
completedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: 배포 완료 시각
|
description: 배포 완료 시각
|
||||||
example: "2025-11-01T09:00:00Z"
|
example: "2025-11-01T09:00:00"
|
||||||
results:
|
totalExecutionTimeMs:
|
||||||
type: array
|
type: integer
|
||||||
description: 채널별 배포 결과
|
format: int64
|
||||||
items:
|
description: 전체 배포 소요 시간 (ms)
|
||||||
$ref: '#/components/schemas/ChannelResult'
|
example: 1234
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
description: 메시지
|
||||||
|
example: "배포가 성공적으로 완료되었습니다"
|
||||||
|
|
||||||
ChannelResult:
|
ChannelDistributionResult:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
- channel
|
- channel
|
||||||
- status
|
- success
|
||||||
properties:
|
properties:
|
||||||
channel:
|
channel:
|
||||||
type: string
|
type: string
|
||||||
description: 채널 타입
|
description: 채널 타입
|
||||||
enum:
|
enum:
|
||||||
- WOORIDONGNE_TV
|
- URIDONGNETV
|
||||||
- RINGO_BIZ
|
- RINGOBIZ
|
||||||
- GENIE_TV
|
- GINITV
|
||||||
- INSTAGRAM
|
- INSTAGRAM
|
||||||
- NAVER_BLOG
|
- NAVER
|
||||||
- KAKAO_CHANNEL
|
- KAKAO
|
||||||
example: "INSTAGRAM"
|
example: "INSTAGRAM"
|
||||||
status:
|
success:
|
||||||
type: string
|
type: boolean
|
||||||
description: 채널별 배포 상태
|
description: 배포 성공 여부
|
||||||
enum:
|
example: true
|
||||||
- PENDING
|
|
||||||
- IN_PROGRESS
|
|
||||||
- SUCCESS
|
|
||||||
- FAILED
|
|
||||||
example: "SUCCESS"
|
|
||||||
distributionId:
|
distributionId:
|
||||||
type: string
|
type: string
|
||||||
description: 채널별 배포 ID (우리동네TV, 지니TV)
|
description: 배포 ID (성공 시)
|
||||||
example: "wtv-uuid-12345"
|
example: "dist-uuid-12345"
|
||||||
estimatedViews:
|
estimatedReach:
|
||||||
type: integer
|
type: integer
|
||||||
description: 예상 노출 수 (우리동네TV, 지니TV)
|
description: 예상 노출 수 (성공 시)
|
||||||
example: 1500
|
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:
|
errorMessage:
|
||||||
type: string
|
type: string
|
||||||
description: 오류 메시지 (실패 시)
|
description: 에러 메시지 (실패 시)
|
||||||
example: "Instagram API 타임아웃"
|
example: "Instagram API 타임아웃"
|
||||||
retries:
|
executionTimeMs:
|
||||||
type: integer
|
type: integer
|
||||||
description: 재시도 횟수
|
format: int64
|
||||||
example: 0
|
description: 배포 소요 시간 (ms)
|
||||||
lastRetryAt:
|
example: 234
|
||||||
type: string
|
|
||||||
format: date-time
|
|
||||||
description: 마지막 재시도 시각
|
|
||||||
example: "2025-11-01T08:59:30Z"
|
|
||||||
|
|
||||||
DistributionStatusResponse:
|
DistributionStatusResponse:
|
||||||
type: object
|
type: object
|
||||||
@ -544,12 +493,12 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: 채널 타입
|
description: 채널 타입
|
||||||
enum:
|
enum:
|
||||||
- WOORIDONGNE_TV
|
- URIDONGNETV
|
||||||
- RINGO_BIZ
|
- RINGOBIZ
|
||||||
- GENIE_TV
|
- GINITV
|
||||||
- INSTAGRAM
|
- INSTAGRAM
|
||||||
- NAVER_BLOG
|
- NAVER
|
||||||
- KAKAO_CHANNEL
|
- KAKAO
|
||||||
example: "INSTAGRAM"
|
example: "INSTAGRAM"
|
||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
@ -569,7 +518,7 @@ components:
|
|||||||
distributionId:
|
distributionId:
|
||||||
type: string
|
type: string
|
||||||
description: 채널별 배포 ID
|
description: 채널별 배포 ID
|
||||||
example: "wtv-uuid-12345"
|
example: "dist-uuid-12345"
|
||||||
estimatedViews:
|
estimatedViews:
|
||||||
type: integer
|
type: integer
|
||||||
description: 예상 노출 수
|
description: 예상 노출 수
|
||||||
@ -578,35 +527,35 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: 업데이트 완료 시각
|
description: 업데이트 완료 시각
|
||||||
example: "2025-11-01T09:00:00Z"
|
example: "2025-11-01T09:00:00"
|
||||||
adId:
|
adId:
|
||||||
type: string
|
type: string
|
||||||
description: 광고 ID
|
description: 광고 ID (지니TV)
|
||||||
example: "gtv-uuid-12345"
|
example: "gtv-uuid-12345"
|
||||||
impressionSchedule:
|
impressionSchedule:
|
||||||
type: array
|
type: array
|
||||||
description: 노출 스케줄
|
description: 노출 스케줄 (지니TV)
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
example:
|
example:
|
||||||
- "2025-11-01 18:00-20:00"
|
- "2025-11-01 18:00-20:00"
|
||||||
postUrl:
|
postUrl:
|
||||||
type: string
|
type: string
|
||||||
description: 게시물 URL
|
description: 게시물 URL (Instagram, Naver Blog)
|
||||||
example: "https://instagram.com/p/generated-post-id"
|
example: "https://instagram.com/p/generated-post-id"
|
||||||
postId:
|
postId:
|
||||||
type: string
|
type: string
|
||||||
description: 게시물 ID
|
description: 게시물 ID (Instagram)
|
||||||
example: "ig-post-12345"
|
example: "ig-post-12345"
|
||||||
messageId:
|
messageId:
|
||||||
type: string
|
type: string
|
||||||
description: 메시지 ID
|
description: 메시지 ID (Kakao Channel)
|
||||||
example: "kakao-msg-12345"
|
example: "kakao-msg-12345"
|
||||||
completedAt:
|
completedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: 완료 시각
|
description: 완료 시각
|
||||||
example: "2025-11-01T09:00:00Z"
|
example: "2025-11-01T09:00:00"
|
||||||
errorMessage:
|
errorMessage:
|
||||||
type: string
|
type: string
|
||||||
description: 오류 메시지
|
description: 오류 메시지
|
||||||
@ -619,7 +568,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: 마지막 재시도 시각
|
description: 마지막 재시도 시각
|
||||||
example: "2025-11-01T08:59:30Z"
|
example: "2025-11-01T08:59:30"
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
|
output: 'standalone',
|
||||||
compiler: {
|
compiler: {
|
||||||
emotion: true,
|
emotion: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,21 +17,20 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Visibility, VisibilityOff, Email, Lock, ChatBubble } from '@mui/icons-material';
|
import { Visibility, VisibilityOff, Email, Lock, ChatBubble, Info } from '@mui/icons-material';
|
||||||
import { useAuthContext } from '@/features/auth';
|
import { useAuthContext } from '@/features/auth';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles';
|
import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles';
|
||||||
|
|
||||||
// 유효성 검사 스키마
|
// 유효성 검사 스키마
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z
|
email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'),
|
||||||
.string()
|
password: z.string().min(1, '비밀번호를 입력해주세요'),
|
||||||
.min(1, '이메일을 입력해주세요')
|
|
||||||
.email('올바른 이메일 형식이 아닙니다'),
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(1, '비밀번호를 입력해주세요'),
|
|
||||||
rememberMe: z.boolean().optional(),
|
rememberMe: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -42,6 +41,7 @@ export default function LoginPage() {
|
|||||||
const { login } = useAuthContext();
|
const { login } = useAuthContext();
|
||||||
const { showToast, setLoading } = useUIStore();
|
const { showToast, setLoading } = useUIStore();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [openUnavailableModal, setOpenUnavailableModal] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -74,7 +74,10 @@ export default function LoginPage() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ 로그인 실패:', result.error);
|
console.error('❌ 로그인 실패:', result.error);
|
||||||
showToast(result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error');
|
showToast(
|
||||||
|
result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 로그인 예외:', error);
|
console.error('💥 로그인 예외:', error);
|
||||||
@ -84,9 +87,13 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocialLogin = (provider: 'kakao' | 'naver') => {
|
const handleUnavailableFeature = (e: React.MouseEvent) => {
|
||||||
// TODO: 소셜 로그인 구현
|
e.preventDefault();
|
||||||
showToast(`${provider === 'kakao' ? '카카오톡' : '네이버'} 로그인은 준비 중입니다`, 'info');
|
setOpenUnavailableModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setOpenUnavailableModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -97,8 +104,8 @@ export default function LoginPage() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
px: 3,
|
px: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
|
||||||
py: 8,
|
py: { xs: 4, sm: 8 }, // 320px: 32px, 600px+: 64px
|
||||||
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
|
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -107,38 +114,49 @@ export default function LoginPage() {
|
|||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 440,
|
maxWidth: 440,
|
||||||
p: { xs: 4, sm: 6 },
|
p: { xs: 2.5, sm: 4, md: 6 }, // 320px: 20px, 600px: 32px, 960px+: 48px
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 로고 및 타이틀 */}
|
{/* 로고 및 타이틀 */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 6 } }}> {/* 320px: 32px, 600px+: 48px */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: 64,
|
width: { xs: 56, sm: 64 }, // 320px: 56px, 600px+: 64px
|
||||||
height: 64,
|
height: { xs: 56, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'primary.main',
|
bgcolor: 'primary.main',
|
||||||
mb: 3,
|
mb: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
|
<Typography sx={{ fontSize: { xs: 28, sm: 32 } }}>🎉</Typography> {/* 320px: 28px, 600px+: 32px */}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h4" sx={{ ...responsiveText.h2, mb: 1 }}>
|
<Typography
|
||||||
KT AI 이벤트
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
...responsiveText.h2,
|
||||||
|
mb: 1,
|
||||||
|
fontSize: { xs: '1.25rem', sm: '1.5rem' } // 320px: 20px, 600px+: 24px
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
KT 이벤트 파트너
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
소상공인을 위한 스마트 마케팅
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
|
소상공인을 위한 마케팅 어시스턴트
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 로그인 폼 */}
|
{/* 로그인 폼 */}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="email"
|
name="email"
|
||||||
control={control}
|
control={control}
|
||||||
@ -154,16 +172,21 @@ export default function LoginPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Email color="action" />
|
<Email color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} /> {/* 320px: 20px, 600px+: 24px */}
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' } // 320px: 14px, 600px+: 16px
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: { xs: 1.5, sm: 2 } }}> {/* 320px: 12px, 600px+: 16px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="password"
|
name="password"
|
||||||
control={control}
|
control={control}
|
||||||
@ -179,7 +202,7 @@ export default function LoginPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Lock color="action" />
|
<Lock color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
@ -194,20 +217,29 @@ export default function LoginPage() {
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="rememberMe"
|
name="rememberMe"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox {...field} checked={field.value} />}
|
control={<Checkbox {...field} checked={field.value} size="small" />}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
로그인 상태 유지
|
로그인 상태 유지
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
@ -223,33 +255,54 @@ export default function LoginPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
sx={{
|
sx={{
|
||||||
mb: 2,
|
mb: 2,
|
||||||
py: { xs: 1.5, sm: 1.75 },
|
py: { xs: 1.25, sm: 1.5, md: 1.75 }, // 320px: 10px, 600px: 12px, 960px+: 14px
|
||||||
fontSize: { xs: 15, sm: 16 },
|
fontSize: { xs: '0.938rem', sm: 15, md: 16 }, // 320px: 15px, 600px: 15px, 960px+: 16px
|
||||||
...getGradientButtonStyle('primary'),
|
...getGradientButtonStyle('primary'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
로그인
|
로그인
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mb: 4 }}>
|
<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'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: { xs: '0.813rem', sm: '0.875rem' } // 320px: 13px, 600px+: 14px
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
비밀번호 찾기
|
비밀번호 찾기
|
||||||
</Link>
|
</Link>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }}
|
||||||
|
>
|
||||||
|
|
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="primary"
|
color="primary"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
sx={{ cursor: 'pointer', fontWeight: 600 }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '0.813rem', sm: '0.875rem' }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
회원가입
|
회원가입
|
||||||
</Link>
|
</Link>
|
||||||
@ -257,20 +310,32 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 소셜 로그인 */}
|
{/* 소셜 로그인 */}
|
||||||
<Divider sx={{ mb: 3 }}>
|
<Divider sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
또는
|
또는
|
||||||
</Typography>
|
</Typography>
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => handleSocialLogin('kakao')}
|
onClick={handleUnavailableFeature}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.5,
|
py: { xs: 1.25, sm: 1.5 }, // 320px: 10px, 600px+: 12px
|
||||||
|
fontSize: { xs: '0.875rem', sm: '0.938rem' }, // 320px: 14px, 600px+: 15px
|
||||||
borderColor: '#FEE500',
|
borderColor: '#FEE500',
|
||||||
bgcolor: '#FEE500',
|
bgcolor: '#FEE500',
|
||||||
color: '#000000',
|
color: '#000000',
|
||||||
@ -280,7 +345,7 @@ export default function LoginPage() {
|
|||||||
borderColor: '#FDD835',
|
borderColor: '#FDD835',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
startIcon={<ChatBubble />}
|
startIcon={<ChatBubble sx={{ fontSize: { xs: 20, sm: 24 } }} />}
|
||||||
>
|
>
|
||||||
카카오톡으로 시작하기
|
카카오톡으로 시작하기
|
||||||
</Button>
|
</Button>
|
||||||
@ -289,9 +354,10 @@ export default function LoginPage() {
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={() => handleSocialLogin('naver')}
|
onClick={handleUnavailableFeature}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.5,
|
py: { xs: 1.25, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.875rem', sm: '0.938rem' },
|
||||||
borderColor: '#03C75A',
|
borderColor: '#03C75A',
|
||||||
bgcolor: '#03C75A',
|
bgcolor: '#03C75A',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
@ -304,8 +370,8 @@ export default function LoginPage() {
|
|||||||
startIcon={
|
startIcon={
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 20,
|
width: { xs: 18, sm: 20 }, // 320px: 18px, 600px+: 20px
|
||||||
height: 20,
|
height: { xs: 18, sm: 20 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -313,7 +379,7 @@ export default function LoginPage() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#03C75A',
|
color: '#03C75A',
|
||||||
fontSize: 14,
|
fontSize: { xs: 12, sm: 14 }, // 320px: 12px, 600px+: 14px
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
N
|
N
|
||||||
@ -325,18 +391,106 @@ export default function LoginPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 약관 동의 안내 */}
|
{/* 약관 동의 안내 */}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block' }}>
|
<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
|
||||||
|
}}
|
||||||
|
>
|
||||||
회원가입 시{' '}
|
회원가입 시{' '}
|
||||||
<Link href="/terms" underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
이용약관
|
이용약관
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
및{' '}
|
및{' '}
|
||||||
<Link href="/privacy" underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
개인정보처리방침
|
개인정보처리방침
|
||||||
</Link>
|
</Link>
|
||||||
에 동의하게 됩니다.
|
에 동의하게 됩니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/app/(auth)/logout/page.tsx
Normal file
57
src/app/(auth)/logout/page.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -97,7 +97,15 @@ export default function AnalyticsPage() {
|
|||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Analytics 데이터 로드 실패:', error);
|
console.error('❌ Analytics 데이터 로드 실패:', error);
|
||||||
// 에러 발생 시에도 로딩 상태 해제
|
|
||||||
|
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
|
||||||
|
if (error.response?.status === 404 || error.response?.status === 400) {
|
||||||
|
console.log('ℹ️ Analytics 데이터가 아직 생성되지 않았습니다.');
|
||||||
|
// 에러 상태를 설정하지 않고 빈 데이터로 표시
|
||||||
|
} else {
|
||||||
|
// 다른 에러는 에러로 처리
|
||||||
|
console.error('❌ 예상치 못한 에러:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -145,7 +153,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -167,7 +175,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -326,7 +334,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@ -603,7 +611,7 @@ export default function AnalyticsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<Box sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
채널별 참여 데이터가 없습니다.
|
채널별 참여 데이터가 없습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@ -248,8 +248,8 @@ export default function DrawPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
|
||||||
@ -261,7 +261,7 @@ export default function DrawPage() {
|
|||||||
{!showResults && (
|
{!showResults && (
|
||||||
<>
|
<>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
||||||
🎲 당첨자 추첨
|
🎲 당첨자 추첨
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -271,7 +271,7 @@ export default function DrawPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Event Info Summary Cards */}
|
{/* Event Info Summary Cards */}
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={6} md={6}>
|
<Grid item xs={6} md={6}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@ -281,12 +281,12 @@ export default function DrawPage() {
|
|||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<EventNote sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<EventNote sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<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' } }}>
|
||||||
이벤트명
|
이벤트명
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
|
||||||
{eventName}
|
{eventName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -301,12 +301,12 @@ export default function DrawPage() {
|
|||||||
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
|
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<People sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<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' } }}>
|
||||||
총 참여자
|
총 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1rem', sm: '1.75rem' } }}>
|
||||||
{totalParticipants}명
|
{totalParticipants}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -315,8 +315,8 @@ export default function DrawPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Drawing Settings */}
|
{/* Drawing Settings */}
|
||||||
<Card elevation={0} sx={{ mb: 10, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
<Card elevation={0} sx={{ mb: { xs: 4, sm: 10 }, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
||||||
<Tune sx={{ fontSize: 32, color: colors.pink }} />
|
<Tune sx={{ fontSize: 32, color: colors.pink }} />
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||||
@ -428,7 +428,7 @@ export default function DrawPage() {
|
|||||||
startIcon={<Casino sx={{ fontSize: 28 }} />}
|
startIcon={<Casino sx={{ fontSize: 28 }} />}
|
||||||
onClick={handleStartDrawing}
|
onClick={handleStartDrawing}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
py: 3,
|
py: 3,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@ -452,7 +452,7 @@ export default function DrawPage() {
|
|||||||
{showResults && (
|
{showResults && (
|
||||||
<>
|
<>
|
||||||
{/* Results Header */}
|
{/* Results Header */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 10 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
||||||
🎉 추첨 완료!
|
🎉 추첨 완료!
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -467,7 +467,7 @@ export default function DrawPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Winner List */}
|
{/* Winner List */}
|
||||||
<Box sx={{ mb: 10 }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
|
||||||
🏆 당첨자 목록
|
🏆 당첨자 목록
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -482,7 +482,7 @@ export default function DrawPage() {
|
|||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 5 }}>
|
<CardContent sx={{ p: { xs: 2.5, sm: 5 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@ -159,10 +159,10 @@ export default function EventDetailPage() {
|
|||||||
const fetchAnalytics = async (forceRefresh = false) => {
|
const fetchAnalytics = async (forceRefresh = false) => {
|
||||||
try {
|
try {
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
console.log('🔄 데이터 새로고침 시작...');
|
console.log('🔄 Analytics 데이터 새로고침...');
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
} else {
|
} else {
|
||||||
console.log('📊 Analytics 데이터 로딩 시작...');
|
console.log('📊 Analytics 데이터 로딩...');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -172,38 +172,47 @@ export default function EventDetailPage() {
|
|||||||
analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
|
analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
|
||||||
analyticsApi.getEventTimelineAnalytics(eventId, {
|
analyticsApi.getEventTimelineAnalytics(eventId, {
|
||||||
interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
|
interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
|
||||||
refresh: forceRefresh
|
|
||||||
}),
|
}),
|
||||||
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true, refresh: forceRefresh }),
|
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true }),
|
||||||
analyticsApi.getEventChannelAnalytics(eventId, { refresh: forceRefresh }),
|
analyticsApi.getEventChannelAnalytics(eventId, {}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.log('✅ Dashboard 데이터:', dashboard);
|
console.log('✅ Dashboard 데이터:', dashboard);
|
||||||
console.log('✅ Timeline 데이터:', timeline);
|
console.log('✅ Timeline 데이터:', timeline);
|
||||||
console.log('✅ ROI 데이터:', roi);
|
console.log('✅ ROI 데이터:', roi);
|
||||||
console.log('✅ Channel 데이터:', channels);
|
console.log('✅ Channels 데이터:', channels);
|
||||||
|
|
||||||
// Analytics 데이터 저장
|
// Analytics 데이터 저장
|
||||||
setAnalyticsData({
|
const formattedAnalyticsData = {
|
||||||
dashboard,
|
dashboard,
|
||||||
timeline,
|
timeline,
|
||||||
roi,
|
roi,
|
||||||
channels,
|
channels,
|
||||||
});
|
};
|
||||||
|
|
||||||
// Event 기본 정보 업데이트
|
setAnalyticsData(formattedAnalyticsData);
|
||||||
|
|
||||||
|
// Event 객체 업데이트 - Analytics 데이터 반영
|
||||||
setEvent(prev => ({
|
setEvent(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
participants: dashboard.summary.participants,
|
participants: dashboard.summary.participants,
|
||||||
views: dashboard.summary.totalViews,
|
views: dashboard.summary.totalViews,
|
||||||
roi: Math.round(dashboard.roi.roi),
|
conversion: dashboard.summary.conversionRate * 100,
|
||||||
conversion: Math.round(dashboard.summary.conversionRate * 100),
|
roi: dashboard.roi.roi,
|
||||||
|
title: dashboard.eventTitle,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log('✅ Analytics 데이터 로딩 완료');
|
console.log('✅ Analytics 데이터 로딩 완료');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Analytics 데이터 로딩 실패:', err);
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -560,7 +569,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -593,7 +603,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -623,7 +634,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -653,7 +665,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@ -168,82 +168,82 @@ export default function ParticipantsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.5rem', sm: '2rem' }, mb: 2 }}>
|
||||||
👥 참여자 목록
|
👥 참여자 목록
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
이벤트에 참여한 사용자들의 정보를 확인하고 관리하세요
|
이벤트에 참여한 사용자들의 정보를 확인하고 관리하세요
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Error Alert */}
|
{/* Error Alert */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
|
<Alert severity="error" sx={{ mb: { xs: 3, sm: 6 } }} icon={<ErrorIcon />} onClose={() => setError('')}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={{ xs: 1, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<People sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<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 }}>
|
||||||
전체 참여자
|
전체 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.total}명
|
{loading ? '...' : stats.total}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.yellow} 0%, #FCD34D 100%)`,
|
background: `linear-gradient(135deg, ${colors.yellow} 0%, #FCD34D 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<AccessTime sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<AccessTime sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<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 }}>
|
||||||
대기중
|
대기중
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.waiting}명
|
{loading ? '...' : stats.waiting}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
|
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<TrendingUp sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<TrendingUp sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<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 }}>
|
||||||
당첨자
|
당첨자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.winner}명
|
{loading ? '...' : stats.winner}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -252,7 +252,7 @@ export default function ParticipantsPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Search Section */}
|
{/* Search Section */}
|
||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="이름, 전화번호 또는 이메일 검색..."
|
placeholder="이름, 전화번호 또는 이메일 검색..."
|
||||||
@ -261,7 +261,7 @@ export default function ParticipantsPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Search />
|
<Search sx={{ fontSize: { xs: 18, sm: 24 } }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@ -269,6 +269,10 @@ export default function ParticipantsPage() {
|
|||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-input': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -276,11 +280,11 @@ export default function ParticipantsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, flexWrap: 'wrap' }}>
|
||||||
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
|
<FilterList sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.pink }} />
|
||||||
<FormControl sx={{ flex: 1, minWidth: 160 }}>
|
<FormControl sx={{ flex: 1, minWidth: { xs: 100, sm: 160 } }}>
|
||||||
<InputLabel>매장 방문</InputLabel>
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>매장 방문</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
|
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
|
||||||
label="매장 방문"
|
label="매장 방문"
|
||||||
@ -291,47 +295,60 @@ export default function ParticipantsPage() {
|
|||||||
);
|
);
|
||||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||||
}}
|
}}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체</MenuItem>
|
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>전체</MenuItem>
|
||||||
<MenuItem value="visited">방문</MenuItem>
|
<MenuItem value="visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>방문</MenuItem>
|
||||||
<MenuItem value="not_visited">미방문</MenuItem>
|
<MenuItem value="not_visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>미방문</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ flex: 1, minWidth: 140 }}>
|
<FormControl sx={{ flex: 1, minWidth: { xs: 90, sm: 140 } }}>
|
||||||
<InputLabel>상태</InputLabel>
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>상태</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
label="상태"
|
label="상태"
|
||||||
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체</MenuItem>
|
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>전체</MenuItem>
|
||||||
<MenuItem value="waiting">당첨 대기</MenuItem>
|
<MenuItem value="waiting" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>당첨 대기</MenuItem>
|
||||||
<MenuItem value="winner">당첨</MenuItem>
|
<MenuItem value="winner" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>당첨</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Total Count & Drawing Button */}
|
{/* Total Count & Drawing Button */}
|
||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: { xs: 2, sm: 4 } }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '0.875rem', sm: '1.5rem' } }}>
|
||||||
총 <span style={{ color: colors.pink }}>{filteredParticipants.length}</span>명 표시
|
총 <span style={{ color: colors.pink }}>{filteredParticipants.length}</span>명 표시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
<Box sx={{ display: 'flex', gap: { xs: 1.5, sm: 3 } }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<Download />}
|
startIcon={<Download sx={{ fontSize: { xs: 16, sm: 20 } }} />}
|
||||||
onClick={handleDownloadClick}
|
onClick={handleDownloadClick}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: { xs: 1.5, sm: 4 },
|
||||||
py: 1.5,
|
py: { xs: 0.75, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.7rem', sm: '0.875rem' },
|
||||||
borderColor: colors.blue,
|
borderColor: colors.blue,
|
||||||
color: colors.blue,
|
color: colors.blue,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@ -340,17 +357,23 @@ 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>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<Casino />}
|
startIcon={<Casino sx={{ fontSize: { xs: 16, sm: 20 } }} />}
|
||||||
onClick={handleDrawClick}
|
onClick={handleDrawClick}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: { xs: 1.5, sm: 4 },
|
||||||
py: 1.5,
|
py: { xs: 0.75, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.7rem', sm: '0.875rem' },
|
||||||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
||||||
@ -358,7 +381,12 @@ 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>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@ -391,7 +419,7 @@ export default function ParticipantsPage() {
|
|||||||
{/* Participant List */}
|
{/* Participant List */}
|
||||||
{!loading && filteredParticipants.length > 0 && (
|
{!loading && filteredParticipants.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ mb: 10 }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{filteredParticipants.map((participant) => (
|
{filteredParticipants.map((participant) => (
|
||||||
<Card
|
<Card
|
||||||
@ -409,21 +437,23 @@ export default function ParticipantsPage() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => handleParticipantClick(participant)}
|
onClick={() => handleParticipantClick(participant)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 5 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 5 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
mb: 4,
|
mb: { xs: 2, sm: 4 },
|
||||||
|
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||||
|
gap: { xs: 2, sm: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 56,
|
width: { xs: 48, sm: 56 },
|
||||||
height: 56,
|
height: { xs: 48, sm: 56 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: colors.purpleLight,
|
bgcolor: colors.purpleLight,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -431,16 +461,16 @@ export default function ParticipantsPage() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 32, color: colors.purple }} />
|
<Person sx={{ fontSize: { xs: 28, sm: 32 }, color: colors.purple }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||||||
#{participant.participantId}
|
#{participant.participantId}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
{participant.phoneNumber}
|
{participant.phoneNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -449,7 +479,7 @@ export default function ParticipantsPage() {
|
|||||||
label={getStatusText(participant.isWinner)}
|
label={getStatusText(participant.isWinner)}
|
||||||
color={getStatusColor(participant.isWinner) as any}
|
color={getStatusColor(participant.isWinner) as any}
|
||||||
size="medium"
|
size="medium"
|
||||||
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
|
sx={{ fontWeight: 600, px: { xs: 1.5, sm: 2 }, py: { xs: 2, sm: 2.5 }, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -458,15 +488,15 @@ export default function ParticipantsPage() {
|
|||||||
sx={{
|
sx={{
|
||||||
borderTop: '1px solid',
|
borderTop: '1px solid',
|
||||||
borderColor: colors.gray[100],
|
borderColor: colors.gray[100],
|
||||||
pt: 4,
|
pt: { xs: 2, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 2,
|
gap: { xs: 1.5, sm: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{participant.channel && (
|
{participant.channel && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
참여 경로
|
참여 경로
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
@ -476,24 +506,25 @@ export default function ParticipantsPage() {
|
|||||||
bgcolor: colors.purpleLight,
|
bgcolor: colors.purpleLight,
|
||||||
color: colors.purple,
|
color: colors.purple,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '0.625rem', sm: '0.75rem' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
참여 일시
|
참여 일시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.75rem', sm: '1rem' }, textAlign: 'right' }}>
|
||||||
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
|
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{participant.storeVisited && (
|
{participant.storeVisited && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
매장 방문
|
매장 방문
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="방문" size="small" color="success" />
|
<Chip label="방문" size="small" color="success" sx={{ fontSize: { xs: '0.625rem', sm: '0.75rem' } }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -505,7 +536,7 @@ export default function ParticipantsPage() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 10 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
@ -514,8 +545,14 @@ export default function ParticipantsPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiPaginationItem-root': {
|
'& .MuiPaginationItem-root': {
|
||||||
fontSize: '1rem',
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
fontWeight: 600,
|
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' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -558,7 +595,7 @@ export default function ParticipantsPage() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 40, color: colors.purple }} />
|
<Person sx={{ fontSize: { xs: 32, sm: 40 }, color: colors.purple }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
{selectedParticipant.name}
|
{selectedParticipant.name}
|
||||||
|
|||||||
81
src/app/(main)/events/create/content-test/page.tsx
Normal file
81
src/app/(main)/events/create/content-test/page.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ export type EventMethod = 'online' | 'offline';
|
|||||||
|
|
||||||
export interface EventData {
|
export interface EventData {
|
||||||
eventDraftId?: number;
|
eventDraftId?: number;
|
||||||
|
eventId?: string;
|
||||||
objective?: EventObjective;
|
objective?: EventObjective;
|
||||||
recommendation?: {
|
recommendation?: {
|
||||||
recommendation: {
|
recommendation: {
|
||||||
@ -95,14 +96,14 @@ export default function EventCreatePage() {
|
|||||||
<funnel.Render
|
<funnel.Render
|
||||||
objective={({ history }) => (
|
objective={({ history }) => (
|
||||||
<ObjectiveStep
|
<ObjectiveStep
|
||||||
onNext={(objective) => {
|
onNext={({ objective, eventId }) => {
|
||||||
history.push('recommendation', { objective });
|
history.push('recommendation', { objective, eventId });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
recommendation={({ context, history }) => (
|
recommendation={({ context, history }) => (
|
||||||
<RecommendationStep
|
<RecommendationStep
|
||||||
objective={context.objective}
|
eventId={context.eventId}
|
||||||
onNext={(recommendation) => {
|
onNext={(recommendation) => {
|
||||||
history.push('channel', { ...context, recommendation });
|
history.push('channel', { ...context, recommendation });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
|||||||
import { eventApi } from '@/entities/event/api/eventApi';
|
import { eventApi } from '@/entities/event/api/eventApi';
|
||||||
import type { EventObjective } from '@/entities/event/model/types';
|
import type { EventObjective } from '@/entities/event/model/types';
|
||||||
|
|
||||||
|
|
||||||
interface ApprovalStepProps {
|
interface ApprovalStepProps {
|
||||||
eventData: EventData;
|
eventData: EventData;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
@ -34,6 +35,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
const [termsDialogOpen, setTermsDialogOpen] = useState(false);
|
const [termsDialogOpen, setTermsDialogOpen] = useState(false);
|
||||||
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
||||||
const [isDeploying, setIsDeploying] = 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 () => {
|
const handleApprove = async () => {
|
||||||
if (!agreeTerms) return;
|
if (!agreeTerms) return;
|
||||||
@ -86,39 +88,46 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
});
|
});
|
||||||
console.log('✅ Event details updated');
|
console.log('✅ Event details updated');
|
||||||
|
|
||||||
// 3. 배포 채널 선택
|
|
||||||
if (eventData.channels && eventData.channels.length > 0) {
|
|
||||||
console.log('📞 Selecting channels:', eventData.channels);
|
|
||||||
|
|
||||||
// 채널명 매핑 (Frontend → Backend)
|
// 채널명 매핑 (Frontend → Backend)
|
||||||
const channelMap: Record<string, string> = {
|
const channelMap: Record<string, string[]> = {
|
||||||
'uriTV': 'WEBSITE',
|
uriTV: ['URIDONGNETV'],
|
||||||
'ringoBiz': 'EMAIL',
|
ringoBiz: ['RINGOBIZ'],
|
||||||
'genieTV': 'KAKAO',
|
genieTV: ['GINITV'],
|
||||||
'sns': 'INSTAGRAM',
|
sns: ['INSTAGRAM', 'NAVER', 'KAKAO'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase());
|
const apiChannels = eventData.channels?.flatMap(ch => channelMap[ch] || []) || [];
|
||||||
|
|
||||||
await eventApi.selectChannels(eventId, {
|
const distributionRequest = {
|
||||||
channels: backendChannels,
|
eventId: eventId,
|
||||||
|
title: eventName,
|
||||||
|
description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '',
|
||||||
|
imageUrl: '', // TODO: 이미지 URL 연동 필요
|
||||||
|
channels: apiChannels,
|
||||||
|
channelSettings: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
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),
|
||||||
});
|
});
|
||||||
console.log('✅ Channels selected');
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(errorData.error || '배포 중 오류가 발생했습니다');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. TODO: 이미지 선택
|
const data = await response.json();
|
||||||
// 현재 frontend에서 selectedImageId를 추적하지 않음
|
console.log('✅ Distribution completed:', data);
|
||||||
// 향후 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);
|
setIsDeploying(false);
|
||||||
setSuccessDialogOpen(true);
|
setSuccessDialogOpen(true);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Event creation failed: No event ID returned');
|
throw new Error('Event creation failed: No event ID returned');
|
||||||
}
|
}
|
||||||
@ -129,6 +138,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const handleSaveDraft = () => {
|
||||||
// TODO: 임시저장 API 연동
|
// TODO: 임시저장 API 연동
|
||||||
alert('임시저장되었습니다');
|
alert('임시저장되었습니다');
|
||||||
@ -146,10 +156,10 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
|
<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: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -159,7 +169,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 10 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<CheckCircle sx={{ fontSize: 64, color: colors.purple, mb: 2 }} />
|
<CheckCircle sx={{ fontSize: 64, color: colors.purple, mb: 2 }} />
|
||||||
<Typography variant="h5" sx={{ ...responsiveText.h3, fontWeight: 700, mb: 2 }}>
|
<Typography variant="h5" sx={{ ...responsiveText.h3, fontWeight: 700, mb: 2 }}>
|
||||||
이벤트를 확인해주세요
|
이벤트를 확인해주세요
|
||||||
@ -170,7 +180,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Event Summary Statistics */}
|
{/* Event Summary Statistics */}
|
||||||
<Grid container spacing={4} sx={{ mb: 10 }}>
|
<Grid container spacing={4} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@ -382,7 +392,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
@ -402,7 +412,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
배포 채널
|
배포 채널
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 4 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 4 }}>
|
||||||
{getChannelNames(eventData.channels).map((channel) => (
|
{getChannelNames(eventData.channels).map((channel) => (
|
||||||
@ -435,7 +445,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Terms Agreement */}
|
{/* Terms Agreement */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
@ -606,7 +616,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
<Typography variant="h5" sx={{ fontSize: '1.5rem', fontWeight: 700, mb: 3 }}>
|
<Typography variant="h5" sx={{ fontSize: '1.5rem', fontWeight: 700, mb: 3 }}>
|
||||||
배포 완료!
|
배포 완료!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: 8 }}>
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: { xs: 3, sm: 8 } }}>
|
||||||
이벤트가 성공적으로 배포되었습니다.
|
이벤트가 성공적으로 배포되었습니다.
|
||||||
<br />
|
<br />
|
||||||
실시간으로 참여자를 확인할 수 있습니다.
|
실시간으로 참여자를 확인할 수 있습니다.
|
||||||
|
|||||||
@ -108,9 +108,9 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -119,7 +119,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
|
||||||
(최소 1개 이상)
|
(최소 1개 이상)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -211,7 +211,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -270,7 +270,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -347,7 +347,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: channels[3].selected ? 2 : 1,
|
border: channels[3].selected ? 2 : 1,
|
||||||
borderColor: channels[3].selected ? colors.purple : 'divider',
|
borderColor: channels[3].selected ? colors.purple : 'divider',
|
||||||
@ -356,7 +356,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -465,13 +465,13 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'grey.50',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 8 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
|
||||||
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
||||||
총 예상 비용
|
총 예상 비용
|
||||||
|
|||||||
@ -40,10 +40,10 @@ export default function ContentEditStep({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
|
<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: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 10 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 4, sm: 10 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@ -310,8 +310,8 @@ export default function ContentPreviewStep({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<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: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -442,9 +442,9 @@ export default function ContentPreviewStep({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -453,7 +453,7 @@ export default function ContentPreviewStep({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: { xs: 3, sm: 8 } }}>
|
||||||
{generatedImages.size > 0 && (
|
{generatedImages.size > 0 && (
|
||||||
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
|
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
|
||||||
✨ 생성된 이미지를 확인하고 스타일을 선택하세요
|
✨ 생성된 이미지를 확인하고 스타일을 선택하세요
|
||||||
@ -477,12 +477,12 @@ export default function ContentPreviewStep({
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
|
||||||
이벤트에 어울리는 스타일을 선택하세요
|
이벤트에 어울리는 스타일을 선택하세요
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
|
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{imageStyles.map((style) => (
|
{imageStyles.map((style) => (
|
||||||
<Grid item xs={12} md={4} key={style.id}>
|
<Grid item xs={12} md={4} key={style.id}>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -67,35 +67,81 @@ const objectives: ObjectiveOption[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface ObjectiveStepProps {
|
interface ObjectiveStepProps {
|
||||||
onNext: (objective: EventObjective) => void;
|
onNext: (data: { objective: EventObjective; eventId: string }) => 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) {
|
export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
||||||
const [selected, setSelected] = useState<EventObjective | null>(null);
|
const [selected, setSelected] = useState<EventObjective | null>(null);
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
onNext(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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="md" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="md" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<Box sx={{ mb: 10, textAlign: 'center' }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
|
||||||
<AutoAwesome sx={{ fontSize: 80, color: colors.purple, mb: 4 }} />
|
<AutoAwesome sx={{ fontSize: { xs: 60, sm: 80 }, color: colors.purple, mb: { xs: 2, sm: 4 } }} />
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: { xs: 2, sm: 4 }, fontSize: { xs: '1.5rem', sm: '2rem' } }}>
|
||||||
이벤트 목적을 선택해주세요
|
이벤트 목적을 선택해주세요
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.125rem' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
|
||||||
AI가 목적에 맞는 최적의 이벤트를 추천해드립니다
|
AI가 목적에 맞는 최적의 이벤트를 추천해드립니다
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Purpose Options */}
|
{/* Purpose Options */}
|
||||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
|
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{objectives.map((objective) => (
|
{objectives.map((objective) => (
|
||||||
<Grid item xs={12} sm={6} key={objective.id}>
|
<Grid item xs={12} sm={6} key={objective.id}>
|
||||||
<Card
|
<Card
|
||||||
@ -116,14 +162,14 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
}}
|
}}
|
||||||
onClick={() => setSelected(objective.id)}
|
onClick={() => setSelected(objective.id)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 2, sm: 3 }, mb: { xs: 2, sm: 3 } }}>
|
||||||
<Box sx={{ color: colors.purple }}>{objective.icon}</Box>
|
<Box sx={{ color: colors.purple }}>{objective.icon}</Box>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 1, sm: 2 }, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
{objective.title}
|
{objective.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
{objective.description}
|
{objective.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -145,15 +191,15 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blue}20 100%)`,
|
background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blue}20 100%)`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ display: 'flex', gap: 3, p: 6 }}>
|
<CardContent sx={{ display: 'flex', gap: { xs: 2, sm: 3 }, p: { xs: 3, sm: 6 } }}>
|
||||||
<AutoAwesome sx={{ color: colors.purple, fontSize: 28 }} />
|
<AutoAwesome sx={{ color: colors.purple, fontSize: { xs: 24, sm: 28 } }} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '1rem', lineHeight: 1.8, color: colors.gray[700] }}>
|
<Typography variant="body2" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' }, lineHeight: 1.8, color: colors.gray[700] }}>
|
||||||
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -168,9 +214,9 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
disabled={!selected}
|
disabled={!selected}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
sx={{
|
sx={{
|
||||||
py: 3,
|
py: { xs: 2, sm: 3 },
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
fontSize: '1rem',
|
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@ -19,8 +19,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
||||||
import { EventObjective, BudgetLevel, EventMethod } from '../page';
|
import { eventApi } from '@/shared/api';
|
||||||
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
|
import type { AiRecommendationResult, EventRecommendation } from '@/shared/api/eventApi';
|
||||||
|
|
||||||
// 디자인 시스템 색상
|
// 디자인 시스템 색상
|
||||||
const colors = {
|
const colors = {
|
||||||
@ -41,130 +41,86 @@ const colors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface RecommendationStepProps {
|
interface RecommendationStepProps {
|
||||||
objective?: EventObjective;
|
|
||||||
eventId?: string; // 이전 단계에서 생성된 eventId
|
eventId?: string; // 이전 단계에서 생성된 eventId
|
||||||
onNext: (data: {
|
onNext: (data: { recommendation: EventRecommendation; eventId: string }) => void;
|
||||||
recommendation: EventRecommendation;
|
|
||||||
eventId: string;
|
|
||||||
}) => void;
|
|
||||||
onBack: () => 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({
|
export default function RecommendationStep({
|
||||||
objective,
|
|
||||||
eventId: initialEventId,
|
eventId: initialEventId,
|
||||||
onNext,
|
onNext,
|
||||||
onBack
|
onBack,
|
||||||
}: RecommendationStepProps) {
|
}: RecommendationStepProps) {
|
||||||
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [polling, setPolling] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [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 }>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 AI 추천 요청
|
// 중복 호출 방지를 위한 ref
|
||||||
|
const requestedEventIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 AI 추천 결과 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!eventId && objective) {
|
// props에서만 eventId를 받음
|
||||||
// Step 1: 이벤트 생성
|
if (initialEventId) {
|
||||||
createEventAndRequestAI();
|
// 이미 요청한 eventId면 중복 요청하지 않음
|
||||||
} else if (eventId) {
|
if (requestedEventIdRef.current === initialEventId) {
|
||||||
// 이미 eventId가 있으면 AI 추천 요청
|
console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId);
|
||||||
requestAIRecommendations(eventId);
|
return;
|
||||||
}
|
}
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createEventAndRequestAI = async () => {
|
requestedEventIdRef.current = initialEventId;
|
||||||
|
setEventId(initialEventId);
|
||||||
|
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
|
||||||
|
// eventId가 있으면 바로 AI 추천 결과 조회
|
||||||
|
fetchAIRecommendations(initialEventId);
|
||||||
|
} else {
|
||||||
|
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
|
||||||
|
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||||||
|
}
|
||||||
|
}, [initialEventId]);
|
||||||
|
|
||||||
|
const fetchAIRecommendations = async (evtId: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Step 1: 이벤트 목적 선택 및 생성
|
console.log('📡 AI 추천 요청 시작, eventId:', evtId);
|
||||||
const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치');
|
|
||||||
const newEventId = eventResponse.eventId;
|
|
||||||
setEventId(newEventId);
|
|
||||||
|
|
||||||
// Step 2: AI 추천 요청
|
// POST /events/{eventId}/ai-recommendations 엔드포인트로 AI 추천 요청
|
||||||
await requestAIRecommendations(newEventId);
|
const recommendations = await eventApi.requestAiRecommendations(evtId);
|
||||||
} catch (err: any) {
|
|
||||||
console.error('이벤트 생성 실패:', err);
|
|
||||||
setError(err.response?.data?.message || err.message || '이벤트 생성에 실패했습니다');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const requestAIRecommendations = async (evtId: string) => {
|
console.log('✅ AI 추천 요청 성공:', recommendations);
|
||||||
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);
|
setAiResult(recommendations);
|
||||||
setLoading(false);
|
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) {
|
} catch (err: any) {
|
||||||
console.error('Job 상태 조회 실패:', err);
|
console.error('❌ AI 추천 요청 실패:', err);
|
||||||
setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
poll();
|
const errorMessage =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
'AI 추천을 생성하는데 실패했습니다';
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
@ -207,7 +163,7 @@ export default function RecommendationStep({
|
|||||||
...prev,
|
...prev,
|
||||||
[optionNumber]: {
|
[optionNumber]: {
|
||||||
...prev[optionNumber],
|
...prev[optionNumber],
|
||||||
title
|
title,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@ -217,31 +173,33 @@ export default function RecommendationStep({
|
|||||||
...prev,
|
...prev,
|
||||||
[optionNumber]: {
|
[optionNumber]: {
|
||||||
...prev[optionNumber],
|
...prev[optionNumber],
|
||||||
description
|
description,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로딩 상태 표시
|
// 로딩 상태 표시
|
||||||
if (loading || polling) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<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: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 4, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '1.5rem' } }}>
|
||||||
AI 이벤트 추천
|
AI 이벤트 추천
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { xs: 2, sm: 4 }, py: { xs: 6, sm: 12 } }}
|
||||||
|
>
|
||||||
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
AI가 최적의 이벤트를 생성하고 있습니다...
|
AI가 최적의 이벤트를 생성하고 있습니다...
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -254,8 +212,8 @@ export default function RecommendationStep({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<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: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -289,10 +247,13 @@ export default function RecommendationStep({
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (eventId) {
|
// props에서 eventId가 없으면 쿠키에서 읽어오기
|
||||||
requestAIRecommendations(eventId);
|
const evtId = initialEventId || getCookie('eventId');
|
||||||
|
|
||||||
|
if (evtId) {
|
||||||
|
fetchAIRecommendations(evtId);
|
||||||
} else {
|
} else {
|
||||||
createEventAndRequestAI();
|
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
sx={{
|
sx={{
|
||||||
@ -315,7 +276,7 @@ export default function RecommendationStep({
|
|||||||
if (!aiResult) {
|
if (!aiResult) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
@ -324,9 +285,9 @@ export default function RecommendationStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -339,12 +300,12 @@ export default function RecommendationStep({
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 8 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
||||||
<Insights sx={{ fontSize: 32, color: colors.purple }} />
|
<Insights sx={{ fontSize: 32, color: colors.purple }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
|
||||||
@ -357,7 +318,12 @@ export default function RecommendationStep({
|
|||||||
📍 업종 트렌드
|
📍 업종 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
|
{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}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -367,7 +333,12 @@ export default function RecommendationStep({
|
|||||||
🗺️ 지역 트렌드
|
🗺️ 지역 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
|
{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}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -377,7 +348,12 @@ export default function RecommendationStep({
|
|||||||
☀️ 시즌 트렌드
|
☀️ 시즌 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
|
{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}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -387,18 +363,19 @@ export default function RecommendationStep({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* AI Recommendations */}
|
{/* AI Recommendations */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
||||||
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
||||||
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다.
|
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수
|
||||||
|
있습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Recommendations */}
|
{/* Recommendations */}
|
||||||
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{aiResult.recommendations.map((rec) => (
|
{aiResult.recommendations.map((rec) => (
|
||||||
<Grid item xs={12} key={rec.optionNumber}>
|
<Grid item xs={12} key={rec.optionNumber}>
|
||||||
<Card
|
<Card
|
||||||
@ -408,9 +385,15 @@ export default function RecommendationStep({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: selected === rec.optionNumber ? 2 : 1,
|
border: selected === rec.optionNumber ? 2 : 1,
|
||||||
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
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',
|
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': {
|
'&:hover': {
|
||||||
borderColor: colors.purple,
|
borderColor: colors.purple,
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
@ -419,8 +402,15 @@ export default function RecommendationStep({
|
|||||||
}}
|
}}
|
||||||
onClick={() => setSelected(rec.optionNumber)}
|
onClick={() => setSelected(rec.optionNumber)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={`옵션 ${rec.optionNumber}`}
|
label={`옵션 ${rec.optionNumber}`}
|
||||||
@ -472,39 +462,73 @@ export default function RecommendationStep({
|
|||||||
|
|
||||||
<Grid container spacing={4} sx={{ mt: 2 }}>
|
<Grid container spacing={4} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={6} md={3}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
{rec.targetAudience}
|
{rec.targetAudience}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
|
{(rec.estimatedCost.min / 10000).toFixed(0)}~
|
||||||
|
{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
|
{rec.expectedMetrics.newCustomers.min}~
|
||||||
|
{rec.expectedMetrics.newCustomers.max}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<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
|
ROI
|
||||||
</Typography>
|
</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}%
|
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<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>
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
||||||
|
|||||||
@ -57,13 +57,74 @@ export default function EventsPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
// API 데이터 가져오기
|
// 목업 데이터
|
||||||
const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({
|
const mockEvents = [
|
||||||
page: currentPage - 1,
|
{
|
||||||
size: itemsPerPage,
|
eventId: 'evt_2025012301',
|
||||||
sort: 'createdAt',
|
eventName: '신규 고객 환영 이벤트',
|
||||||
order: 'desc'
|
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 상태를 UI 상태로 매핑
|
// API 상태를 UI 상태로 매핑
|
||||||
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
|
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
|
||||||
@ -215,7 +276,7 @@ export default function EventsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@ -241,41 +302,6 @@ export default function EventsPage() {
|
|||||||
</Box>
|
</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 */}
|
{/* Summary Statistics */}
|
||||||
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
|
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import BottomNavigation from '@/shared/ui/BottomNavigation';
|
import BottomNavigation from '@/shared/ui/BottomNavigation';
|
||||||
|
import { AuthGuard } from '@/features/auth';
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<AuthGuard>
|
||||||
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
||||||
{children}
|
{children}
|
||||||
<BottomNavigation />
|
<BottomNavigation />
|
||||||
</Box>
|
</Box>
|
||||||
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab } from '@mui/material';
|
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab, CircularProgress, Alert } from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
Celebration,
|
Celebration,
|
||||||
@ -19,34 +20,9 @@ import {
|
|||||||
cardStyles,
|
cardStyles,
|
||||||
colors,
|
colors,
|
||||||
} from '@/shared/lib/button-styles';
|
} from '@/shared/lib/button-styles';
|
||||||
|
import { useAuth } from '@/features/auth/model/useAuth';
|
||||||
// Mock 사용자 데이터 (API 연동 전까지 임시 사용)
|
import { analyticsApi } from '@/entities/analytics/api/analyticsApi';
|
||||||
const mockUser = {
|
import type { UserAnalyticsDashboardResponse } from '@/entities/analytics/model/types';
|
||||||
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 = [
|
const mockActivities = [
|
||||||
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
||||||
@ -56,14 +32,47 @@ const mockActivities = [
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
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);
|
||||||
|
|
||||||
// KPI 계산
|
// Analytics API 호출
|
||||||
const activeEvents = mockEvents.filter((e) => e.status === '진행중');
|
useEffect(() => {
|
||||||
const totalParticipants = mockEvents.reduce((sum, e) => sum + e.participants, 0);
|
const fetchAnalytics = async () => {
|
||||||
const avgROI =
|
if (!user?.userId) {
|
||||||
mockEvents.length > 0
|
setLoading(false);
|
||||||
? Math.round(mockEvents.reduce((sum, e) => sum + e.roi, 0) / mockEvents.length)
|
return;
|
||||||
: 0;
|
}
|
||||||
|
|
||||||
|
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 ?? [];
|
||||||
|
|
||||||
const handleCreateEvent = () => {
|
const handleCreateEvent = () => {
|
||||||
router.push('/events/create');
|
router.push('/events/create');
|
||||||
@ -83,7 +92,7 @@ export default function HomePage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@ -98,16 +107,30 @@ export default function HomePage() {
|
|||||||
mb: { xs: 2, sm: 4 },
|
mb: { xs: 2, sm: 4 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
안녕하세요, {mockUser.name}님! 👋
|
안녕하세요, {user?.userName || '사용자'}님! 👋
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ ...responsiveText.body1 }}>
|
<Typography variant="body1" sx={{ ...responsiveText.body1 }}>
|
||||||
이벤트 현황을 한눈에 확인하고 성과를 분석해보세요
|
이벤트 현황을 한눈에 확인하고 성과를 분석해보세요
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 */}
|
{/* KPI Cards */}
|
||||||
<Grid container spacing={{ xs: 3, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
|
<Grid container spacing={{ xs: 1.5, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -116,22 +139,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Celebration sx={{
|
<Celebration sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -142,8 +165,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
진행 중인 이벤트
|
진행 중인 이벤트
|
||||||
@ -153,16 +177,16 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeEvents.length}
|
{activeEventsCount}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -171,22 +195,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group sx={{
|
<Group sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -197,8 +221,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
총 참여자
|
총 참여자
|
||||||
@ -208,7 +233,7 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -217,7 +242,7 @@ export default function HomePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -226,22 +251,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrendingUp sx={{
|
<TrendingUp sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -252,8 +277,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
평균 ROI
|
평균 ROI
|
||||||
@ -263,7 +289,7 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -288,7 +314,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
onClick={handleCreateEvent}
|
onClick={handleCreateEvent}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 56, sm: 72 },
|
width: { xs: 56, sm: 72 },
|
||||||
@ -319,7 +345,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
onClick={handleViewAnalytics}
|
onClick={handleViewAnalytics}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 56, sm: 72 },
|
width: { xs: 56, sm: 72 },
|
||||||
@ -368,7 +394,7 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeEvents.length === 0 ? (
|
{!loading && eventPerformances.length === 0 ? (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -402,16 +428,16 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : !loading && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 3, sm: 6 } }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 3, sm: 6 } }}>
|
||||||
{activeEvents.map((event) => (
|
{eventPerformances.slice(0, 2).map((event) => (
|
||||||
<Card
|
<Card
|
||||||
key={event.id}
|
key={event.eventId}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
...cardStyles.clickable,
|
...cardStyles.clickable,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleEventClick(event.id)}
|
onClick={() => handleEventClick(event.eventId)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: { xs: 4, sm: 6, md: 8 } }}>
|
<CardContent sx={{ p: { xs: 4, sm: 6, md: 8 } }}>
|
||||||
<Box
|
<Box
|
||||||
@ -424,7 +450,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
{event.title}
|
{event.eventTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -441,23 +467,7 @@ export default function HomePage() {
|
|||||||
{event.status}
|
{event.status}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Box sx={{ display: 'flex', gap: { xs: 6, sm: 12 }, mt: { xs: 3, sm: 6 } }}>
|
||||||
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>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@ -479,6 +489,27 @@ export default function HomePage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@ -487,7 +518,7 @@ export default function HomePage() {
|
|||||||
ROI
|
ROI
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||||||
{event.roi}%
|
{Math.round(event.roi * 100) / 100}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -13,24 +13,23 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
Avatar,
|
|
||||||
Select,
|
Select,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
FormControl,
|
FormControl,
|
||||||
InputLabel,
|
InputLabel,
|
||||||
InputAdornment,
|
|
||||||
IconButton,
|
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Person, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
|
import { CheckCircle } from '@mui/icons-material';
|
||||||
import { useAuthContext } from '@/features/auth';
|
import { useAuthContext } from '@/features/auth';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { userApi } from '@/entities/user';
|
import { userApi } from '@/entities/user';
|
||||||
import Header from '@/shared/ui/Header';
|
import Header from '@/shared/ui/Header';
|
||||||
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
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({
|
const basicInfoSchema = z.object({
|
||||||
@ -50,32 +49,13 @@ const businessInfoSchema = z.object({
|
|||||||
businessHours: z.string().optional(),
|
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 BasicInfoData = z.infer<typeof basicInfoSchema>;
|
||||||
type BusinessInfoData = z.infer<typeof businessInfoSchema>;
|
type BusinessInfoData = z.infer<typeof businessInfoSchema>;
|
||||||
type PasswordData = z.infer<typeof passwordSchema>;
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, logout, refreshProfile } = useAuthContext();
|
const { user, logout, refreshProfile } = useAuthContext();
|
||||||
const { showToast, setLoading } = useUIStore();
|
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 [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
||||||
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
|
||||||
const [profileLoaded, setProfileLoaded] = useState(false);
|
const [profileLoaded, setProfileLoaded] = useState(false);
|
||||||
@ -105,26 +85,12 @@ export default function ProfilePage() {
|
|||||||
resolver: zodResolver(businessInfoSchema),
|
resolver: zodResolver(businessInfoSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
businessName: '',
|
businessName: '',
|
||||||
businessType: '',
|
businessType: 'restaurant',
|
||||||
businessLocation: '',
|
businessLocation: '',
|
||||||
businessHours: '',
|
businessHours: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// 비밀번호 변경 폼
|
|
||||||
const {
|
|
||||||
control: passwordControl,
|
|
||||||
handleSubmit: handlePasswordSubmit,
|
|
||||||
formState: { errors: passwordErrors },
|
|
||||||
reset: resetPassword,
|
|
||||||
} = useForm<PasswordData>({
|
|
||||||
resolver: zodResolver(passwordSchema),
|
|
||||||
defaultValues: {
|
|
||||||
currentPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 프로필 데이터 로드
|
// 프로필 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -164,7 +130,7 @@ export default function ProfilePage() {
|
|||||||
// 사업장 정보 폼 초기화
|
// 사업장 정보 폼 초기화
|
||||||
resetBusiness({
|
resetBusiness({
|
||||||
businessName: profile.storeName || '',
|
businessName: profile.storeName || '',
|
||||||
businessType: profile.industry || '',
|
businessType: profile.industry || 'restaurant',
|
||||||
businessLocation: profile.address || '',
|
businessLocation: profile.address || '',
|
||||||
businessHours: profile.businessHours || '',
|
businessHours: profile.businessHours || '',
|
||||||
});
|
});
|
||||||
@ -244,40 +210,6 @@ 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 = () => {
|
const handleSave = () => {
|
||||||
handleBasicSubmit((basicData) => {
|
handleBasicSubmit((basicData) => {
|
||||||
@ -310,27 +242,34 @@ export default function ProfilePage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* 사용자 정보 섹션 */}
|
{/* 사용자 정보 섹션 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10, textAlign: 'center' }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Avatar
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
mx: 'auto',
|
mx: 'auto',
|
||||||
mb: 3,
|
mb: 3,
|
||||||
bgcolor: colors.purple,
|
borderRadius: '50%',
|
||||||
color: 'white',
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 56 }} />
|
<Image
|
||||||
</Avatar>
|
src={userImage}
|
||||||
|
alt="User Profile"
|
||||||
|
fill
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
<Typography variant="h5" sx={{ ...responsiveText.h3, mb: 1 }}>
|
<Typography variant="h5" sx={{ ...responsiveText.h3, mb: 1 }}>
|
||||||
{user?.userName}
|
{user?.userName}
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -341,7 +280,7 @@ export default function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
||||||
기본 정보
|
기본 정보
|
||||||
@ -399,7 +338,7 @@ export default function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 매장 정보 */}
|
{/* 매장 정보 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
||||||
매장 정보
|
매장 정보
|
||||||
@ -469,121 +408,6 @@ export default function ProfilePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
<Button
|
<Button
|
||||||
@ -634,12 +458,12 @@ export default function ProfilePage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<DialogContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
<CheckCircle sx={{ fontSize: { xs: 48, sm: 64 }, color: 'success.main', mb: { xs: 1, sm: 2 } }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
저장 완료
|
저장 완료
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
프로필 정보가 업데이트되었습니다.
|
프로필 정보가 업데이트되었습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -519,7 +519,7 @@ export default function TestAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<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={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<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={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<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={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
|
|||||||
44
src/app/api/analytics/events/[eventId]/channels/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/channels/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/roi/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/roi/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/timeline/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/timeline/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/channels/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/channels/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/roi/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/roi/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/analytics/users/[userId]/route.ts
Normal file
59
src/app/api/analytics/users/[userId]/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/timeline/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/timeline/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,52 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
src/app/api/distribution/[eventId]/status/route.ts
Normal file
44
src/app/api/distribution/[eventId]/status/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/app/api/participations/[eventId]/draw-winners/route.ts
Normal file
58
src/app/api/participations/[eventId]/draw-winners/route.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/participations/[eventId]/participants/route.ts
Normal file
56
src/app/api/participations/[eventId]/participants/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/participations/[eventId]/participate/route.ts
Normal file
56
src/app/api/participations/[eventId]/participate/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/app/api/participations/[eventId]/winners/route.ts
Normal file
56
src/app/api/participations/[eventId]/winners/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/v1/events/[eventId]/draw-winners/route.ts
Normal file
47
src/app/api/v1/events/[eventId]/draw-winners/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/app/api/v1/events/[eventId]/participants/route.ts
Normal file
61
src/app/api/v1/events/[eventId]/participants/route.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/v1/events/[eventId]/participate/route.ts
Normal file
47
src/app/api/v1/events/[eventId]/participate/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/v1/events/[eventId]/winners/route.ts
Normal file
59
src/app/api/v1/events/[eventId]/winners/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const { objective } = body;
|
const { objective } = body;
|
||||||
|
|
||||||
// 백엔드 API 호출 시도
|
// 백엔드 API 호출 시도
|
||||||
const backendUrl = 'http://localhost:8080/api/events/objectives';
|
const EVENT_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
const backendUrl = `${EVENT_HOST}/api/events/objectives`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const backendResponse = await fetch(backendUrl, {
|
const backendResponse = await fetch(backendUrl, {
|
||||||
|
|||||||
41
src/app/api/v1/users/login/route.ts
Normal file
41
src/app/api/v1/users/login/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/app/api/v1/users/logout/route.ts
Normal file
47
src/app/api/v1/users/logout/route.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/app/api/v1/users/password/route.ts
Normal file
53
src/app/api/v1/users/password/route.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
src/app/api/v1/users/profile/route.ts
Normal file
95
src/app/api/v1/users/profile/route.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/v1/users/register/route.ts
Normal file
42
src/app/api/v1/users/register/route.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import Script from 'next/script';
|
||||||
import { MUIThemeProvider } from '@/shared/lib/theme-provider';
|
import { MUIThemeProvider } from '@/shared/lib/theme-provider';
|
||||||
import { ReactQueryProvider } from '@/shared/lib/react-query-provider';
|
import { ReactQueryProvider } from '@/shared/lib/react-query-provider';
|
||||||
import { AuthProvider } from '@/features/auth';
|
import { AuthProvider } from '@/features/auth';
|
||||||
@ -34,6 +35,7 @@ export default function RootLayout({
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<Script src="/runtime-env.js" strategy="beforeInteractive" />
|
||||||
<MUIThemeProvider>
|
<MUIThemeProvider>
|
||||||
<ReactQueryProvider>
|
<ReactQueryProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
|||||||
@ -15,11 +15,11 @@ import type {
|
|||||||
RoiQueryParams,
|
RoiQueryParams,
|
||||||
} from '../model/types';
|
} from '../model/types';
|
||||||
|
|
||||||
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics API Service
|
* Analytics API Service
|
||||||
* 실시간 효과 측정 및 통합 대시보드 API
|
* 실시간 효과 측정 및 통합 대시보드 API
|
||||||
|
*
|
||||||
|
* Note: Proxy routes handle /api/v1 prefix, so paths start with /users or /events
|
||||||
*/
|
*/
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
// ============= User Analytics (사용자 전체 이벤트 통합) =============
|
// ============= User Analytics (사용자 전체 이벤트 통합) =============
|
||||||
@ -32,7 +32,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams
|
params?: AnalyticsQueryParams
|
||||||
): Promise<UserAnalyticsDashboardResponse> => {
|
): Promise<UserAnalyticsDashboardResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics`,
|
`/users/${userId}`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -46,7 +46,7 @@ export const analyticsApi = {
|
|||||||
params?: TimelineQueryParams
|
params?: TimelineQueryParams
|
||||||
): Promise<UserTimelineAnalyticsResponse> => {
|
): Promise<UserTimelineAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/timeline`,
|
`/users/${userId}/timeline`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -60,7 +60,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams & RoiQueryParams
|
params?: AnalyticsQueryParams & RoiQueryParams
|
||||||
): Promise<UserRoiAnalyticsResponse> => {
|
): Promise<UserRoiAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/roi`,
|
`/users/${userId}/roi`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -74,7 +74,7 @@ export const analyticsApi = {
|
|||||||
params?: ChannelQueryParams
|
params?: ChannelQueryParams
|
||||||
): Promise<UserChannelAnalyticsResponse> => {
|
): Promise<UserChannelAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/channels`,
|
`/users/${userId}/channels`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -90,7 +90,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams
|
params?: AnalyticsQueryParams
|
||||||
): Promise<AnalyticsDashboardResponse> => {
|
): Promise<AnalyticsDashboardResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
|
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics`,
|
`/events/${eventId}`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -104,7 +104,7 @@ export const analyticsApi = {
|
|||||||
params?: TimelineQueryParams
|
params?: TimelineQueryParams
|
||||||
): Promise<TimelineAnalyticsResponse> => {
|
): Promise<TimelineAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/timeline`,
|
`/events/${eventId}/timeline`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -118,7 +118,7 @@ export const analyticsApi = {
|
|||||||
params?: RoiQueryParams
|
params?: RoiQueryParams
|
||||||
): Promise<RoiAnalyticsResponse> => {
|
): Promise<RoiAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/roi`,
|
`/events/${eventId}/roi`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -132,7 +132,7 @@ export const analyticsApi = {
|
|||||||
params?: ChannelQueryParams
|
params?: ChannelQueryParams
|
||||||
): Promise<ChannelAnalyticsResponse> => {
|
): Promise<ChannelAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/channels`,
|
`/events/${eventId}/channels`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
const ANALYTICS_HOST =
|
// Next.js API Proxy를 통해 Analytics API 호출 (CORS 회피)
|
||||||
process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086';
|
const ANALYTICS_PROXY_BASE = '/api/analytics';
|
||||||
|
|
||||||
export const analyticsClient: AxiosInstance = axios.create({
|
export const analyticsClient: AxiosInstance = axios.create({
|
||||||
baseURL: ANALYTICS_HOST,
|
baseURL: ANALYTICS_PROXY_BASE,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -29,21 +29,18 @@ const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'
|
|||||||
/**
|
/**
|
||||||
* Event Service용 API 클라이언트
|
* Event Service용 API 클라이언트
|
||||||
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
|
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
|
||||||
*
|
|
||||||
* 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피)
|
|
||||||
* 프로덕션 환경: 환경 변수에서 직접 호스트 사용
|
|
||||||
*/
|
*/
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
|
||||||
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
|
|
||||||
|
|
||||||
const eventApiClient = axios.create({
|
const eventApiClient = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: `${EVENT_HOST}/api/${API_VERSION}`,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: false, // CORS 설정
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor - JWT 토큰 추가
|
// Request interceptor - JWT 토큰 추가
|
||||||
|
|||||||
1
src/entities/participation/api/index.ts
Normal file
1
src/entities/participation/api/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { participationApi, default } from './participationApi';
|
||||||
142
src/entities/participation/api/participationApi.ts
Normal file
142
src/entities/participation/api/participationApi.ts
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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;
|
||||||
10
src/entities/participation/index.ts
Normal file
10
src/entities/participation/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { participationApi } from './api';
|
||||||
|
export type {
|
||||||
|
ParticipationRequest,
|
||||||
|
ParticipationResponse,
|
||||||
|
ApiResponse,
|
||||||
|
PageResponse,
|
||||||
|
DrawWinnersRequest,
|
||||||
|
DrawWinnersResponse,
|
||||||
|
WinnerSummary,
|
||||||
|
} from './model';
|
||||||
9
src/entities/participation/model/index.ts
Normal file
9
src/entities/participation/model/index.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type {
|
||||||
|
ParticipationRequest,
|
||||||
|
ParticipationResponse,
|
||||||
|
ApiResponse,
|
||||||
|
PageResponse,
|
||||||
|
DrawWinnersRequest,
|
||||||
|
DrawWinnersResponse,
|
||||||
|
WinnerSummary,
|
||||||
|
} from './types';
|
||||||
114
src/entities/participation/model/types.ts
Normal file
114
src/entities/participation/model/types.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* 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[];
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { apiClient } from '@/shared/api';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import type {
|
import type {
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LoginResponse,
|
LoginResponse,
|
||||||
@ -10,8 +10,57 @@ import type {
|
|||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
} from '../model/types';
|
} from '../model/types';
|
||||||
|
|
||||||
|
// Use Next.js API proxy to bypass CORS issues
|
||||||
const USER_API_BASE = '/api/v1/users';
|
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
|
* User API Service
|
||||||
* 사용자 인증 및 프로필 관리 API
|
* 사용자 인증 및 프로필 관리 API
|
||||||
@ -21,8 +70,8 @@ export const userApi = {
|
|||||||
* 로그인
|
* 로그인
|
||||||
*/
|
*/
|
||||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||||
const response = await apiClient.post<LoginResponse>(
|
const response = await userApiClient.post<LoginResponse>(
|
||||||
`${USER_API_BASE}/login`,
|
'/login',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -33,15 +82,14 @@ export const userApi = {
|
|||||||
*/
|
*/
|
||||||
register: async (data: RegisterRequest): Promise<RegisterResponse> => {
|
register: async (data: RegisterRequest): Promise<RegisterResponse> => {
|
||||||
console.log('📞 userApi.register 호출');
|
console.log('📞 userApi.register 호출');
|
||||||
console.log('🎯 URL:', `${USER_API_BASE}/register`);
|
|
||||||
console.log('📦 요청 데이터:', {
|
console.log('📦 요청 데이터:', {
|
||||||
...data,
|
...data,
|
||||||
password: '***'
|
password: '***'
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.post<RegisterResponse>(
|
const response = await userApiClient.post<RegisterResponse>(
|
||||||
`${USER_API_BASE}/register`,
|
'/register',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
console.log('✅ userApi.register 성공:', response.data);
|
console.log('✅ userApi.register 성공:', response.data);
|
||||||
@ -56,15 +104,9 @@ export const userApi = {
|
|||||||
* 로그아웃
|
* 로그아웃
|
||||||
*/
|
*/
|
||||||
logout: async (): Promise<LogoutResponse> => {
|
logout: async (): Promise<LogoutResponse> => {
|
||||||
const token = localStorage.getItem('accessToken');
|
const response = await userApiClient.post<LogoutResponse>(
|
||||||
const response = await apiClient.post<LogoutResponse>(
|
'/logout',
|
||||||
`${USER_API_BASE}/logout`,
|
{}
|
||||||
{},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@ -73,8 +115,8 @@ export const userApi = {
|
|||||||
* 프로필 조회
|
* 프로필 조회
|
||||||
*/
|
*/
|
||||||
getProfile: async (): Promise<ProfileResponse> => {
|
getProfile: async (): Promise<ProfileResponse> => {
|
||||||
const response = await apiClient.get<ProfileResponse>(
|
const response = await userApiClient.get<ProfileResponse>(
|
||||||
`${USER_API_BASE}/profile`
|
'/profile'
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
@ -85,8 +127,8 @@ export const userApi = {
|
|||||||
updateProfile: async (
|
updateProfile: async (
|
||||||
data: UpdateProfileRequest
|
data: UpdateProfileRequest
|
||||||
): Promise<ProfileResponse> => {
|
): Promise<ProfileResponse> => {
|
||||||
const response = await apiClient.put<ProfileResponse>(
|
const response = await userApiClient.put<ProfileResponse>(
|
||||||
`${USER_API_BASE}/profile`,
|
'/profile',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -96,7 +138,7 @@ export const userApi = {
|
|||||||
* 비밀번호 변경
|
* 비밀번호 변경
|
||||||
*/
|
*/
|
||||||
changePassword: async (data: ChangePasswordRequest): Promise<void> => {
|
changePassword: async (data: ChangePasswordRequest): Promise<void> => {
|
||||||
await apiClient.put(`${USER_API_BASE}/password`, data);
|
await userApiClient.put('/password', data);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export interface LoginRequest {
|
|||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
token: string;
|
token: string;
|
||||||
userId: number;
|
userId: string; // UUID format
|
||||||
userName: string;
|
userName: string;
|
||||||
role: string;
|
role: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -31,9 +31,9 @@ export interface RegisterRequest {
|
|||||||
|
|
||||||
export interface RegisterResponse {
|
export interface RegisterResponse {
|
||||||
token: string;
|
token: string;
|
||||||
userId: number;
|
userId: string; // UUID format
|
||||||
userName: string;
|
userName: string;
|
||||||
storeId: number;
|
storeId: string; // UUID format
|
||||||
storeName: string;
|
storeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,12 +45,12 @@ export interface LogoutResponse {
|
|||||||
|
|
||||||
// 프로필 조회/수정
|
// 프로필 조회/수정
|
||||||
export interface ProfileResponse {
|
export interface ProfileResponse {
|
||||||
userId: number;
|
userId: string; // UUID format
|
||||||
userName: string;
|
userName: string;
|
||||||
phoneNumber: string;
|
phoneNumber: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
storeId: number;
|
storeId: string; // UUID format
|
||||||
storeName: string;
|
storeName: string;
|
||||||
industry: string;
|
industry: string;
|
||||||
address: string;
|
address: string;
|
||||||
@ -77,12 +77,12 @@ export interface ChangePasswordRequest {
|
|||||||
|
|
||||||
// User 상태
|
// User 상태
|
||||||
export interface User {
|
export interface User {
|
||||||
userId: number;
|
userId: string; // UUID format
|
||||||
userName: string;
|
userName: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
phoneNumber?: string;
|
phoneNumber?: string;
|
||||||
storeId?: number;
|
storeId?: string; // UUID format
|
||||||
storeName?: string;
|
storeName?: string;
|
||||||
industry?: string;
|
industry?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export { useAuth } from './model/useAuth';
|
export { useAuth } from './model/useAuth';
|
||||||
export { AuthProvider, useAuthContext } from './model/AuthProvider';
|
export { AuthProvider, useAuthContext } from './model/AuthProvider';
|
||||||
|
export { AuthGuard } from './ui/AuthGuard';
|
||||||
|
|||||||
@ -57,14 +57,25 @@ export const useAuth = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await userApi.login(credentials);
|
const response = await userApi.login(credentials);
|
||||||
|
|
||||||
|
// 토큰을 먼저 저장 (프로필 조회에 필요)
|
||||||
|
localStorage.setItem(TOKEN_KEY, response.token);
|
||||||
|
|
||||||
|
// 프로필 조회하여 storeId 포함한 전체 정보 가져오기
|
||||||
|
const profile = await userApi.getProfile();
|
||||||
|
|
||||||
const user: User = {
|
const user: User = {
|
||||||
userId: response.userId,
|
userId: profile.userId,
|
||||||
userName: response.userName,
|
userName: profile.userName,
|
||||||
email: response.email,
|
email: profile.email,
|
||||||
role: response.role,
|
role: profile.role,
|
||||||
|
phoneNumber: profile.phoneNumber,
|
||||||
|
storeId: profile.storeId,
|
||||||
|
storeName: profile.storeName,
|
||||||
|
industry: profile.industry,
|
||||||
|
address: profile.address,
|
||||||
|
businessHours: profile.businessHours,
|
||||||
};
|
};
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, response.token);
|
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||||
|
|
||||||
setAuthState({
|
setAuthState({
|
||||||
@ -76,6 +87,8 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
return { success: true, user };
|
return { success: true, user };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 로그인 실패 시 저장된 토큰 삭제
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error instanceof Error ? error.message : '로그인에 실패했습니다.',
|
error: error instanceof Error ? error.message : '로그인에 실패했습니다.',
|
||||||
|
|||||||
70
src/features/auth/ui/AuthGuard.tsx
Normal file
70
src/features/auth/ui/AuthGuard.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
'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}</>;
|
||||||
|
};
|
||||||
@ -2,13 +2,15 @@ import axios, { AxiosInstance } from 'axios';
|
|||||||
|
|
||||||
// AI Service API 클라이언트
|
// AI Service API 클라이언트
|
||||||
const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083';
|
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({
|
export const aiApiClient: AxiosInstance = axios.create({
|
||||||
baseURL: AI_API_BASE_URL,
|
baseURL: `${AI_API_BASE_URL}/api/${API_VERSION}`,
|
||||||
timeout: 300000, // AI 생성은 최대 5분
|
timeout: 300000, // AI 생성은 최대 5분
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: false, // CORS 설정
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor
|
// Request interceptor
|
||||||
@ -54,78 +56,7 @@ aiApiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Types
|
// Types (eventApi.ts로 이동됨 - import해서 사용)
|
||||||
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 {
|
export interface JobStatusResponse {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
@ -168,11 +99,11 @@ export const aiApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// AI 추천 결과 조회 (Internal API)
|
// AI 추천 결과 조회 (Internal API) - Deprecated: eventApi.getAiRecommendations 사용
|
||||||
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
|
// getRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
||||||
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
|
// const response = await aiApiClient.get<AiRecommendationResult>(`/internal/recommendations/${eventId}`);
|
||||||
return response.data;
|
// return response.data;
|
||||||
},
|
// },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default aiApi;
|
export default aiApi;
|
||||||
|
|||||||
@ -1,19 +1,20 @@
|
|||||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
// 마이크로서비스별 호스트 설정
|
// 마이크로서비스별 호스트 설정
|
||||||
|
const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
const API_HOSTS = {
|
const API_HOSTS = {
|
||||||
user: process.env.NEXT_PUBLIC_USER_HOST || 'http://localhost:8081',
|
user: GATEWAY_HOST,
|
||||||
event: process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080',
|
event: GATEWAY_HOST,
|
||||||
content: process.env.NEXT_PUBLIC_CONTENT_HOST || 'http://localhost:8082',
|
content: GATEWAY_HOST,
|
||||||
ai: process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083',
|
ai: GATEWAY_HOST,
|
||||||
participation: process.env.NEXT_PUBLIC_PARTICIPATION_HOST || 'http://localhost:8084',
|
participation: GATEWAY_HOST,
|
||||||
distribution: process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://localhost:8085',
|
distribution: GATEWAY_HOST,
|
||||||
analytics: process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086',
|
analytics: GATEWAY_HOST,
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
|
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
|
||||||
|
|
||||||
// 기본 User API 클라이언트 (기존 호환성 유지)
|
// 기본 User API 클라이언트 (Gateway 직접 연결)
|
||||||
const API_BASE_URL = API_HOSTS.user;
|
const API_BASE_URL = API_HOSTS.user;
|
||||||
|
|
||||||
export const apiClient: AxiosInstance = axios.create({
|
export const apiClient: AxiosInstance = axios.create({
|
||||||
@ -33,6 +34,15 @@ 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 함수
|
// 공통 Request interceptor 함수
|
||||||
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
|
||||||
console.log('🚀 API Request:', {
|
console.log('🚀 API Request:', {
|
||||||
@ -92,4 +102,8 @@ apiClient.interceptors.response.use(responseInterceptor, responseErrorIntercepto
|
|||||||
participationClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
|
participationClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
|
||||||
participationClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
|
participationClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
|
||||||
|
|
||||||
|
// Distribution API Client 인터셉터 적용
|
||||||
|
distributionClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
|
||||||
|
distributionClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@ -4,17 +4,13 @@ import axios, { AxiosInstance } from 'axios';
|
|||||||
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
||||||
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
|
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({
|
export const eventApiClient: AxiosInstance = axios.create({
|
||||||
baseURL: BASE_URL,
|
baseURL: `${EVENT_API_BASE_URL}/api/${API_VERSION}`,
|
||||||
timeout: 30000, // Job 폴링 고려
|
timeout: 30000, // Job 폴링 고려
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
|
withCredentials: false, // CORS 설정
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor
|
// Request interceptor
|
||||||
@ -24,9 +20,15 @@ eventApiClient.interceptors.request.use(
|
|||||||
method: config.method?.toUpperCase(),
|
method: config.method?.toUpperCase(),
|
||||||
url: config.url,
|
url: config.url,
|
||||||
baseURL: config.baseURL,
|
baseURL: config.baseURL,
|
||||||
data: config.data,
|
fullURL: `${config.baseURL}${config.url}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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');
|
const token = localStorage.getItem('accessToken');
|
||||||
if (token && config.headers) {
|
if (token && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
@ -54,8 +56,18 @@ eventApiClient.interceptors.response.use(
|
|||||||
message: error.message,
|
message: error.message,
|
||||||
status: error.response?.status,
|
status: error.response?.status,
|
||||||
url: error.config?.url,
|
url: error.config?.url,
|
||||||
data: error.response?.data,
|
requestData: error.config?.data,
|
||||||
|
responseData: 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);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@ -73,6 +85,7 @@ export interface EventCreatedResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AiRecommendationRequest {
|
export interface AiRecommendationRequest {
|
||||||
|
objective: string;
|
||||||
storeInfo: {
|
storeInfo: {
|
||||||
storeId: string;
|
storeId: string;
|
||||||
storeName: string;
|
storeName: string;
|
||||||
@ -98,6 +111,78 @@ export interface EventJobStatusResponse {
|
|||||||
completedAt?: string;
|
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 {
|
export interface SelectRecommendationRequest {
|
||||||
recommendationId: string;
|
recommendationId: string;
|
||||||
customizations?: {
|
customizations?: {
|
||||||
@ -220,21 +305,21 @@ export const eventApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Step 2: AI 추천 요청
|
// Step 2: AI 추천 요청 (POST)
|
||||||
requestAiRecommendations: async (
|
requestAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
||||||
eventId: string,
|
const response = await eventApiClient.post<AiRecommendationResult>(
|
||||||
storeInfo: AiRecommendationRequest['storeInfo']
|
`/events/${eventId}/ai-recommendations`
|
||||||
): Promise<JobAcceptedResponse> => {
|
|
||||||
const response = await eventApiClient.post<JobAcceptedResponse>(
|
|
||||||
`/events/${eventId}/ai-recommendations`,
|
|
||||||
{ storeInfo }
|
|
||||||
);
|
);
|
||||||
|
console.log('✅ AI 추천 요청 성공:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Job 상태 폴링
|
// AI 추천 결과 조회 (GET)
|
||||||
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
|
getAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
||||||
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
|
const response = await eventApiClient.get<AiRecommendationResult>(
|
||||||
|
`/events/${eventId}/ai-recommendations`
|
||||||
|
);
|
||||||
|
console.log('✅ AI 추천 결과 조회:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { participationClient } from './client';
|
import axios from 'axios';
|
||||||
import type {
|
import type {
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
PageResponse,
|
PageResponse,
|
||||||
@ -10,18 +10,19 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* Participation API Service
|
* Participation API Service
|
||||||
* 이벤트 참여 관련 API 함수들
|
* 이벤트 참여 관련 API 함수들
|
||||||
|
* Next.js API Routes를 통해 프록시하여 CORS 문제 해결
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이벤트 참여 신청
|
* 이벤트 참여 신청
|
||||||
* POST /v1/events/{eventId}/participate
|
* POST /api/participations/{eventId}/participate
|
||||||
*/
|
*/
|
||||||
export const participate = async (
|
export const participate = async (
|
||||||
eventId: string,
|
eventId: string,
|
||||||
data: ParticipationRequest
|
data: ParticipationRequest
|
||||||
): Promise<ApiResponse<ParticipationResponse>> => {
|
): Promise<ApiResponse<ParticipationResponse>> => {
|
||||||
const response = await participationClient.post<ApiResponse<ParticipationResponse>>(
|
const response = await axios.post<ApiResponse<ParticipationResponse>>(
|
||||||
`/v1/events/${eventId}/participate`,
|
`/api/participations/${eventId}/participate`,
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
@ -29,37 +30,35 @@ export const participate = async (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 참여자 목록 조회 (페이징)
|
* 참여자 목록 조회 (페이징)
|
||||||
* GET /v1/events/{eventId}/participants
|
* GET /api/participations/{eventId}/participants
|
||||||
*/
|
*/
|
||||||
export const getParticipants = async (
|
export const getParticipants = async (
|
||||||
params: GetParticipantsParams
|
params: GetParticipantsParams
|
||||||
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
|
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
|
||||||
const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params;
|
const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params;
|
||||||
|
|
||||||
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
|
const queryParams = new URLSearchParams();
|
||||||
`/v1/events/${eventId}/participants`,
|
if (storeVisited !== undefined) queryParams.append('storeVisited', String(storeVisited));
|
||||||
{
|
queryParams.append('page', String(page));
|
||||||
params: {
|
queryParams.append('size', String(size));
|
||||||
storeVisited,
|
sort.forEach(s => queryParams.append('sort', s));
|
||||||
page,
|
|
||||||
size,
|
const response = await axios.get<ApiResponse<PageResponse<ParticipationResponse>>>(
|
||||||
sort,
|
`/api/participations/${eventId}/participants?${queryParams.toString()}`
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 특정 참여자 정보 조회
|
* 특정 참여자 정보 조회
|
||||||
* GET /v1/events/{eventId}/participants/{participantId}
|
* GET /api/participations/{eventId}/participants/{participantId}
|
||||||
*/
|
*/
|
||||||
export const getParticipant = async (
|
export const getParticipant = async (
|
||||||
eventId: string,
|
eventId: string,
|
||||||
participantId: string
|
participantId: string
|
||||||
): Promise<ApiResponse<ParticipationResponse>> => {
|
): Promise<ApiResponse<ParticipationResponse>> => {
|
||||||
const response = await participationClient.get<ApiResponse<ParticipationResponse>>(
|
const response = await axios.get<ApiResponse<ParticipationResponse>>(
|
||||||
`/v1/events/${eventId}/participants/${participantId}`
|
`/api/participations/${eventId}/participants/${participantId}`
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@ -112,15 +111,15 @@ export const searchParticipants = async (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 추첨
|
* 당첨자 추첨
|
||||||
* POST /v1/events/{eventId}/draw-winners
|
* POST /api/participations/{eventId}/draw-winners
|
||||||
*/
|
*/
|
||||||
export const drawWinners = async (
|
export const drawWinners = async (
|
||||||
eventId: string,
|
eventId: string,
|
||||||
winnerCount: number,
|
winnerCount: number,
|
||||||
applyStoreVisitBonus?: boolean
|
applyStoreVisitBonus?: boolean
|
||||||
): Promise<ApiResponse<import('../types/api.types').DrawWinnersResponse>> => {
|
): Promise<ApiResponse<import('../types/api.types').DrawWinnersResponse>> => {
|
||||||
const response = await participationClient.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
|
const response = await axios.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
|
||||||
`/v1/events/${eventId}/draw-winners`,
|
`/api/participations/${eventId}/draw-winners`,
|
||||||
{
|
{
|
||||||
winnerCount,
|
winnerCount,
|
||||||
applyStoreVisitBonus,
|
applyStoreVisitBonus,
|
||||||
@ -131,7 +130,7 @@ export const drawWinners = async (
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 당첨자 목록 조회
|
* 당첨자 목록 조회
|
||||||
* GET /v1/events/{eventId}/winners
|
* GET /api/participations/{eventId}/winners
|
||||||
*/
|
*/
|
||||||
export const getWinners = async (
|
export const getWinners = async (
|
||||||
eventId: string,
|
eventId: string,
|
||||||
@ -139,15 +138,13 @@ export const getWinners = async (
|
|||||||
size = 20,
|
size = 20,
|
||||||
sort: string[] = ['winnerRank,ASC']
|
sort: string[] = ['winnerRank,ASC']
|
||||||
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
|
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
|
||||||
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
|
const queryParams = new URLSearchParams();
|
||||||
`/v1/events/${eventId}/winners`,
|
queryParams.append('page', String(page));
|
||||||
{
|
queryParams.append('size', String(size));
|
||||||
params: {
|
sort.forEach(s => queryParams.append('sort', s));
|
||||||
page,
|
|
||||||
size,
|
const response = await axios.get<ApiResponse<PageResponse<ParticipationResponse>>>(
|
||||||
sort,
|
`/api/participations/${eventId}/winners?${queryParams.toString()}`
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|||||||
BIN
src/shared/ui/user_img.png
Normal file
BIN
src/shared/ui/user_img.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@ -2,7 +2,7 @@ import { create } from 'zustand';
|
|||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string; // UUID format
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user