Merge pull request #9 from ktds-dg0501/develop

Develop
This commit is contained in:
Cherry Kim 2025-10-29 15:04:33 +09:00 committed by GitHub
commit 8d610deca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 4252 additions and 897 deletions

View File

@ -0,0 +1,19 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(mkdir:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(npm run dev:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(ls:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -7,5 +7,5 @@ NEXT_PUBLIC_PARTICIPATION_HOST=http://localhost:8084
NEXT_PUBLIC_DISTRIBUTION_HOST=http://localhost:8085
NEXT_PUBLIC_ANALYTICS_HOST=http://localhost:8086
# API Version
NEXT_PUBLIC_API_VERSION=v1
# API Version prefix
NEXT_PUBLIC_API_VERSION=api

263
API_CHANGES.md Normal file
View File

@ -0,0 +1,263 @@
# Content API 변경사항
## 📋 주요 변경사항 요약
### 1. **eventDraftId → eventId 타입 변경**
| 항목 | 기존 (Old) | 변경 (New) |
|------|-----------|-----------|
| 필드명 | `eventDraftId` | `eventId` |
| 타입 | `number` | `string` |
| 예시 | `7777` | `"7777"` |
---
## 🔄 영향을 받는 인터페이스
### GenerateImagesRequest
**Before:**
```typescript
interface GenerateImagesRequest {
eventDraftId: number;
eventTitle: string;
eventDescription: string;
industry?: string;
location?: string;
trends?: string[];
styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[];
platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[];
}
```
**After:**
```typescript
interface GenerateImagesRequest {
eventId: string; // Changed from eventDraftId: number
eventTitle: string;
eventDescription: string;
industry?: string;
location?: string;
trends?: string[];
styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[];
platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[];
}
```
### JobInfo
**Before:**
```typescript
interface JobInfo {
id: string;
eventDraftId: number;
jobType: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultMessage?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
```
**After:**
```typescript
interface JobInfo {
id: string;
eventId: string; // Changed from eventDraftId: number
jobType: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultMessage?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
```
### ImageInfo
**Before:**
```typescript
interface ImageInfo {
id: number;
eventDraftId: number;
style: 'SIMPLE' | 'FANCY' | 'TRENDY';
platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO';
cdnUrl: string;
prompt: string;
selected: boolean;
createdAt: string;
updatedAt: string;
}
```
**After:**
```typescript
interface ImageInfo {
id: number;
eventId: string; // Changed from eventDraftId: number
style: 'SIMPLE' | 'FANCY' | 'TRENDY';
platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO';
cdnUrl: string;
prompt: string;
selected: boolean;
createdAt: string;
updatedAt: string;
}
```
### ContentInfo
**Before:**
```typescript
interface ContentInfo {
id: number;
eventDraftId: number;
eventTitle: string;
eventDescription: string;
images: ImageInfo[];
createdAt: string;
updatedAt: string;
}
```
**After:**
```typescript
interface ContentInfo {
id: number;
eventId: string; // Changed from eventDraftId: number
eventTitle: string;
eventDescription: string;
images: ImageInfo[];
createdAt: string;
updatedAt: string;
}
```
---
## 📝 수정된 파일 목록
### 1. Type Definitions
- ✅ `src/shared/api/contentApi.ts`
- `GenerateImagesRequest` interface updated
- `JobInfo` interface updated
- `ImageInfo` interface updated
- `ContentInfo` interface updated
- API function signatures updated
### 2. Components
- ✅ `src/app/(main)/events/create/steps/ContentPreviewStep.tsx`
- `EventCreationData` interface: `eventDraftId: number``eventDraftId: string`
- Mock data updated to use string type
- API call updated: `eventDraftId``eventId`
### 3. Mock Data Files
- ✅ `public/init-mock-data.html`
- `eventDraftId: 7777``eventDraftId: "7777"`
- ✅ `MOCK_DATA_SETUP.md`
- All mock data examples updated to string type
- Documentation notes added about type change
### 4. API Routes (Next.js Proxy)
- ✅ `src/app/api/content/images/generate/route.ts` (no changes needed)
- ✅ `src/app/api/content/images/jobs/[jobId]/route.ts` (no changes needed)
- ✅ `src/app/api/content/events/[eventDraftId]/images/route.ts`
- Comment added about eventId parameter
---
## 🧪 테스트 예시
### API 요청 예시
**Before:**
```json
POST /api/v1/content/images/generate
{
"eventDraftId": 7777,
"eventTitle": "맥주 파티 이벤트",
"eventDescription": "강남에서 열리는 신나는 맥주 파티",
"industry": "음식점",
"location": "강남",
"trends": ["파티", "맥주", "생맥주"],
"styles": ["SIMPLE", "FANCY", "TRENDY"],
"platforms": ["INSTAGRAM"]
}
```
**After:**
```json
POST /api/v1/content/images/generate
{
"eventId": "7777",
"eventTitle": "맥주 파티 이벤트",
"eventDescription": "강남에서 열리는 신나는 맥주 파티",
"industry": "음식점",
"location": "강남",
"trends": ["파티", "맥주", "생맥주"],
"styles": ["SIMPLE", "FANCY", "TRENDY"],
"platforms": ["INSTAGRAM"]
}
```
### localStorage Mock 데이터
**Before:**
```javascript
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: 7777,
eventTitle: "맥주 파티 이벤트",
// ...
}));
```
**After:**
```javascript
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: "7777", // String type now
eventTitle: "맥주 파티 이벤트",
// ...
}));
```
---
## ✅ 마이그레이션 체크리스트
- [x] TypeScript 인터페이스 업데이트
- [x] API 호출 코드 수정
- [x] Mock 데이터 타입 변경
- [x] 문서 업데이트
- [x] 빌드 성공 확인
- [ ] 개발 서버 테스트
- [ ] 실제 API 연동 테스트
---
## 🔗 관련 API 문서
- Swagger UI: http://localhost:8084/swagger-ui/index.html
- OpenAPI Spec: http://localhost:8084/v3/api-docs
---
## 📌 주의사항
1. **타입 주의**: `eventId`는 이제 `string` 타입입니다. 숫자로 사용하지 마세요.
2. **Mock 데이터**: localStorage에 저장할 때 문자열 타입으로 저장해야 합니다.
3. **API 호출**: 프론트엔드에서 백엔드로 전송 시 string으로 전송됩니다.
4. **하위 호환성**: 기존 number 타입 데이터는 작동하지 않으므로 localStorage를 초기화해야 합니다.
---
## 🔄 롤백 방법
만약 이전 버전으로 돌아가야 한다면:
1. `git revert` 또는 특정 커밋으로 복원
2. localStorage 초기화: `localStorage.removeItem('eventCreationData')`
3. 개발 서버 재시작

221
CORS_FIX.md Normal file
View File

@ -0,0 +1,221 @@
# CORS 문제 해결 방법
## 문제 상황
프론트엔드(`http://localhost:3000`)에서 백엔드 Content API(`http://localhost:8084`)를 직접 호출하면 **CORS(Cross-Origin Resource Sharing)** 에러가 발생했습니다.
### 에러 메시지
```
Network Error
AxiosError: Network Error
code: "ERR_NETWORK"
```
### 원인 분석
```bash
# CORS preflight 요청 테스트
curl -X OPTIONS http://localhost:8084/api/v1/content/images/generate \
-H 'Origin: http://localhost:3000' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
# 결과: HTTP/1.1 403 Forbidden
# Invalid CORS request
```
백엔드 서버가 `http://localhost:3000` origin에서의 CORS 요청을 허용하지 않음.
---
## 해결 방법: Next.js API Proxy
백엔드 CORS 설정을 수정하는 대신, **Next.js API Routes를 프록시로 사용**하여 CORS 문제를 우회했습니다.
### 아키텍처
```
[Browser]
↓ (Same-Origin Request)
[Next.js Frontend: localhost:3000]
↓ [Next.js API Proxy: /api/content/*]
↓ (Server-to-Server Request, CORS 무관)
[Content API Backend: localhost:8084]
```
### 구현 파일
#### 1. **이미지 생성 프록시** (`/api/content/images/generate/route.ts`)
```typescript
export async function POST(request: NextRequest) {
const body = await request.json();
const response = await fetch('http://localhost:8084/api/v1/content/images/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return NextResponse.json(await response.json());
}
```
**URL 매핑**:
- Frontend: `POST /api/content/images/generate`
- Backend: `POST http://localhost:8084/api/v1/content/images/generate`
#### 2. **Job 상태 조회 프록시** (`/api/content/images/jobs/[jobId]/route.ts`)
```typescript
export async function GET(request: NextRequest, { params }: { params: { jobId: string } }) {
const { jobId } = params;
const response = await fetch(`http://localhost:8084/api/v1/content/images/jobs/${jobId}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return NextResponse.json(await response.json());
}
```
**URL 매핑**:
- Frontend: `GET /api/content/images/jobs/{jobId}`
- Backend: `GET http://localhost:8084/api/v1/content/images/jobs/{jobId}`
#### 3. **이미지 목록 조회 프록시** (`/api/content/events/[eventDraftId]/images/route.ts`)
```typescript
export async function GET(request: NextRequest, { params }: { params: { eventDraftId: string } }) {
const { eventDraftId } = params;
const { searchParams } = new URL(request.url);
let url = `http://localhost:8084/api/v1/content/events/${eventDraftId}/images`;
if (searchParams.get('style')) url += `?style=${searchParams.get('style')}`;
if (searchParams.get('platform')) url += `&platform=${searchParams.get('platform')}`;
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
return NextResponse.json(await response.json());
}
```
**URL 매핑**:
- Frontend: `GET /api/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM`
- Backend: `GET http://localhost:8084/api/v1/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM`
---
## 클라이언트 코드 변경
### Before (직접 백엔드 호출 - CORS 에러 발생)
```typescript
const CONTENT_API_BASE_URL = 'http://localhost:8084';
export const contentApiClient = axios.create({
baseURL: CONTENT_API_BASE_URL,
});
// ❌ CORS Error
await contentApiClient.post('/api/v1/content/images/generate', request);
```
### After (Next.js API Proxy 사용 - CORS 우회)
```typescript
const CONTENT_API_BASE_URL = '/api/content'; // Same-origin request
export const contentApiClient = axios.create({
baseURL: CONTENT_API_BASE_URL,
});
// ✅ Works! (Same-origin → Server-side proxy → Backend)
await contentApiClient.post('/images/generate', request);
```
---
## 장점
**프론트엔드 수정만으로 해결**: 백엔드 CORS 설정 변경 불필요
**Same-Origin 정책 준수**: 브라우저는 같은 도메인으로 인식
**서버 간 통신**: Next.js 서버에서 백엔드 호출 (CORS 무관)
**보안 강화**: 백엔드 URL을 클라이언트에 노출하지 않음
**환경 변수 활용**: `NEXT_PUBLIC_CONTENT_API_URL`로 배포 환경 대응
---
## 프로덕션 배포 시 고려사항
### 환경 변수 설정
```bash
# .env.local (개발 환경)
NEXT_PUBLIC_CONTENT_API_URL=http://localhost:8084
# .env.production (프로덕션 환경)
NEXT_PUBLIC_CONTENT_API_URL=https://api.production.com
```
### 프록시 코드에 적용
```typescript
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
```
### 타임아웃 설정
```typescript
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(120000), // 120초 타임아웃
});
```
---
## 테스트 방법
### 1. 개발 서버 실행
```bash
npm run dev
```
### 2. 브라우저에서 테스트
```
http://localhost:3000/events/create?event-creation.step=contentPreview
```
### 3. 네트워크 탭 확인
브라우저 개발자 도구 → Network 탭에서 다음 요청 확인:
```
POST http://localhost:3000/api/content/images/generate (Status: 202)
GET http://localhost:3000/api/content/images/jobs/job-xxxxx (Status: 200)
GET http://localhost:3000/api/content/events/7777/images (Status: 200)
```
모두 **Same-Origin** 요청이므로 CORS 에러 없음!
---
## 참고 자료
- [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
- [CORS (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS)
- [Proxy Pattern](https://en.wikipedia.org/wiki/Proxy_pattern)

182
FIX_EVENTID_MISMATCH.md Normal file
View File

@ -0,0 +1,182 @@
# EventId 불일치 문제 해결
## 문제 상황
사용자가 이미지 생성 페이지에서 스타일 1 카드에 이미지가 표시되지 않고 플레이스홀더만 보이는 문제가 발생했습니다.
### 스크린샷 분석
- **스타일 1 (SIMPLE)**: 플레이스홀더만 표시 (아이콘 + 제목 + 경품)
- **스타일 2 (FANCY)**: 실제 이미지 표시 ✅
- **스타일 3 (TRENDY)**: 실제 이미지 표시 ✅
## 근본 원인
API 분석 결과, 데이터베이스에 저장된 이미지와 Mock 데이터의 eventId가 일치하지 않았습니다:
```bash
# Mock 데이터 eventId
"7777"
# 데이터베이스 실제 eventId
curl http://localhost:8084/api/v1/content/events/7777/images
→ Response: [] (빈 배열)
# 데이터베이스에 존재하는 eventId
- "Tst12131": SIMPLE 이미지 1개
- "1761634317010": SIMPLE, FANCY, TRENDY 각 2개씩 총 6개
- null: SIMPLE 이미지 1개
```
**결론**: Mock 데이터의 eventId "7777"로는 어떤 이미지도 조회되지 않았습니다.
## 해결 방법
데이터베이스에 이미 존재하는 이미지가 있는 eventId로 Mock 데이터를 변경했습니다.
### 변경된 eventId
```javascript
// Before
eventDraftId: "7777"
// After
eventDraftId: "1761634317010"
```
**선택 이유**:
- SIMPLE, FANCY, TRENDY 3가지 스타일 모두 이미지 보유
- 각 스타일별로 2개씩 총 6개의 이미지 존재
- INSTAGRAM 플랫폼 이미지 존재
## 수정된 파일
### 1. ContentPreviewStep.tsx
**위치**: `src/app/(main)/events/create/steps/ContentPreviewStep.tsx:109`
```typescript
const mockData: EventCreationData = {
eventDraftId: "1761634317010", // Changed from "7777"
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
};
```
### 2. init-mock-data.html
**위치**: `public/init-mock-data.html:121`, `public/init-mock-data.html:168`
```html
<!-- UI 표시 -->
<span id="eventId">1761634317010</span>
<!-- JavaScript 데이터 -->
<script>
const mockEventData = {
eventDraftId: "1761634317010", // Changed from "7777"
// ...
};
</script>
```
### 3. QUICK_TEST.md
**위치**: `QUICK_TEST.md` (전체 문서)
- Mock 데이터 예시의 eventId 변경
- API 확인 예시의 eventId 변경
- 디버깅 로그 예시 업데이트
### 4. MOCK_DATA_SETUP.md
**위치**: `MOCK_DATA_SETUP.md` (전체 문서)
- Mock 데이터 구조 예시 업데이트
- 테스트 시나리오 eventId 변경
- 참고 사항 추가: "1761634317010은 데이터베이스에 이미 생성된 이미지가 있는 eventId"
## 빌드 검증
```bash
npm run build
```
**성공**: TypeScript 타입 검증 통과, 빌드 완료
경고 사항:
- `loadingProgress`, `setLoadingProgress` 미사용 변수 (기능에 영향 없음)
- 기타 ESLint 경고 (기존 코드, 금번 수정과 무관)
## 테스트 방법
### 1. localStorage 초기화
브라우저 콘솔에서 기존 데이터 삭제:
```javascript
localStorage.removeItem('eventCreationData');
```
### 2. 개발 서버 실행
```bash
npm run dev
```
### 3. 페이지 접속
```
http://localhost:3000/events/create?event-creation.step=contentPreview
```
### 4. 예상 결과
- ✅ 스타일 1 (SIMPLE): 실제 이미지 표시
- ✅ 스타일 2 (FANCY): 실제 이미지 표시
- ✅ 스타일 3 (TRENDY): 실제 이미지 표시
- ✅ 3개 스타일 모두 "크게보기" 버튼 활성화
### 5. 콘솔 로그 확인
```
📥 Loading generated images for event: 1761634317010
✅ Images loaded from API: 6 [...]
📸 Processing image 1: { id: X, style: 'SIMPLE', ... }
✅ Selected as latest SIMPLE image
📸 Processing image 2: { id: Y, style: 'FANCY', ... }
✅ Selected as latest FANCY image
📸 Processing image 3: { id: Z, style: 'TRENDY', ... }
✅ Selected as latest TRENDY image
🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 }
✅ 이미지 로드 완료!
🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, ... }
✅ SIMPLE image loaded successfully
```
## 추가 참고 사항
### 새로운 이벤트 테스트 시
새로운 eventId로 이미지를 생성하려면:
1. localStorage에 새로운 eventId 설정
2. "이미지 재생성" 버튼 클릭
3. 약 2초 후 자동으로 생성된 이미지 로드
### Mock 데이터 변경 방법
`public/init-mock-data.html` 페이지 사용:
```
http://localhost:3000/init-mock-data.html
```
또는 브라우저 콘솔에서 직접 설정:
```javascript
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: "1761634317010",
eventTitle: "...",
// ...
}));
```
## 결론
EventId 불일치 문제를 해결하여 모든 스타일 카드에서 실제 이미지가 정상적으로 표시됩니다.
**핵심 변경**: Mock 데이터의 eventId를 데이터베이스에 존재하는 "1761634317010"으로 변경
**영향 범위**:
- 개발/테스트 환경의 Mock 데이터만 영향
- 실제 운영 환경에서는 Channel Step API에서 제공하는 실제 eventId 사용
- 코드 로직 변경 없음, 데이터만 변경

149
MOCK_DATA_SETUP.md Normal file
View File

@ -0,0 +1,149 @@
# Mock 데이터 설정 가이드
AI 이미지 생성 기능을 테스트하기 위해 localStorage에 mock 데이터를 설정하는 방법입니다.
## 🚀 빠른 시작
### 방법 1: 웹 인터페이스 사용 (권장)
1. 개발 서버 실행
```bash
npm run dev
```
2. 브라우저에서 mock 데이터 초기화 페이지 열기
```
http://localhost:3000/init-mock-data.html
```
3. "LocalStorage에 저장하기" 버튼 클릭
4. 이미지 생성 페이지로 이동
```
http://localhost:3000/events/create?step=contentPreview
```
### 방법 2: 브라우저 콘솔 사용
1. 개발 서버 실행 후 브라우저에서 아무 페이지나 열기
2. F12 또는 Cmd+Option+I로 개발자 도구 열기
3. Console 탭에서 다음 코드 실행:
```javascript
const mockEventData = {
eventDraftId: "1761634317010", // String type (existing eventId with images)
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
};
localStorage.setItem('eventCreationData', JSON.stringify(mockEventData));
console.log('✅ Mock 데이터 저장 완료!');
```
4. 이미지 생성 페이지로 이동
### 방법 3: 테스트 HTML 파일 사용
프로젝트 루트의 `test-localstorage.html` 파일을 브라우저에서 직접 열기:
```bash
open test-localstorage.html
```
## 📊 Mock 데이터 구조
```json
{
"eventDraftId": "1761634317010",
"eventTitle": "맥주 파티 이벤트",
"eventDescription": "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
"industry": "음식점",
"location": "강남",
"trends": ["파티", "맥주", "생맥주"],
"prize": "생맥주 1잔"
}
```
**참고**:
- `eventDraftId`는 API 변경으로 인해 `string` 타입입니다.
- `"1761634317010"`은 데이터베이스에 이미 생성된 이미지가 있는 eventId입니다.
## 🧪 테스트 시나리오
### 시나리오 1: 전체 이미지 생성 플로우
1. Mock 데이터 설정
2. `/events/create?step=contentPreview` 접속
3. 자동으로 AI 이미지 생성 시작
4. 3가지 스타일(SIMPLE, FANCY, TRENDY) 확인
5. 스타일 선택 후 다음 단계 진행
### 시나리오 2: 다양한 이벤트 데이터 테스트
다른 업종/지역/트렌드로 테스트:
```javascript
// 카페 이벤트 (새로운 이미지 생성 필요)
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: "test-cafe-001",
eventTitle: "커피 할인 이벤트",
eventDescription: "신메뉴 출시 기념 30% 할인",
industry: "카페",
location: "홍대",
trends: ["커피", "할인", "신메뉴"],
prize: "아메리카노 1잔"
}));
```
```javascript
// 뷰티 이벤트 (새로운 이미지 생성 필요)
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: "test-beauty-001",
eventTitle: "봄맞이 피부관리 이벤트",
eventDescription: "봄맞이 특별 케어 프로그램",
industry: "뷰티",
location: "강남",
trends: ["피부관리", "봄", "케어"],
prize: "페이셜 케어 1회"
}));
```
## 🔍 디버깅
### localStorage 데이터 확인
```javascript
// 현재 저장된 데이터 확인
const data = localStorage.getItem('eventCreationData');
console.log(JSON.parse(data));
```
### localStorage 데이터 삭제
```javascript
localStorage.removeItem('eventCreationData');
console.log('✅ 데이터 삭제 완료');
```
## ⚠️ 주의사항
1. **같은 도메인**: localStorage는 도메인별로 분리되므로, 같은 localhost:3000에서 설정해야 합니다.
2. **브라우저 제한**: 시크릿 모드에서는 localStorage가 제한될 수 있습니다.
3. **데이터 유지**: 브라우저를 닫아도 localStorage 데이터는 유지됩니다. 새로운 테스트 시 삭제 후 진행하세요.
## 🎯 실제 API 연동 후
Channel Step API가 구현되면 이 mock 데이터 설정은 불필요하며,
실제 플로우에서 자동으로 데이터가 저장됩니다:
```
Objective → Recommendation → Channel (여기서 localStorage 저장) → ContentPreview (이미지 생성)
```

145
QUICK_TEST.md Normal file
View File

@ -0,0 +1,145 @@
# 🚀 AI 이미지 생성 빠른 테스트 가이드
## ⚡ 가장 빠른 방법 (기존 이미지 확인)
```bash
# 1. 개발 서버 실행
npm run dev
# 2. 브라우저에서 바로 접속
http://localhost:3000/events/create?event-creation.step=contentPreview
```
**끝!** 자동으로 Mock 데이터(eventId: "1761634317010")가 생성되고 기존 생성된 이미지를 불러옵니다.
💡 **이미지가 없을 경우**: "이미지 생성하기" 버튼을 클릭하면 새로운 이미지를 생성합니다.
---
## 📋 커스텀 데이터로 테스트 (선택사항)
### 1단계: 개발 서버 실행
```bash
npm run dev
```
### 2단계: Mock 데이터 설정 (3가지 방법 중 선택)
### ✨ 방법 A: 웹 UI 사용 (가장 쉬움!)
브라우저에서 열기:
```
http://localhost:3000/init-mock-data.html
```
"LocalStorage에 저장하기" 버튼 클릭 → 완료!
---
### 방법 B: 브라우저 콘솔 사용
1. `http://localhost:3000` 접속
2. F12 (개발자 도구) → Console 탭
3. 다음 코드 복사 & 붙여넣기:
```javascript
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: "1761634317010",
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
}));
```
---
### 방법 C: HTML 파일 직접 열기
```bash
open test-localstorage.html
```
## 3단계: 이미지 생성 페이지 접속
브라우저에서 열기:
```
http://localhost:3000/events/create?event-creation.step=contentPreview
```
## 4단계: 자동 실행 확인 ✅
1. 페이지 로딩되면 자동으로 이미지 생성 시작
2. 로딩 스피너와 진행률 확인
3. 약 60초 후 3가지 스타일 이미지 완성
- 스타일 1: 심플
- 스타일 2: 화려
- 스타일 3: 트렌디
## 예상 결과
### 이미지가 있는 경우
- ✅ **즉시 표시**: 로딩 후 바로 이미지 미리보기 화면
- ✅ **3개 스타일 이미지**: SIMPLE, FANCY, TRENDY 각각 최신 이미지 표시
- ✅ **이미지 선택**: 라디오 버튼으로 원하는 스타일 선택
- ✅ **재생성 버튼**: "이미지 재생성" 버튼으로 새로운 이미지 생성 가능
- ✅ **크게보기**: 각 이미지 클릭 시 전체화면 미리보기
### 이미지가 없는 경우
- ⚠️ **에러 메시지**: "생성된 이미지가 없습니다. 이미지를 먼저 생성해주세요."
- 🔄 **생성 버튼**: "이미지 생성하기" 버튼 클릭
- ⏳ **생성 대기**: API 요청 후 2초 뒤 자동으로 이미지 조회
- ✅ **이미지 표시**: 생성 완료된 이미지 자동 표시
## 문제 해결
### ~~"이벤트 정보를 찾을 수 없습니다" 에러~~
→ ✅ **해결됨!** 이제 자동으로 Mock 데이터가 생성됩니다.
### ~~Network Error / CORS 에러~~
→ ✅ **해결됨!** Next.js API proxy를 통해 CORS 문제 우회
→ 프론트엔드가 `/api/content/*` → 백엔드 `localhost:8084` 로 자동 프록시
### 이미지 생성 실패
→ Content API (localhost:8084) 실행 여부 확인
→ 터미널에서 확인: `curl http://localhost:8084/api/v1/content/events/7777/images`
### 이미지가 표시되지 않음
→ 네트워크 탭에서 CDN URL 로드 상태 확인
→ Azure Blob Storage 접근 권한 확인
## API 확인
```bash
# 이벤트 1761634317010의 이미지 확인
curl http://localhost:8084/api/v1/content/events/1761634317010/images
# 프론트엔드 프록시를 통한 확인 (개발 서버 실행 중)
curl http://localhost:3000/api/content/events/1761634317010/images
```
## 디버깅
브라우저 개발자 도구 (F12) → Console 탭에서 다음 로그 확인:
```
📥 Loading generated images for event: 1761634317010
✅ Images loaded from API: 6 [...]
📸 Processing image 1: { id: 1, style: 'SIMPLE', platform: 'INSTAGRAM', ... }
✅ Selected as latest SIMPLE image
📸 Processing image 2: { id: 3, style: 'FANCY', platform: 'INSTAGRAM', ... }
✅ Selected as latest FANCY image
📸 Processing image 3: { id: 5, style: 'TRENDY', platform: 'INSTAGRAM', ... }
✅ Selected as latest TRENDY image
🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 }
✅ 이미지 로드 완료! 미리보기 화면으로 전환합니다.
🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, cdnUrl: 'https://blob...' }
✅ SIMPLE image loaded successfully
```
---
더 자세한 내용은 `MOCK_DATA_SETUP.md` 참조

117
TEST_URLS.md Normal file
View File

@ -0,0 +1,117 @@
# 🔗 AI 이미지 생성 테스트 URL 가이드
## ✅ 올바른 URL
### ContentPreview Step 직접 접속
```
http://localhost:3000/events/create?event-creation.step=contentPreview
```
또는 간단하게:
```
http://localhost:3000/events/create?step=contentPreview
```
### Mock 데이터 설정 페이지
```
http://localhost:3000/init-mock-data.html
```
## 📝 URL 파라미터 설명
- `event-creation.step=contentPreview` - Funnel의 step을 contentPreview로 설정
- `step=contentPreview` - 간단한 형식 (funnel id가 event-creation일 때)
## 🎯 전체 플로우 테스트 URL
### 1. 시작 (Objective Step)
```
http://localhost:3000/events/create
```
### 2. Channel Step까지 진행 후
```
http://localhost:3000/events/create?event-creation.step=channel
```
### 3. ContentPreview Step (이미지 생성)
```
http://localhost:3000/events/create?event-creation.step=contentPreview
```
## 💡 자동 Mock 데이터 생성
이제 `contentPreview` 페이지에 직접 접속하면:
1. ✅ localStorage 확인
2. ✅ 데이터 없으면 자동으로 Mock 데이터 생성
3. ✅ 즉시 AI 이미지 생성 시작
**더 이상 수동으로 Mock 데이터를 설정할 필요가 없습니다!**
## 🧪 테스트 시나리오
### 시나리오 1: 가장 빠른 테스트
```bash
# 1. 개발 서버 실행
npm run dev
# 2. 브라우저에서 바로 접속
http://localhost:3000/events/create?event-creation.step=contentPreview
```
→ 자동으로 Mock 데이터 생성 & 이미지 생성 시작!
### 시나리오 2: 커스텀 데이터로 테스트
```bash
# 1. Mock 데이터 설정 페이지 열기
http://localhost:3000/init-mock-data.html
# 2. 원하는 데이터 입력 후 저장
# 3. ContentPreview 접속
http://localhost:3000/events/create?event-creation.step=contentPreview
```
### 시나리오 3: 전체 플로우 테스트
```bash
# 1. 처음부터 시작
http://localhost:3000/events/create
# 2. Objective 선택
# 3. Recommendation 확인
# 4. Channel 선택 (SNS, 우리동네TV, 지니TV 중 하나)
# 5. 자동으로 ContentPreview로 이동하며 이미지 생성 시작
```
## 🐛 문제 해결
### "이벤트 정보를 찾을 수 없습니다" 에러
→ 이제 이 에러는 발생하지 않습니다! 자동으로 Mock 데이터가 생성됩니다.
### 이미지 생성 실패
```bash
# Content API 서버 확인
curl http://localhost:8084/api/v1/content/events/7777/images
# API 서버가 꺼져있다면 실행 필요
```
### 다른 이벤트 ID로 테스트하고 싶을 때
```javascript
// 브라우저 콘솔에서
localStorage.setItem('eventCreationData', JSON.stringify({
eventDraftId: 8888, // 다른 ID
eventTitle: "커피 할인 이벤트",
eventDescription: "신메뉴 출시 기념",
industry: "카페",
location: "홍대",
trends: ["커피", "할인"],
prize: "아메리카노 1잔"
}));
// 페이지 새로고침
location.reload();
```

View File

@ -6,12 +6,21 @@ const nextConfig = {
emotion: true,
},
images: {
domains: ['localhost'],
domains: ['localhost', 'blobkteventstorage.blob.core.windows.net'],
formats: ['image/webp', 'image/avif'],
},
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
},
// CORS 우회를 위한 API Proxy 설정
async rewrites() {
return [
{
source: '/api/proxy/:path*',
destination: 'http://localhost:8084/api/:path*',
},
]
},
}
module.exports = nextConfig

8
package-lock.json generated
View File

@ -17,7 +17,7 @@
"@tanstack/react-query": "^5.59.16",
"@use-funnel/browser": "^0.0.12",
"@use-funnel/next": "^0.0.12",
"axios": "^1.7.7",
"axios": "^1.13.0",
"chart.js": "^4.5.1",
"dayjs": "^1.11.13",
"next": "^14.2.15",
@ -2122,9 +2122,9 @@
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",

View File

@ -19,7 +19,7 @@
"@tanstack/react-query": "^5.59.16",
"@use-funnel/browser": "^0.0.12",
"@use-funnel/next": "^0.0.12",
"axios": "^1.7.7",
"axios": "^1.13.0",
"chart.js": "^4.5.1",
"dayjs": "^1.11.13",
"next": "^14.2.15",

205
public/init-mock-data.html Normal file
View File

@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mock 데이터 초기화</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
background: white;
border-radius: 16px;
padding: 40px;
box-shadow: 0 8px 32px rgba(0,0,0,0.2);
max-width: 500px;
text-align: center;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.data-box {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 20px;
text-align: left;
margin-bottom: 25px;
}
.data-box h3 {
margin: 0 0 15px 0;
color: #374151;
font-size: 16px;
}
.data-item {
margin-bottom: 10px;
font-size: 14px;
}
.data-item strong {
color: #4b5563;
display: inline-block;
width: 120px;
}
.data-item span {
color: #6b7280;
}
button {
background: linear-gradient(135deg, #C084FC 0%, #F472B6 100%);
color: white;
border: none;
padding: 14px 32px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
width: 100%;
}
button:hover {
transform: translateY(-2px);
}
button:active {
transform: translateY(0);
}
.success {
background: #10b981;
color: white;
padding: 15px;
border-radius: 8px;
margin-top: 20px;
display: none;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.link {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
.link a {
color: #667eea;
text-decoration: none;
font-weight: 500;
}
.link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 Mock 데이터 초기화</h1>
<p class="subtitle">이벤트 생성 테스트를 위한 샘플 데이터</p>
<div class="data-box">
<h3>📋 저장될 데이터</h3>
<div class="data-item">
<strong>이벤트 ID:</strong>
<span id="eventId">1761634317010</span>
</div>
<div class="data-item">
<strong>제목:</strong>
<span id="title">맥주 파티 이벤트</span>
</div>
<div class="data-item">
<strong>설명:</strong>
<span id="description">강남에서 열리는 신나는 맥주 파티</span>
</div>
<div class="data-item">
<strong>업종:</strong>
<span id="industry">음식점</span>
</div>
<div class="data-item">
<strong>지역:</strong>
<span id="location">강남</span>
</div>
<div class="data-item">
<strong>트렌드:</strong>
<span id="trends">파티, 맥주, 생맥주</span>
</div>
<div class="data-item">
<strong>경품:</strong>
<span id="prize">생맥주 1잔</span>
</div>
</div>
<button onclick="setupMockData()">
💾 LocalStorage에 저장하기
</button>
<div id="success" class="success">
✅ Mock 데이터가 성공적으로 저장되었습니다!<br>
이제 이벤트 생성 페이지로 이동할 수 있습니다.
</div>
<div class="link">
<a href="/events/create?step=contentPreview" target="_blank">
🚀 이미지 생성 페이지 열기 →
</a>
</div>
</div>
<script>
function setupMockData() {
const mockEventData = {
eventDraftId: "1761634317010",
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
};
// localStorage에 저장
localStorage.setItem('eventCreationData', JSON.stringify(mockEventData));
// 성공 메시지 표시
const successDiv = document.getElementById('success');
successDiv.style.display = 'block';
// 콘솔에 로그
console.log('✅ Mock 데이터가 localStorage에 저장되었습니다:');
console.log(JSON.stringify(mockEventData, null, 2));
// 5초 후 메시지 숨기기
setTimeout(() => {
successDiv.style.display = 'none';
}, 5000);
}
// 페이지 로드 시 현재 localStorage 확인
window.onload = function() {
const existingData = localStorage.getItem('eventCreationData');
if (existingData) {
console.log('📦 기존 localStorage 데이터:', JSON.parse(existingData));
} else {
console.log('📭 localStorage가 비어있습니다.');
}
};
</script>
</body>
</html>

17
setup-mock-data.js Normal file
View File

@ -0,0 +1,17 @@
// Mock 데이터를 localStorage에 저장하는 스크립트
const mockEventData = {
eventDraftId: 7777,
eventTitle: "맥주 파티 이벤트",
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!",
industry: "음식점",
location: "강남",
trends: ["파티", "맥주", "생맥주"],
prize: "생맥주 1잔"
};
// localStorage에 저장
localStorage.setItem('eventCreationData', JSON.stringify(mockEventData));
console.log('✅ Mock 데이터가 localStorage에 저장되었습니다:');
console.log(JSON.stringify(mockEventData, null, 2));

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
@ -17,6 +17,8 @@ import {
DialogActions,
IconButton,
Grid,
Alert,
CircularProgress,
} from '@mui/material';
import {
EventNote,
@ -25,12 +27,13 @@ import {
Download,
Refresh,
Notifications,
List as ListIcon,
Add,
Remove,
Info,
People,
} from '@mui/icons-material';
import { drawWinners, getWinners, getParticipants } from '@/shared/api/participation.api';
import type { DrawWinnersResponse } from '@/shared/types/api.types';
// 디자인 시스템 색상
const colors = {
@ -50,39 +53,19 @@ const colors = {
},
};
// Mock 데이터
const mockEventData = {
name: '신규고객 유치 이벤트',
totalParticipants: 127,
participants: [
{ id: '00042', name: '김**', phone: '010-****-1234', channel: '우리동네TV', hasBonus: true },
{ id: '00089', name: '이**', phone: '010-****-5678', channel: 'SNS', hasBonus: false },
{ id: '00103', name: '박**', phone: '010-****-9012', channel: '링고비즈', hasBonus: true },
{ id: '00012', name: '최**', phone: '010-****-3456', channel: 'SNS', hasBonus: false },
{ id: '00067', name: '정**', phone: '010-****-7890', channel: '우리동네TV', hasBonus: false },
{ id: '00025', name: '강**', phone: '010-****-2468', channel: '링고비즈', hasBonus: true },
{ id: '00078', name: '조**', phone: '010-****-1357', channel: 'SNS', hasBonus: false },
],
};
const mockDrawingHistory = [
{ date: '2025-01-15 14:30', winnerCount: 5, isRedraw: false },
{ date: '2025-01-15 14:25', winnerCount: 5, isRedraw: true },
];
interface Winner {
id: string;
participantId: string;
name: string;
phone: string;
channel: string;
hasBonus: boolean;
phoneNumber: string;
rank: number;
channel?: string;
storeVisited: boolean;
}
export default function DrawPage() {
const params = useParams();
const router = useRouter();
// eventId will be used for API calls in future
const _eventId = params.eventId as string;
const eventId = params.eventId as string;
// State
const [winnerCount, setWinnerCount] = useState(5);
@ -95,8 +78,60 @@ export default function DrawPage() {
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [redrawDialogOpen, setRedrawDialogOpen] = useState(false);
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false);
const [historyDetailOpen, setHistoryDetailOpen] = useState(false);
const [selectedHistory, setSelectedHistory] = useState<typeof mockDrawingHistory[0] | null>(null);
// API 관련 상태
const [totalParticipants, setTotalParticipants] = useState(0);
const [eventName] = useState('이벤트');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [drawResult, setDrawResult] = useState<DrawWinnersResponse | null>(null);
// 초기 데이터 로드
useEffect(() => {
const loadInitialData = async () => {
try {
setLoading(true);
setError(null);
// 참여자 총 수 조회
const participantsResponse = await getParticipants({
eventId,
page: 0,
size: 1,
});
setTotalParticipants(participantsResponse.data.totalElements);
// 기존 당첨자가 있는지 확인
try {
const winnersResponse = await getWinners(eventId, 0, 100);
if (winnersResponse.data.content.length > 0) {
// 당첨자가 있으면 결과 화면 표시
const winnerList: Winner[] = winnersResponse.data.content.map((p) => ({
participantId: p.participantId,
name: p.name,
phoneNumber: p.phoneNumber,
rank: 0, // rank는 순서대로
channel: p.channel,
storeVisited: p.storeVisited,
}));
setWinners(winnerList);
setShowResults(true);
}
} catch {
// 당첨자가 없으면 무시
console.log('No winners yet');
}
} catch (err) {
console.error('Failed to load initial data:', err);
setError('데이터를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
loadInitialData();
}, [eventId]);
const handleDecrease = () => {
if (winnerCount > 1) {
@ -105,7 +140,7 @@ export default function DrawPage() {
};
const handleIncrease = () => {
if (winnerCount < 100 && winnerCount < mockEventData.totalParticipants) {
if (winnerCount < 100 && winnerCount < totalParticipants) {
setWinnerCount(winnerCount + 1);
}
};
@ -114,34 +149,54 @@ export default function DrawPage() {
setConfirmDialogOpen(true);
};
const executeDrawing = () => {
const executeDrawing = async () => {
setConfirmDialogOpen(false);
setIsDrawing(true);
setError(null);
try {
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
setAnimationText('당첨자 선정 중...');
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
}, 1000);
// 실제 API 호출
const response = await drawWinners(eventId, winnerCount, storeBonus);
setDrawResult(response.data);
// Phase 2: 완료 (2 seconds)
setTimeout(() => {
setAnimationText('완료!');
setAnimationSubtext('추첨이 완료되었습니다');
}, 2000);
// Phase 3: Show results (3 seconds)
// Phase 3: 당첨자 목록 변환 및 표시
setTimeout(() => {
setIsDrawing(false);
const winnerList: Winner[] = response.data.winners.map((w) => ({
participantId: w.participantId,
name: w.name,
phoneNumber: w.phoneNumber,
rank: w.rank,
storeVisited: false, // API 응답에 포함되지 않음
}));
// Select random winners
const shuffled = [...mockEventData.participants].sort(() => Math.random() - 0.5);
setWinners(shuffled.slice(0, winnerCount));
setWinners(winnerList);
setIsDrawing(false);
setShowResults(true);
}, 3000);
} catch (err) {
console.error('Draw failed:', err);
setIsDrawing(false);
const errorMessage =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
setError(errorMessage || '추첨에 실패했습니다. 다시 시도해주세요.');
}
};
const handleRedraw = () => {
const handleRedraw = async () => {
setRedrawDialogOpen(false);
setShowResults(false);
setWinners([]);
@ -161,7 +216,7 @@ export default function DrawPage() {
const handleDownload = () => {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
const filename = `당첨자목록_${mockEventData.name}_${dateStr}.xlsx`;
const filename = `당첨자목록_${eventName}_${dateStr}.xlsx`;
alert(`${filename} 다운로드를 시작합니다`);
};
@ -169,11 +224,6 @@ export default function DrawPage() {
router.push('/events');
};
const handleHistoryDetail = (history: typeof mockDrawingHistory[0]) => {
setSelectedHistory(history);
setHistoryDetailOpen(true);
};
const getRankClass = (rank: number) => {
if (rank === 1) return 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
if (rank === 2) return 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)';
@ -181,9 +231,32 @@ export default function DrawPage() {
return '#e0e0e0';
};
// 로딩 상태
if (loading) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={60} />
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
{/* 에러 메시지 */}
{error && (
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Setup View (Before Drawing) */}
{!showResults && (
<>
@ -214,7 +287,7 @@ export default function DrawPage() {
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
{mockEventData.name}
{eventName}
</Typography>
</CardContent>
</Card>
@ -234,7 +307,7 @@ export default function DrawPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{mockEventData.totalParticipants}
{totalParticipants}
</Typography>
</CardContent>
</Card>
@ -372,65 +445,6 @@ export default function DrawPage() {
</Button>
{/* Drawing History */}
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
📜
</Typography>
{mockDrawingHistory.length === 0 ? (
<Card elevation={0} sx={{ borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<ListIcon sx={{ fontSize: 64, color: colors.gray[300], mb: 2 }} />
<Typography variant="h6" color="text.secondary">
</Typography>
</CardContent>
</Card>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{mockDrawingHistory.slice(0, 3).map((history, index) => (
<Card
key={index}
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: 5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 700 }}>
{history.date} {history.isRedraw && '(재추첨)'}
</Typography>
<Typography variant="body1" color="text.secondary">
{history.winnerCount}
</Typography>
</Box>
<Button
variant="outlined"
size="medium"
onClick={() => handleHistoryDetail(history)}
startIcon={<ListIcon />}
sx={{
borderRadius: 3,
borderColor: colors.purple,
color: colors.purple,
'&:hover': {
borderColor: colors.purple,
bgcolor: colors.purpleLight,
},
}}
>
</Button>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</>
)}
@ -443,8 +457,13 @@ export default function DrawPage() {
🎉 !
</Typography>
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
{mockEventData.totalParticipants} {winnerCount}
{totalParticipants} {winners.length}
</Typography>
{drawResult && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
: {new Date(drawResult.drawnAt).toLocaleString('ko-KR')}
</Typography>
)}
</Box>
{/* Winner List */}
@ -453,11 +472,10 @@ export default function DrawPage() {
🏆
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{winners.map((winner, index) => {
const rank = index + 1;
{winners.map((winner) => {
return (
<Card
key={winner.id}
key={winner.participantId}
elevation={0}
sx={{
borderRadius: 4,
@ -471,7 +489,7 @@ export default function DrawPage() {
width: 64,
height: 64,
borderRadius: '50%',
background: getRankClass(rank),
background: getRankClass(winner.rank),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -481,19 +499,21 @@ export default function DrawPage() {
flexShrink: 0,
}}
>
{rank}
{winner.rank}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
: #{winner.id}
ID: {winner.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
{winner.name} ({winner.phone})
{winner.name} ({winner.phoneNumber})
</Typography>
{winner.channel && (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
: {winner.channel}{' '}
{winner.hasBonus && storeBonus && '🌟'}
{winner.storeVisited && storeBonus && '🌟'}
</Typography>
)}
</Box>
</Box>
</CardContent>
@ -671,8 +691,13 @@ export default function DrawPage() {
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
<Typography variant="body1" sx={{ textAlign: 'center', fontSize: '1.125rem' }}>
{mockEventData.totalParticipants} {winnerCount} ?
{totalParticipants} {winnerCount} ?
</Typography>
{storeBonus && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
1.5 .
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
<Button
@ -825,56 +850,6 @@ export default function DrawPage() {
</DialogActions>
</Dialog>
{/* History Detail Dialog */}
<Dialog
open={historyDetailOpen}
onClose={() => setHistoryDetailOpen(false)}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { borderRadius: 4 } }}
>
<DialogTitle sx={{ pt: 6, px: 6, pb: 4, fontSize: '1.5rem', fontWeight: 700 }}>
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
{selectedHistory && (
<Box sx={{ p: 4 }}>
<Typography variant="body1" sx={{ mb: 3, fontSize: '1.125rem' }}>
: {selectedHistory.date}
</Typography>
<Typography variant="body1" sx={{ mb: 3, fontSize: '1.125rem' }}>
: {selectedHistory.winnerCount}
</Typography>
<Typography variant="body1" sx={{ mb: 4, fontSize: '1.125rem' }}>
: {selectedHistory.isRedraw ? '예' : '아니오'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4 }}>
<Button
onClick={() => setHistoryDetailOpen(false)}
variant="contained"
fullWidth
sx={{
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
</DialogActions>
</Dialog>
</Container>
</Box>
);

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
@ -22,6 +22,8 @@ import {
DialogContent,
DialogActions,
Grid,
CircularProgress,
Alert,
} from '@mui/material';
import {
Search,
@ -32,7 +34,10 @@ import {
TrendingUp,
Person,
AccessTime,
Error as ErrorIcon,
} from '@mui/icons-material';
import { getParticipants } from '@/shared/api/participation.api';
import type { ParticipationResponse } from '@/shared/types/api.types';
// 디자인 시스템 색상
const colors = {
@ -52,115 +57,99 @@ const colors = {
},
};
// Mock 데이터
const mockParticipants = [
{
id: '0001',
name: '김**',
phone: '010-****-1234',
channel: 'SNS (Instagram)',
channelType: 'sns',
date: '2025-11-02 14:23',
status: 'waiting' as const,
},
{
id: '0002',
name: '이**',
phone: '010-****-5678',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 15:45',
status: 'waiting' as const,
},
{
id: '0003',
name: '박**',
phone: '010-****-9012',
channel: '링고비즈',
channelType: 'ringoBiz',
date: '2025-11-02 16:12',
status: 'waiting' as const,
},
{
id: '0004',
name: '최**',
phone: '010-****-3456',
channel: 'SNS (Naver)',
channelType: 'sns',
date: '2025-11-02 17:30',
status: 'winner' as const,
},
{
id: '0005',
name: '정**',
phone: '010-****-7890',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 18:15',
status: 'loser' as const,
},
];
type ChannelType = 'all' | 'uriTV' | 'ringoBiz' | 'sns';
type StatusType = 'all' | 'waiting' | 'winner' | 'loser';
type StatusType = 'all' | 'winner' | 'waiting';
export default function ParticipantsPage() {
const params = useParams();
const router = useRouter();
const eventId = params.eventId as string;
const [searchTerm, setSearchTerm] = useState('');
const [channelFilter, setChannelFilter] = useState<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<StatusType>('all');
const [currentPage, setCurrentPage] = useState(1);
const [selectedParticipant, setSelectedParticipant] = useState<typeof mockParticipants[0] | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
// 데이터 상태
const [participants, setParticipants] = useState<ParticipationResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// 필터 상태
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusType>('all');
const [storeVisitedFilter, setStoreVisitedFilter] = useState<boolean | undefined>(undefined);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalElements, setTotalElements] = useState(0);
const itemsPerPage = 20;
// 필터링
const filteredParticipants = mockParticipants.filter((participant) => {
const matchesSearch =
participant.name.includes(searchTerm) || participant.phone.includes(searchTerm);
const matchesChannel = channelFilter === 'all' || participant.channelType === channelFilter;
const matchesStatus = statusFilter === 'all' || participant.status === statusFilter;
// UI 상태
const [selectedParticipant, setSelectedParticipant] = useState<ParticipationResponse | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
return matchesSearch && matchesChannel && matchesStatus;
// 데이터 로드
const loadParticipants = async () => {
try {
setLoading(true);
setError('');
const response = await getParticipants({
eventId,
storeVisited: storeVisitedFilter,
page: currentPage - 1, // API는 0부터 시작
size: itemsPerPage,
sort: ['createdAt,DESC'],
});
// 페이지네이션
const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, filteredParticipants.length);
const pageParticipants = filteredParticipants.slice(startIndex, endIndex);
const getStatusColor = (status: string) => {
switch (status) {
case 'waiting':
return 'default';
case 'winner':
return 'success';
case 'loser':
return 'error';
default:
return 'default';
if (response.success && response.data) {
setParticipants(response.data.content);
setTotalPages(response.data.totalPages);
setTotalElements(response.data.totalElements);
} else {
setError(response.message || '참여자 목록을 불러오는데 실패했습니다.');
}
} catch (err: any) {
console.error('참여자 목록 로드 오류:', err);
setError(err.response?.data?.message || '참여자 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'waiting':
return '당첨 대기';
case 'winner':
return '당첨';
case 'loser':
return '미당첨';
default:
return status;
}
// 초기 로드 및 필터 변경 시 재로드
useEffect(() => {
loadParticipants();
}, [eventId, currentPage, storeVisitedFilter]);
// 클라이언트 사이드 필터링 (검색어, 당첨 여부)
const filteredParticipants = participants.filter((participant) => {
const matchesSearch =
searchTerm === '' ||
participant.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
participant.phoneNumber.includes(searchTerm) ||
participant.email?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'winner' && participant.isWinner) ||
(statusFilter === 'waiting' && !participant.isWinner);
return matchesSearch && matchesStatus;
});
// 통계 계산
const stats = {
total: totalElements,
waiting: participants.filter((p) => !p.isWinner).length,
winner: participants.filter((p) => p.isWinner).length,
};
const handleParticipantClick = (participant: typeof mockParticipants[0]) => {
const getStatusText = (isWinner: boolean) => {
return isWinner ? '당첨' : '당첨 대기';
};
const getStatusColor = (isWinner: boolean) => {
return isWinner ? 'success' : 'default';
};
const handleParticipantClick = (participant: ParticipationResponse) => {
setSelectedParticipant(participant);
setDetailDialogOpen(true);
};
@ -173,16 +162,9 @@ export default function ParticipantsPage() {
alert('엑셀 다운로드 기능은 추후 구현됩니다');
};
// 통계 계산
const stats = {
total: mockParticipants.length,
waiting: mockParticipants.filter((p) => p.status === 'waiting').length,
winner: mockParticipants.filter((p) => p.status === 'winner').length,
channelDistribution: {
uriTV: mockParticipants.filter((p) => p.channelType === 'uriTV').length,
ringoBiz: mockParticipants.filter((p) => p.channelType === 'ringoBiz').length,
sns: mockParticipants.filter((p) => p.channelType === 'sns').length,
},
const handlePageChange = (_: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
@ -198,9 +180,16 @@ export default function ParticipantsPage() {
</Typography>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* Statistics Cards */}
<Grid container spacing={6} sx={{ mb: 10 }}>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -215,12 +204,12 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.total}
{loading ? '...' : stats.total}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -235,12 +224,12 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.waiting}
{loading ? '...' : stats.waiting}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -255,27 +244,7 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.winner}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<Casino sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.total > 0 ? Math.round((stats.winner / stats.total) * 100) : 0}%
{loading ? '...' : stats.winner}
</Typography>
</CardContent>
</Card>
@ -286,7 +255,7 @@ export default function ParticipantsPage() {
<Box sx={{ mb: 6 }}>
<TextField
fullWidth
placeholder="이름 또는 전화번호 검색..."
placeholder="이름, 전화번호 또는 이메일 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
@ -302,6 +271,7 @@ export default function ParticipantsPage() {
bgcolor: 'white',
},
}}
disabled={loading}
/>
</Box>
@ -310,17 +280,23 @@ export default function ParticipantsPage() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
<FormControl sx={{ flex: 1, minWidth: 160 }}>
<InputLabel> </InputLabel>
<InputLabel> </InputLabel>
<Select
value={channelFilter}
label="참여 경로"
onChange={(e) => setChannelFilter(e.target.value as ChannelType)}
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
label="매장 방문"
onChange={(e) => {
const value = e.target.value;
setStoreVisitedFilter(
value === 'all' ? undefined : value === 'visited' ? true : false
);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all"> </MenuItem>
<MenuItem value="uriTV">TV</MenuItem>
<MenuItem value="ringoBiz"></MenuItem>
<MenuItem value="sns">SNS</MenuItem>
<MenuItem value="all"></MenuItem>
<MenuItem value="visited"></MenuItem>
<MenuItem value="not_visited"></MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flex: 1, minWidth: 140 }}>
@ -330,11 +306,11 @@ export default function ParticipantsPage() {
label="상태"
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all"></MenuItem>
<MenuItem value="waiting"> </MenuItem>
<MenuItem value="winner"></MenuItem>
<MenuItem value="loser"></MenuItem>
</Select>
</FormControl>
</Box>
@ -344,13 +320,14 @@ export default function ParticipantsPage() {
<Box sx={{ mb: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
<span style={{ color: colors.pink }}>{filteredParticipants.length}</span>
<span style={{ color: colors.pink }}>{filteredParticipants.length}</span>
</Typography>
<Box sx={{ display: 'flex', gap: 3 }}>
<Button
variant="outlined"
startIcon={<Download />}
onClick={handleDownloadClick}
disabled={loading}
sx={{
borderRadius: 3,
px: 4,
@ -369,6 +346,7 @@ export default function ParticipantsPage() {
variant="contained"
startIcon={<Casino />}
onClick={handleDrawClick}
disabled={loading}
sx={{
borderRadius: 3,
px: 4,
@ -386,23 +364,38 @@ export default function ParticipantsPage() {
</Box>
</Box>
{/* Participant List */}
<Box sx={{ mb: 10 }}>
{pageParticipants.length === 0 ? (
{/* Loading State */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 16 }}>
<CircularProgress size={60} />
</Box>
)}
{/* Empty State */}
{!loading && filteredParticipants.length === 0 && (
<Box sx={{ textAlign: 'center', py: 16 }}>
<Person sx={{ fontSize: 80, color: colors.gray[300], mb: 3 }} />
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
? '검색 결과가 없습니다'
: '아직 참여자가 없습니다'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
? '다른 검색어나 필터를 사용해보세요'
: '첫 번째 참여자를 기다리고 있습니다'}
</Typography>
</Box>
) : (
)}
{/* Participant List */}
{!loading && filteredParticipants.length > 0 && (
<>
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{pageParticipants.map((participant) => (
{filteredParticipants.map((participant) => (
<Card
key={participant.id}
key={participant.participantId}
elevation={0}
sx={{
cursor: 'pointer',
@ -442,19 +435,19 @@ export default function ParticipantsPage() {
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
#{participant.id}
#{participant.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
{participant.name}
</Typography>
<Typography variant="body1" color="text.secondary">
{participant.phone}
{participant.phoneNumber}
</Typography>
</Box>
</Box>
<Chip
label={getStatusText(participant.status)}
color={getStatusColor(participant.status) as any}
label={getStatusText(participant.isWinner)}
color={getStatusColor(participant.isWinner) as any}
size="medium"
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
/>
@ -471,6 +464,7 @@ export default function ParticipantsPage() {
gap: 2,
}}
>
{participant.channel && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" color="text.secondary">
@ -485,20 +479,28 @@ export default function ParticipantsPage() {
}}
/>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{participant.date}
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
</Typography>
</Box>
{participant.storeVisited && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Chip label="방문" size="small" color="success" />
</Box>
)}
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
{/* Pagination */}
@ -507,7 +509,7 @@ export default function ParticipantsPage() {
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
onChange={handlePageChange}
color="primary"
size="large"
sx={{
@ -519,6 +521,8 @@ export default function ParticipantsPage() {
/>
</Box>
)}
</>
)}
{/* Participant Detail Dialog */}
<Dialog
@ -560,7 +564,7 @@ export default function ParticipantsPage() {
{selectedParticipant.name}
</Typography>
<Typography variant="body2" color="text.secondary">
#{selectedParticipant.id}
#{selectedParticipant.participantId}
</Typography>
</Box>
@ -576,10 +580,28 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.phone}
{selectedParticipant.phoneNumber}
</Typography>
</Box>
{selectedParticipant.email && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.email}
</Typography>
</Box>
)}
{selectedParticipant.channel && (
<Box
sx={{
p: 3,
@ -594,6 +616,7 @@ export default function ParticipantsPage() {
{selectedParticipant.channel}
</Typography>
</Box>
)}
<Box
sx={{
@ -606,7 +629,7 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.date}
{new Date(selectedParticipant.participatedAt).toLocaleString('ko-KR')}
</Typography>
</Box>
@ -621,12 +644,44 @@ export default function ParticipantsPage() {
</Typography>
<Chip
label={getStatusText(selectedParticipant.status)}
color={getStatusColor(selectedParticipant.status) as any}
label={getStatusText(selectedParticipant.isWinner)}
color={getStatusColor(selectedParticipant.isWinner) as any}
size="medium"
sx={{ fontWeight: 600 }}
/>
</Box>
{selectedParticipant.storeVisited && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Chip label="방문 완료" color="success" sx={{ fontWeight: 600 }} />
</Box>
)}
{selectedParticipant.bonusEntries > 0 && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.bonusEntries}
</Typography>
</Box>
)}
</Box>
</Box>
)}

View File

@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import {
Box,
Container,
@ -17,34 +18,36 @@ import {
DialogActions,
Grid,
Divider,
Alert,
CircularProgress,
} from '@mui/material';
import { Celebration, CheckCircle } from '@mui/icons-material';
import { Celebration, CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { participate } from '@/shared/api/participation.api';
import type { ParticipationRequest } from '@/shared/types/api.types';
// Mock 데이터
// Mock 데이터 (이벤트 정보는 추후 Event API로 대체)
const mockEventData = {
id: '1',
title: '신규고객 유치 이벤트',
prize: '커피 쿠폰',
startDate: '2025-11-01',
endDate: '2025-11-15',
announcementDate: '2025-11-20',
participants: 128,
image: '/images/event-banner.jpg',
};
export default function EventParticipatePage() {
// const params = useParams();
// eventId will be used for API calls in future
// const eventId = params.eventId as string;
const params = useParams();
const eventId = params.eventId as string;
// Form state
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [agreedToPrivacy, setAgreedToPrivacy] = useState(false);
// Error state
const [nameError, setNameError] = useState('');
const [phoneError, setPhoneError] = useState('');
const [apiError, setApiError] = useState('');
// UI state
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
@ -63,21 +66,22 @@ export default function EventParticipatePage() {
const formatted = formatPhoneNumber(e.target.value);
setPhone(formatted);
setPhoneError('');
setApiError('');
};
// 유효성 검사
const validateForm = () => {
let isValid = true;
// 이름 검증
if (name.length < 2) {
setNameError('이름은 2자 이상이어야 합니다');
// 이름 검증 (2-50자)
if (name.length < 2 || name.length > 50) {
setNameError('이름은 2자 이상 50자 이하이어야 합니다');
isValid = false;
} else {
setNameError('');
}
// 전화번호 검증
// 전화번호 검증 (패턴: ^\d{3}-\d{3,4}-\d{4}$)
const phonePattern = /^010-\d{4}-\d{4}$/;
if (!phonePattern.test(phone)) {
setPhoneError('올바른 전화번호 형식이 아닙니다 (010-0000-0000)');
@ -95,14 +99,6 @@ export default function EventParticipatePage() {
return isValid;
};
// 중복 참여 체크
const checkDuplicate = () => {
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
return participatedPhones.includes(phone);
};
// 제출 처리
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -111,26 +107,48 @@ export default function EventParticipatePage() {
return;
}
// 중복 참여 체크
if (checkDuplicate()) {
alert('이미 참여하신 전화번호입니다');
return;
}
setSubmitting(true);
setApiError('');
// API 호출 시뮬레이션
setTimeout(() => {
// 참여 기록 저장
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
participatedPhones.push(phone);
localStorage.setItem('participated_phones', JSON.stringify(participatedPhones));
try {
// API 요청 데이터 생성
const requestData: ParticipationRequest = {
name,
phoneNumber: phone,
email: email || undefined,
agreePrivacy: agreedToPrivacy,
channel: 'web', // 웹 참여
};
setSubmitting(false);
// API 호출
const response = await participate(eventId, requestData);
if (response.success) {
// 성공 시 다이얼로그 표시
setSuccessDialogOpen(true);
}, 1000);
// 폼 초기화
setName('');
setPhone('');
setEmail('');
setAgreedToPrivacy(false);
} else {
// API 응답은 성공했지만 success가 false인 경우
setApiError(response.message || '참여 신청에 실패했습니다.');
}
} catch (error: any) {
console.error('참여 신청 오류:', error);
// 에러 메시지 처리
if (error.response?.data?.message) {
setApiError(error.response.data.message);
} else if (error.message) {
setApiError(error.message);
} else {
setApiError('참여 신청 중 오류가 발생했습니다. 다시 시도해주세요.');
}
} finally {
setSubmitting(false);
}
};
return (
@ -199,6 +217,13 @@ export default function EventParticipatePage() {
</Grid>
</Grid>
{/* API Error Alert */}
{apiError && (
<Alert severity="error" sx={{ mb: 3 }} icon={<ErrorIcon />} onClose={() => setApiError('')}>
{apiError}
</Alert>
)}
{/* Participation Form */}
<Card sx={{ borderRadius: 3 }}>
<CardContent sx={{ p: 3 }}>
@ -214,11 +239,13 @@ export default function EventParticipatePage() {
onChange={(e) => {
setName(e.target.value);
setNameError('');
setApiError('');
}}
error={!!nameError}
helperText={nameError}
helperText={nameError || '2자 이상 50자 이하로 입력해주세요'}
sx={{ mb: 2 }}
required
disabled={submitting}
/>
<TextField
@ -229,8 +256,23 @@ export default function EventParticipatePage() {
error={!!phoneError}
helperText={phoneError || '010-0000-0000 형식으로 입력해주세요'}
placeholder="010-0000-0000"
sx={{ mb: 3 }}
sx={{ mb: 2 }}
required
disabled={submitting}
/>
<TextField
fullWidth
label="이메일 (선택)"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setApiError('');
}}
placeholder="example@email.com"
sx={{ mb: 3 }}
disabled={submitting}
/>
<Divider sx={{ mb: 2 }} />
@ -241,6 +283,7 @@ export default function EventParticipatePage() {
checked={agreedToPrivacy}
onChange={(e) => setAgreedToPrivacy(e.target.checked)}
color="primary"
disabled={submitting}
/>
}
label={
@ -279,19 +322,22 @@ export default function EventParticipatePage() {
fontSize: '1rem',
}}
>
{submitting ? '참여 중...' : '참여하기'}
{submitting ? (
<>
<CircularProgress size={24} sx={{ mr: 1, color: 'white' }} />
...
</>
) : (
'참여하기'
)}
</Button>
</form>
</CardContent>
</Card>
{/* Participants Count */}
{/* Participants Count Info */}
<Box sx={{ textAlign: 'center', mt: 3 }}>
<Typography variant="body2" color="text.secondary">
<strong style={{ color: '#e91e63' }}>{mockEventData.participants}</strong>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
: {mockEventData.announcementDate}
</Typography>
</Box>
@ -307,7 +353,7 @@ export default function EventParticipatePage() {
<DialogContent dividers>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>1. </strong>
<br />- ,
<br />- , , ()
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>2. </strong>

View File

@ -15,19 +15,50 @@ export type BudgetLevel = 'low' | 'medium' | 'high';
export type EventMethod = 'online' | 'offline';
export interface EventData {
eventDraftId?: number;
objective?: EventObjective;
recommendation?: {
budget: BudgetLevel;
method: EventMethod;
recommendation: {
optionNumber: number;
concept: string;
title: string;
prize: string;
participationMethod: string;
expectedParticipants: number;
estimatedCost: number;
roi: number;
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: {
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;
};
};
differentiator: string;
};
eventId: string;
};
contentPreview?: {
imageStyle: string;
images?: any[];
};
contentEdit?: {
title: string;
@ -89,6 +120,18 @@ export default function EventCreatePage() {
);
if (needsContent) {
// localStorage에 이벤트 정보 저장
const eventData = {
eventDraftId: context.recommendation?.eventId || String(Date.now()), // eventId 사용
eventTitle: context.recommendation?.recommendation.title || '',
eventDescription: context.recommendation?.recommendation.description || '',
industry: '',
location: '',
trends: context.recommendation?.recommendation.promotionChannels || [],
prize: '',
};
localStorage.setItem('eventCreationData', JSON.stringify(eventData));
history.push('contentPreview', { ...context, channels });
} else {
history.push('approval', { ...context, channels });
@ -101,12 +144,13 @@ export default function EventCreatePage() {
)}
contentPreview={({ context, history }) => (
<ContentPreviewStep
title={context.recommendation?.title || ''}
prize={context.recommendation?.prize || ''}
onNext={(imageStyle) => {
eventId={context.recommendation?.eventId}
eventTitle={context.recommendation?.recommendation.title}
eventDescription={context.recommendation?.recommendation.description}
onNext={(imageStyle, images) => {
history.push('contentEdit', {
...context,
contentPreview: { imageStyle },
contentPreview: { imageStyle, images },
});
}}
onSkip={() => {
@ -119,8 +163,8 @@ export default function EventCreatePage() {
)}
contentEdit={({ context, history }) => (
<ContentEditStep
initialTitle={context.recommendation?.title || ''}
initialPrize={context.recommendation?.prize || ''}
initialTitle={context.recommendation?.recommendation.title || ''}
initialPrize={''}
onNext={(contentEdit) => {
history.push('approval', { ...context, contentEdit });
}}

View File

@ -120,7 +120,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
{eventData.recommendation?.title || '이벤트 제목'}
{eventData.recommendation?.recommendation.title || '이벤트 제목'}
</Typography>
</CardContent>
</Card>
@ -158,7 +158,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
{eventData.recommendation?.expectedParticipants || 0}
{eventData.recommendation?.recommendation.expectedMetrics.newCustomers.max || 0}
<Typography component="span" sx={{
fontSize: '1rem',
ml: 0.5,
@ -204,7 +204,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
{((eventData.recommendation?.estimatedCost || 0) / 10000).toFixed(0)}
{((eventData.recommendation?.recommendation.estimatedCost.max || 0) / 10000).toFixed(0)}
<Typography component="span" sx={{
fontSize: '1rem',
ml: 0.5,
@ -250,7 +250,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}}
>
{eventData.recommendation?.roi || 0}%
{eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}%
</Typography>
</CardContent>
</Card>
@ -270,7 +270,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.title}
{eventData.recommendation?.recommendation.title}
</Typography>
</Box>
<IconButton size="small">
@ -288,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.prize}
{eventData.recommendation?.recommendation.mechanics.details || ''}
</Typography>
</Box>
<IconButton size="small">
@ -306,7 +306,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.participationMethod}
{eventData.recommendation?.recommendation.mechanics.details || ''}
</Typography>
</Box>
</Box>

View File

@ -12,8 +12,11 @@ import {
IconButton,
Dialog,
Grid,
Alert,
} from '@mui/material';
import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material';
import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material';
import { contentApi, ImageInfo } from '@/shared/api/contentApi';
import Image from 'next/image';
// 디자인 시스템 색상
const colors = {
@ -34,7 +37,7 @@ const colors = {
};
interface ImageStyle {
id: string;
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
name: string;
gradient?: string;
icon: string;
@ -43,19 +46,19 @@ interface ImageStyle {
const imageStyles: ImageStyle[] = [
{
id: 'simple',
id: 'SIMPLE',
name: '스타일 1: 심플',
icon: 'celebration',
},
{
id: 'fancy',
id: 'FANCY',
name: '스타일 2: 화려',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'auto_awesome',
textColor: 'white',
},
{
id: 'trendy',
id: 'TRENDY',
name: '스타일 3: 트렌디',
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'trending_up',
@ -64,50 +67,246 @@ const imageStyles: ImageStyle[] = [
];
interface ContentPreviewStepProps {
title: string;
prize: string;
onNext: (imageStyle: string) => void;
eventId?: string;
eventTitle?: string;
eventDescription?: string;
onNext: (imageStyle: string, images: ImageInfo[]) => void;
onSkip: () => void;
onBack: () => void;
}
interface EventCreationData {
eventDraftId: string;
eventTitle: string;
eventDescription: string;
industry: string;
location: string;
trends: string[];
prize: string;
}
export default function ContentPreviewStep({
title,
prize,
eventId: propsEventId,
eventTitle: propsEventTitle,
eventDescription: propsEventDescription,
onNext,
onSkip,
onBack,
}: ContentPreviewStepProps) {
const [loading, setLoading] = useState(true);
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [fullscreenStyle, setFullscreenStyle] = useState<ImageStyle | null>(null);
const [fullscreenImage, setFullscreenImage] = useState<ImageInfo | null>(null);
const [generatedImages, setGeneratedImages] = useState<Map<string, ImageInfo>>(new Map());
const [error, setError] = useState<string | null>(null);
const [loadingProgress, setLoadingProgress] = useState(0);
const [loadingMessage, setLoadingMessage] = useState('이미지 생성 요청 중...');
const [eventData, setEventData] = useState<EventCreationData | null>(null);
useEffect(() => {
// AI 이미지 생성 시뮬레이션
const timer = setTimeout(() => {
// localStorage에서 이벤트 데이터 읽기
const storedData = localStorage.getItem('eventCreationData');
if (storedData) {
const data: EventCreationData = JSON.parse(storedData);
setEventData(data);
// 먼저 이미지 조회 시도
loadImages(data).then((hasImages) => {
// 이미지가 없으면 자동으로 생성
if (!hasImages) {
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} else if (propsEventId) {
// Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만)
console.log('✅ Using event data from props:', propsEventId);
const data: EventCreationData = {
eventDraftId: propsEventId,
eventTitle: propsEventTitle || '',
eventDescription: propsEventDescription || '',
industry: '',
location: '',
trends: [],
prize: '',
};
setEventData(data);
// 이미지 조회 시도
loadImages(data).then((hasImages) => {
if (!hasImages) {
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} else {
// 이벤트 데이터가 없으면 에러 표시
console.error('❌ No event data available. Cannot proceed.');
setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.');
setLoading(false);
}, 5000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [propsEventId, propsEventTitle, propsEventDescription]);
return () => clearTimeout(timer);
}, []);
const loadImages = async (data: EventCreationData): Promise<boolean> => {
try {
setError(null);
const handleStyleSelect = (styleId: string) => {
console.log('📥 Loading images for event:', data.eventDraftId);
const images = await contentApi.getImages(data.eventDraftId);
console.log('✅ Images loaded from API:', images.length, images);
if (!images || images.length === 0) {
console.warn('⚠️ No images found.');
return false; // 이미지 없음
}
const imageMap = new Map<string, ImageInfo>();
// 각 스타일별로 가장 최신 이미지만 선택 (createdAt 기준)
images.forEach((image, index) => {
console.log(`📸 Processing image ${index + 1}:`, {
id: image.id,
eventId: image.eventId,
style: image.style,
platform: image.platform,
cdnUrl: image.cdnUrl?.substring(0, 50) + '...',
createdAt: image.createdAt,
});
if (image.platform === 'INSTAGRAM') {
const existing = imageMap.get(image.style);
if (!existing || new Date(image.createdAt) > new Date(existing.createdAt)) {
console.log(` ✅ Selected as latest ${image.style} image`);
imageMap.set(image.style, image);
} else {
console.log(` ⏭️ Skipped (older than existing ${image.style} image)`);
}
} else {
console.log(` ⏭️ Skipped (platform: ${image.platform})`);
}
});
console.log('🎨 Image map created with entries:', {
SIMPLE: imageMap.has('SIMPLE') ? 'YES ✅' : 'NO ❌',
FANCY: imageMap.has('FANCY') ? 'YES ✅' : 'NO ❌',
TRENDY: imageMap.has('TRENDY') ? 'YES ✅' : 'NO ❌',
totalSize: imageMap.size,
});
console.log('🖼️ Image map details:', Array.from(imageMap.entries()).map(([style, img]) => ({
style,
id: img.id,
eventId: img.eventId,
cdnUrl: img.cdnUrl?.substring(0, 60) + '...',
})));
setGeneratedImages(imageMap);
console.log('✅ Images loaded successfully!');
return true; // 이미지 있음
} catch (err) {
console.error('❌ Load images error:', err);
// API 에러는 polling에서 무시 (계속 시도)
return false;
}
};
const handleStyleSelect = (styleId: 'SIMPLE' | 'FANCY' | 'TRENDY') => {
setSelectedStyle(styleId);
};
const handlePreview = (style: ImageStyle, e: React.MouseEvent) => {
const handlePreview = (image: ImageInfo, e: React.MouseEvent) => {
e.stopPropagation();
setFullscreenStyle(style);
setFullscreenImage(image);
setFullscreenOpen(true);
};
const handleNext = () => {
if (selectedStyle) {
onNext(selectedStyle);
const allImages = Array.from(generatedImages.values());
onNext(selectedStyle, allImages);
}
};
const handleGenerateImagesAuto = async (data: EventCreationData) => {
try {
setLoading(true);
setError(null);
setLoadingProgress(0);
setLoadingMessage('이미지 생성 요청 중...');
console.log('🎨 Auto-generating images for event:', data.eventDraftId);
// 이미지 생성 요청 (202 Accepted 응답만 확인)
await contentApi.generateImages({
eventId: data.eventDraftId,
eventTitle: data.eventTitle,
eventDescription: data.eventDescription,
industry: data.industry,
location: data.location,
trends: data.trends,
styles: ['SIMPLE', 'FANCY', 'TRENDY'],
platforms: ['INSTAGRAM'],
});
console.log('✅ Image generation request accepted (202)');
console.log('⏳ AI 이미지 생성 중... 약 60초 소요됩니다.');
setLoadingProgress(10);
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
// 생성 완료까지 대기 (polling)
let attempts = 0;
const maxAttempts = 30; // 최대 60초 (2초 * 30회)
const pollImages = async () => {
attempts++;
console.log(`🔄 이미지 확인 시도 ${attempts}/${maxAttempts}...`);
// 진행률 업데이트 (10% ~ 90%)
const progress = Math.min(10 + (attempts / maxAttempts) * 80, 90);
setLoadingProgress(progress);
// 단계별 메시지 업데이트
if (attempts < 10) {
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
} else if (attempts < 20) {
setLoadingMessage('스타일을 적용하고 있어요...');
} else {
setLoadingMessage('거의 완료되었어요...');
}
const hasImages = await loadImages(data);
if (hasImages) {
console.log('✅ 이미지 생성 완료!');
setLoadingProgress(100);
setLoadingMessage('이미지 생성 완료!');
setTimeout(() => setLoading(false), 500); // 100% 잠깐 보여주기
} else if (attempts < maxAttempts) {
// 2초 후 다시 시도
setTimeout(pollImages, 2000);
} else {
console.warn('⚠️ 이미지 생성 시간 초과. "이미지 재생성" 버튼을 클릭하세요.');
setError('이미지 생성이 완료되지 않았습니다. 잠시 후 "이미지 재생성" 버튼을 클릭해주세요.');
setLoading(false);
}
};
// 첫 번째 확인은 5초 후 시작 (생성 시작 시간 고려)
setTimeout(pollImages, 5000);
} catch (err) {
console.error('❌ Image generation request error:', err);
setError('이미지 생성 요청에 실패했습니다.');
setLoading(false);
}
};
const handleGenerateImages = async () => {
if (!eventData) return;
handleGenerateImagesAuto(eventData);
};
if (loading) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
@ -121,7 +320,7 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15 }}>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
{/* 그라데이션 스피너 */}
<Box
sx={{
@ -161,17 +360,69 @@ export default function ContentPreviewStep({
}}
/>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, fontSize: '1.5rem' }}>
AI
{/* 진행률 바 */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
{loadingMessage}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: colors.purple, fontSize: '1.25rem' }}>
{Math.round(loadingProgress)}%
</Typography>
</Box>
<Box
sx={{
width: '100%',
height: 8,
bgcolor: 'rgba(0,0,0,0.1)',
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
}}
>
<Box
sx={{
width: `${loadingProgress}%`,
height: '100%',
background: `linear-gradient(90deg, ${colors.purple}, ${colors.pink})`,
borderRadius: 4,
transition: 'width 0.3s ease-in-out',
}}
/>
</Box>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, fontSize: '1.125rem' }}>
{generatedImages.size > 0 ? (
<>
<br />
...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
시간: 5초
!
</>
) : (
<>
AI가
<br />
60
</>
)}
</Typography>
{error && (
<Alert severity="error" sx={{ maxWidth: 400, mx: 'auto', mt: 3 }}>
{error}
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{ mt: 2 }}
>
</Button>
</Alert>
)}
</Box>
</Container>
</Box>
@ -179,7 +430,18 @@ export default function ContentPreviewStep({
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Box
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
pb: 20,
animation: 'fadeIn 0.5s ease-in',
'@keyframes fadeIn': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
}}
>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
@ -191,11 +453,35 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
{generatedImages.size > 0 && (
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
</Alert>
)}
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{
ml: 4,
py: 2,
px: 4,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
</Typography>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value)}>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{imageStyles.map((style) => (
<Grid item xs={12} md={4} key={style.id}>
@ -237,6 +523,39 @@ export default function ContentPreviewStep({
sx={{
width: '100%',
aspectRatio: '1 / 1',
position: 'relative',
overflow: 'hidden',
bgcolor: colors.gray[100],
}}
>
{(() => {
const hasImage = generatedImages.has(style.id);
const imageData = generatedImages.get(style.id);
console.log(`🖼️ Rendering ${style.id}:`, {
hasImage,
imageDataExists: !!imageData,
fullCdnUrl: imageData?.cdnUrl,
mapSize: generatedImages.size,
mapKeys: Array.from(generatedImages.keys()),
});
return hasImage && imageData ? (
<Image
src={imageData.cdnUrl}
alt={style.name}
fill
style={{ objectFit: 'cover' }}
unoptimized
onLoad={() => console.log(`${style.id} image loaded successfully from:`, imageData.cdnUrl)}
onError={(e) => {
console.error(`${style.id} image load error:`, e);
console.error(` Failed URL:`, imageData.cdnUrl);
}}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
background: style.gradient || colors.gray[100],
display: 'flex',
flexDirection: 'column',
@ -265,7 +584,7 @@ export default function ContentPreviewStep({
fontSize: '1.25rem',
}}
>
{title}
{eventData?.eventTitle || '이벤트'}
</Typography>
<Typography
variant="body1"
@ -275,16 +594,25 @@ export default function ContentPreviewStep({
fontSize: '1rem',
}}
>
{prize}
{eventData?.prize || '경품'}
</Typography>
</Box>
);
})()}
</Box>
{/* 크게보기 버튼 */}
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
startIcon={<ZoomIn />}
onClick={(e) => handlePreview(style, e)}
onClick={(e) => {
const image = generatedImages.get(style.id);
if (image) {
handlePreview(image, e);
}
}}
disabled={!generatedImages.has(style.id)}
sx={{
borderRadius: 2,
py: 1.5,
@ -387,51 +715,24 @@ export default function ContentPreviewStep({
<span className="material-icons">close</span>
</IconButton>
{fullscreenStyle && (
{fullscreenImage && (
<Box
sx={{
width: '100%',
maxWidth: 600,
maxWidth: 800,
aspectRatio: '1 / 1',
background: fullscreenStyle.gradient || '#f5f5f5',
position: 'relative',
borderRadius: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
overflow: 'hidden',
}}
>
<span
className="material-icons"
style={{
fontSize: 80,
marginBottom: 24,
color: fullscreenStyle.textColor || 'inherit',
}}
>
{fullscreenStyle.icon}
</span>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 2,
color: fullscreenStyle.textColor || 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="h6"
sx={{
color: fullscreenStyle.textColor || 'text.secondary',
opacity: fullscreenStyle.textColor ? 0.9 : 1,
}}
>
{prize}
</Typography>
<Image
src={fullscreenImage.cdnUrl}
alt={`${fullscreenImage.style} style`}
fill
style={{ objectFit: 'contain' }}
unoptimized
/>
</Box>
)}
</Box>

View File

@ -1,4 +1,6 @@
import { useState } from 'react';
'use client';
import { useState, useEffect } from 'react';
import {
Box,
Container,
@ -13,11 +15,12 @@ import {
RadioGroup,
FormControlLabel,
IconButton,
Tabs,
Tab,
CircularProgress,
Alert,
} from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { EventObjective, BudgetLevel, EventMethod } from '../page';
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
// 디자인 시스템 색상
const colors = {
@ -37,130 +40,288 @@ const colors = {
},
};
interface Recommendation {
id: string;
budget: BudgetLevel;
method: EventMethod;
title: string;
prize: string;
participationMethod: string;
expectedParticipants: number;
estimatedCost: number;
roi: number;
}
// Mock 추천 데이터
const mockRecommendations: Recommendation[] = [
// 저비용
{
id: 'low-online',
budget: 'low',
method: 'online',
title: 'SNS 팔로우 이벤트',
prize: '커피 쿠폰',
participationMethod: 'SNS 팔로우',
expectedParticipants: 180,
estimatedCost: 250000,
roi: 520,
},
{
id: 'low-offline',
budget: 'low',
method: 'offline',
title: '전화번호 등록 이벤트',
prize: '커피 쿠폰',
participationMethod: '방문 시 전화번호 등록',
expectedParticipants: 120,
estimatedCost: 300000,
roi: 380,
},
// 중비용
{
id: 'medium-online',
budget: 'medium',
method: 'online',
title: '리뷰 작성 이벤트',
prize: '상품권 5만원',
participationMethod: '네이버 리뷰 작성',
expectedParticipants: 250,
estimatedCost: 800000,
roi: 450,
},
{
id: 'medium-offline',
budget: 'medium',
method: 'offline',
title: '스탬프 적립 이벤트',
prize: '상품권 5만원',
participationMethod: '3회 방문 시 스탬프',
expectedParticipants: 200,
estimatedCost: 1000000,
roi: 380,
},
// 고비용
{
id: 'high-online',
budget: 'high',
method: 'online',
title: '인플루언서 협업 이벤트',
prize: '애플 에어팟',
participationMethod: '게시물 공유 및 댓글',
expectedParticipants: 500,
estimatedCost: 2000000,
roi: 380,
},
{
id: 'high-offline',
budget: 'high',
method: 'offline',
title: 'VIP 고객 초대 이벤트',
prize: '애플 에어팟',
participationMethod: '누적 10회 방문',
expectedParticipants: 300,
estimatedCost: 2500000,
roi: 320,
},
];
interface RecommendationStepProps {
objective?: EventObjective;
onNext: (data: Recommendation) => void;
eventId?: string; // 이전 단계에서 생성된 eventId
onNext: (data: {
recommendation: EventRecommendation;
eventId: string;
}) => void;
onBack: () => void;
}
export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) {
const [selectedBudget, setSelectedBudget] = useState<BudgetLevel>('low');
const [selected, setSelected] = useState<string | null>(null);
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({});
export default function RecommendationStep({
objective,
eventId: initialEventId,
onNext,
onBack
}: RecommendationStepProps) {
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
const [jobId, setJobId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [polling, setPolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget);
const [aiResult, setAiResult] = useState<AIRecommendationResult | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
const handleNext = () => {
const selectedRec = mockRecommendations.find((r) => r.id === selected);
if (selectedRec && selected) {
const edited = editedData[selected];
onNext({
...selectedRec,
title: edited?.title || selectedRec.title,
prize: edited?.prize || selectedRec.prize,
});
// 컴포넌트 마운트 시 AI 추천 요청
useEffect(() => {
if (!eventId && objective) {
// Step 1: 이벤트 생성
createEventAndRequestAI();
} else if (eventId) {
// 이미 eventId가 있으면 AI 추천 요청
requestAIRecommendations(eventId);
}
}, []);
const createEventAndRequestAI = async () => {
try {
setLoading(true);
setError(null);
// Step 1: 이벤트 목적 선택 및 생성
const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치');
const newEventId = eventResponse.eventId;
setEventId(newEventId);
// Step 2: AI 추천 요청
await requestAIRecommendations(newEventId);
} catch (err: any) {
console.error('이벤트 생성 실패:', err);
setError(err.response?.data?.message || '이벤트 생성에 실패했습니다');
setLoading(false);
}
};
const handleEditTitle = (id: string, title: string) => {
const requestAIRecommendations = async (evtId: string) => {
try {
setLoading(true);
setError(null);
// 사용자 정보에서 매장 정보 가져오기
const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}');
const storeInfo = {
storeId: userProfile.storeId || '1',
storeName: userProfile.storeName || '내 매장',
category: userProfile.industry || '음식점',
description: userProfile.businessHours || '',
};
// AI 추천 요청
const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo);
setJobId(jobResponse.jobId);
// Job 폴링 시작
pollJobStatus(jobResponse.jobId, evtId);
} catch (err: any) {
console.error('AI 추천 요청 실패:', err);
setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다');
setLoading(false);
}
};
const pollJobStatus = async (jId: string, evtId: string) => {
setPolling(true);
const maxAttempts = 60; // 최대 5분 (5초 간격)
let attempts = 0;
const poll = async () => {
try {
const status = await eventApi.getJobStatus(jId);
console.log('Job 상태:', status);
if (status.status === 'COMPLETED') {
// AI 추천 결과 조회
const recommendations = await aiApi.getRecommendations(evtId);
setAiResult(recommendations);
setLoading(false);
setPolling(false);
return;
} else if (status.status === 'FAILED') {
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
setLoading(false);
setPolling(false);
return;
}
// 계속 폴링
attempts++;
if (attempts < maxAttempts) {
setTimeout(poll, 5000); // 5초 후 재시도
} else {
setError('AI 추천 생성 시간이 초과되었습니다');
setLoading(false);
setPolling(false);
}
} catch (err: any) {
console.error('Job 상태 조회 실패:', err);
setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
setLoading(false);
setPolling(false);
}
};
poll();
};
const handleNext = async () => {
if (selected === null || !aiResult || !eventId) return;
const selectedRec = aiResult.recommendations[selected - 1];
const edited = editedData[selected];
try {
setLoading(true);
// AI 추천 선택 API 호출
await eventApi.selectRecommendation(eventId, {
recommendationId: `${eventId}-opt${selected}`,
customizations: {
eventName: edited?.title || selectedRec.title,
description: edited?.description || selectedRec.description,
},
});
// 다음 단계로 이동
onNext({
recommendation: {
...selectedRec,
title: edited?.title || selectedRec.title,
description: edited?.description || selectedRec.description,
},
eventId,
});
} catch (err: any) {
console.error('추천 선택 실패:', err);
setError(err.response?.data?.message || '추천 선택에 실패했습니다');
} finally {
setLoading(false);
}
};
const handleEditTitle = (optionNumber: number, title: string) => {
setEditedData((prev) => ({
...prev,
[id]: { ...prev[id], title },
[optionNumber]: {
...prev[optionNumber],
title
},
}));
};
const handleEditPrize = (id: string, prize: string) => {
const handleEditDescription = (optionNumber: number, description: string) => {
setEditedData((prev) => ({
...prev,
[id]: { ...prev[id], prize },
[optionNumber]: {
...prev[optionNumber],
description
},
}));
};
// 로딩 상태 표시
if (loading || polling) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
<CircularProgress size={60} sx={{ color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
AI가 ...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
, ,
</Typography>
</Box>
</Container>
</Box>
);
}
// 에러 상태 표시
if (error) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
AI
</Typography>
</Box>
<Alert severity="error" sx={{ mb: 4 }}>
{error}
</Alert>
<Box sx={{ display: 'flex', gap: 4 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
onClick={() => {
setError(null);
if (eventId) {
requestAIRecommendations(eventId);
} else {
createEventAndRequestAI();
}
}}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
}}
>
</Button>
</Box>
</Container>
</Box>
);
}
// AI 결과가 없으면 로딩 표시
if (!aiResult) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
<CircularProgress />
</Container>
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
@ -195,158 +356,159 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
📍
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
🗺
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
{trend.description}
</Typography>
))}
</Grid>
</Grid>
</CardContent>
</Card>
{/* Budget Selection */}
{/* AI Recommendations */}
<Box sx={{ mb: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
AI ({aiResult.recommendations.length} )
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
2 ( 1, 1)
. .
</Typography>
<Tabs
value={selectedBudget}
onChange={(_, value) => setSelectedBudget(value)}
variant="fullWidth"
sx={{ mb: 8 }}
>
<Tab
label="💰 저비용"
value="low"
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
/>
<Tab
label="💰💰 중비용"
value="medium"
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
/>
<Tab
label="💰💰💰 고비용"
value="high"
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
/>
</Tabs>
</Box>
{/* Recommendations */}
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{budgetRecommendations.map((rec) => (
<Grid item xs={12} md={6} key={rec.id}>
{aiResult.recommendations.map((rec) => (
<Grid item xs={12} key={rec.optionNumber}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
border: selected === rec.id ? 2 : 1,
borderColor: selected === rec.id ? colors.purple : 'divider',
bgcolor: selected === rec.id ? `${colors.purpleLight}40` : 'background.paper',
border: selected === rec.optionNumber ? 2 : 1,
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
transition: 'all 0.2s',
boxShadow: selected === rec.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:hover': {
borderColor: colors.purple,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
onClick={() => setSelected(rec.id)}
onClick={() => setSelected(rec.optionNumber)}
>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Chip
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
color={rec.method === 'online' ? 'primary' : 'secondary'}
label={`옵션 ${rec.optionNumber}`}
color="primary"
size="medium"
sx={{ fontSize: '0.875rem', py: 2 }}
/>
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
<Chip
label={rec.concept}
variant="outlined"
size="medium"
sx={{ fontSize: '0.875rem', py: 2 }}
/>
</Box>
<FormControlLabel
value={rec.optionNumber}
control={<Radio />}
label=""
sx={{ m: 0 }}
/>
</Box>
<TextField
fullWidth
variant="outlined"
value={editedData[rec.id]?.title || rec.title}
onChange={(e) => handleEditTitle(rec.id, e.target.value)}
value={editedData[rec.optionNumber]?.title || rec.title}
onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()}
sx={{ mb: 4 }}
InputProps={{
endAdornment: <Edit fontSize="small" color="action" />,
sx: { fontSize: '1rem', py: 2 },
sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
}}
/>
<Box sx={{ mb: 4 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}>
</Typography>
<TextField
fullWidth
size="medium"
multiline
rows={2}
variant="outlined"
value={editedData[rec.id]?.prize || rec.prize}
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
value={editedData[rec.optionNumber]?.description || rec.description}
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()}
sx={{ mb: 4 }}
InputProps={{
endAdornment: <Edit fontSize="small" color="action" />,
sx: { fontSize: '1rem' },
}}
/>
</Box>
<Grid container spacing={4} sx={{ mt: 4 }}>
<Grid item xs={6}>
<Grid container spacing={4} sx={{ mt: 2 }}>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.participationMethod}
{rec.targetAudience}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.expectedParticipants}
</Typography>
</Grid>
<Grid item xs={6}>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{(rec.estimatedCost / 10000).toFixed(0)}
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}
</Typography>
</Grid>
<Grid item xs={6}>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}
</Typography>
</Grid>
<Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
ROI
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
{rec.roi}%
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
{rec.differentiator}
</Typography>
</Grid>
</Grid>
@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
fullWidth
variant="contained"
size="large"
disabled={!selected}
disabled={selected === null || loading}
onClick={handleNext}
sx={{
py: 3,
@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
},
}}
>
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
</Button>
</Box>
</Container>

View File

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

View File

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

View File

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

178
src/shared/api/aiApi.ts Normal file
View File

@ -0,0 +1,178 @@
import axios, { AxiosInstance } from 'axios';
// AI Service API 클라이언트
const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083';
export const aiApiClient: AxiosInstance = axios.create({
baseURL: AI_API_BASE_URL,
timeout: 300000, // AI 생성은 최대 5분
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
aiApiClient.interceptors.request.use(
(config) => {
console.log('🤖 AI API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
data: config.data,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ AI API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
aiApiClient.interceptors.response.use(
(response) => {
console.log('✅ AI API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ AI API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
data: error.response?.data,
});
return Promise.reject(error);
}
);
// Types
export interface TrendKeyword {
keyword: string;
relevance: number;
description: string;
}
export interface TrendAnalysis {
industryTrends: TrendKeyword[];
regionalTrends: TrendKeyword[];
seasonalTrends: TrendKeyword[];
}
export interface ExpectedMetrics {
newCustomers: {
min: number;
max: number;
};
repeatVisits?: {
min: number;
max: number;
};
revenueIncrease: {
min: number;
max: number;
};
roi: {
min: number;
max: number;
};
socialEngagement?: {
estimatedPosts: number;
estimatedReach: number;
};
}
export interface EventRecommendation {
optionNumber: number;
concept: string;
title: string;
description: string;
targetAudience: string;
duration: {
recommendedDays: number;
recommendedPeriod?: string;
};
mechanics: {
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
details: string;
};
promotionChannels: string[];
estimatedCost: {
min: number;
max: number;
breakdown?: {
material?: number;
promotion?: number;
discount?: number;
};
};
expectedMetrics: ExpectedMetrics;
differentiator: string;
}
export interface AIRecommendationResult {
eventId: string;
trendAnalysis: TrendAnalysis;
recommendations: EventRecommendation[];
generatedAt: string;
expiresAt: string;
aiProvider: 'CLAUDE' | 'GPT4';
}
export interface JobStatusResponse {
jobId: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
message: string;
eventId?: string;
createdAt: string;
startedAt?: string;
completedAt?: string;
failedAt?: string;
errorMessage?: string;
retryCount?: number;
processingTimeMs?: number;
}
export interface HealthCheckResponse {
status: 'UP' | 'DOWN' | 'DEGRADED';
timestamp: string;
services: {
kafka: 'UP' | 'DOWN';
redis: 'UP' | 'DOWN';
claude_api: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
gpt4_api?: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
circuit_breaker: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
};
}
// API Functions
export const aiApi = {
// 헬스체크
healthCheck: async (): Promise<HealthCheckResponse> => {
const response = await aiApiClient.get<HealthCheckResponse>('/health');
return response.data;
},
// Job 상태 조회 (Internal API)
getJobStatus: async (jobId: string): Promise<JobStatusResponse> => {
const response = await aiApiClient.get<JobStatusResponse>(`/internal/jobs/${jobId}/status`);
return response.data;
},
// AI 추천 결과 조회 (Internal API)
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
return response.data;
},
};
export default aiApi;

View File

@ -1,18 +1,40 @@
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://20.196.65.160:8081';
// 마이크로서비스별 호스트 설정
const API_HOSTS = {
user: process.env.NEXT_PUBLIC_USER_HOST || 'http://localhost:8081',
event: process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080',
content: process.env.NEXT_PUBLIC_CONTENT_HOST || 'http://localhost:8082',
ai: process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083',
participation: process.env.NEXT_PUBLIC_PARTICIPATION_HOST || 'http://localhost:8084',
distribution: process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://localhost:8085',
analytics: process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086',
};
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
// 기본 User API 클라이언트 (기존 호환성 유지)
const API_BASE_URL = API_HOSTS.user;
export const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 90000, // 30초로 증가
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - JWT 토큰 추가
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// Participation API 전용 클라이언트
export const participationClient: AxiosInstance = axios.create({
baseURL: `${API_HOSTS.participation}/${API_VERSION}`,
timeout: 90000,
headers: {
'Content-Type': 'application/json',
},
});
// 공통 Request interceptor 함수
const requestInterceptor = (config: InternalAxiosRequestConfig) => {
console.log('🚀 API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
@ -26,24 +48,24 @@ apiClient.interceptors.request.use(
console.log('🔑 Token added to request');
}
return config;
},
(error: AxiosError) => {
};
const requestErrorInterceptor = (error: AxiosError) => {
console.error('❌ Request Error:', error);
return Promise.reject(error);
}
);
};
// Response interceptor - 에러 처리
apiClient.interceptors.response.use(
(response) => {
// 공통 Response interceptor 함수
const responseInterceptor = (response: any) => {
console.log('✅ API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error: AxiosError) => {
};
const responseErrorInterceptor = (error: AxiosError) => {
console.error('❌ API Error:', {
message: error.message,
status: error.response?.status,
@ -54,14 +76,20 @@ apiClient.interceptors.response.use(
if (error.response?.status === 401) {
console.warn('🔒 401 Unauthorized - Redirecting to login');
// 인증 실패 시 토큰 삭제 및 로그인 페이지로 리다이렉트
localStorage.removeItem('accessToken');
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
};
// User API Client 인터셉터 적용
apiClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
apiClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
// Participation API Client 인터셉터 적용
participationClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor);
participationClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor);
export default apiClient;

View File

@ -0,0 +1,160 @@
import axios, { AxiosInstance } from 'axios';
// Use Next.js API proxy to bypass CORS issues
const CONTENT_API_BASE_URL = '/api/content';
export const contentApiClient: AxiosInstance = axios.create({
baseURL: CONTENT_API_BASE_URL,
timeout: 120000, // 이미지 생성은 시간이 오래 걸릴 수 있으므로 120초
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
contentApiClient.interceptors.request.use(
(config) => {
console.log('🎨 Content API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
data: config.data,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ Content API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
contentApiClient.interceptors.response.use(
(response) => {
console.log('✅ Content API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ Content API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
data: error.response?.data,
});
return Promise.reject(error);
}
);
// Types
export interface GenerateImagesRequest {
eventId: string;
eventTitle: string;
eventDescription: string;
industry?: string;
location?: string;
trends?: string[];
styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[];
platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[];
}
export interface JobInfo {
id: string;
eventId: string;
jobType: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultMessage?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
export interface ImageInfo {
id: number;
eventId: string;
style: 'SIMPLE' | 'FANCY' | 'TRENDY';
platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO';
cdnUrl: string;
prompt: string;
selected: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContentInfo {
id: number;
eventId: string;
eventTitle: string;
eventDescription: string;
images: ImageInfo[];
createdAt: string;
updatedAt: string;
}
// API Functions
export const contentApi = {
// 이미지 생성 (Next.js API proxy 사용)
generateImages: async (request: GenerateImagesRequest): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>('/images/generate', request);
return response.data;
},
// Job 상태 조회 (Next.js API proxy 사용)
getJobStatus: async (jobId: string): Promise<JobInfo> => {
const response = await contentApiClient.get<JobInfo>(`/images/jobs/${jobId}`);
return response.data;
},
// 이벤트별 콘텐츠 조회
getContentByEventId: async (eventId: string): Promise<ContentInfo> => {
const response = await contentApiClient.get<ContentInfo>(`/events/${eventId}`);
return response.data;
},
// 이미지 목록 조회 (Next.js API proxy 사용)
getImages: async (
eventId: string,
style?: 'SIMPLE' | 'FANCY' | 'TRENDY',
platform?: 'INSTAGRAM' | 'NAVER' | 'KAKAO'
): Promise<ImageInfo[]> => {
const params = new URLSearchParams();
if (style) params.append('style', style);
if (platform) params.append('platform', platform);
const response = await contentApiClient.get<ImageInfo[]>(
`/events/${eventId}/images${params.toString() ? `?${params.toString()}` : ''}`
);
return response.data;
},
// 특정 이미지 조회
getImageById: async (imageId: number): Promise<ImageInfo> => {
const response = await contentApiClient.get<ImageInfo>(`/api/v1/content/images/${imageId}`);
return response.data;
},
// 이미지 삭제
deleteImage: async (imageId: number): Promise<void> => {
await contentApiClient.delete(`/api/v1/content/images/${imageId}`);
},
// 이미지 재생성
regenerateImage: async (imageId: number, newPrompt?: string): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>(
`/api/v1/content/images/${imageId}/regenerate`,
{ imageId, newPrompt }
);
return response.data;
},
};
export default contentApi;

329
src/shared/api/eventApi.ts Normal file
View File

@ -0,0 +1,329 @@
import axios, { AxiosInstance } from 'axios';
// Event Service API 클라이언트
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
export const eventApiClient: AxiosInstance = axios.create({
baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`,
timeout: 30000, // Job 폴링 고려
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
eventApiClient.interceptors.request.use(
(config) => {
console.log('📅 Event API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
data: config.data,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
console.error('❌ Event API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
eventApiClient.interceptors.response.use(
(response) => {
console.log('✅ Event API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ Event API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
data: error.response?.data,
});
return Promise.reject(error);
}
);
// Types
export interface EventObjectiveRequest {
objective: string; // "신규 고객 유치", "재방문 유도", "매출 증대", "브랜드 인지도 향상"
}
export interface EventCreatedResponse {
eventId: string;
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
objective: string;
createdAt: string;
}
export interface AiRecommendationRequest {
storeInfo: {
storeId: string;
storeName: string;
category: string;
description?: string;
};
}
export interface JobAcceptedResponse {
jobId: string;
status: 'PENDING';
message: string;
}
export interface EventJobStatusResponse {
jobId: string;
jobType: 'AI_RECOMMENDATION' | 'IMAGE_GENERATION';
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultKey?: string;
errorMessage?: string;
createdAt: string;
completedAt?: string;
}
export interface SelectRecommendationRequest {
recommendationId: string;
customizations?: {
eventName?: string;
description?: string;
startDate?: string;
endDate?: string;
discountRate?: number;
};
}
export interface ImageGenerationRequest {
eventInfo: {
eventName: string;
description: string;
promotionType: string;
};
imageCount?: number;
}
export interface SelectChannelsRequest {
channels: ('WEBSITE' | 'KAKAO' | 'INSTAGRAM' | 'FACEBOOK' | 'NAVER_BLOG')[];
}
export interface ChannelDistributionResult {
channel: string;
success: boolean;
url?: string;
message: string;
}
export interface EventPublishedResponse {
eventId: string;
status: 'PUBLISHED';
publishedAt: string;
channels: string[];
distributionResults: ChannelDistributionResult[];
}
export interface EventSummary {
eventId: string;
eventName: string;
objective: string;
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
startDate: string;
endDate: string;
thumbnailUrl?: string;
createdAt: string;
}
export interface PageInfo {
page: number;
size: number;
totalElements: number;
totalPages: number;
}
export interface EventListResponse {
content: EventSummary[];
page: PageInfo;
}
export interface GeneratedImage {
imageId: string;
imageUrl: string;
isSelected: boolean;
createdAt: string;
}
export interface AiRecommendation {
recommendationId: string;
eventName: string;
description: string;
promotionType: string;
targetAudience: string;
isSelected: boolean;
}
export interface EventDetailResponse {
eventId: string;
userId: string;
storeId: string;
eventName: string;
objective: string;
description: string;
targetAudience: string;
promotionType: string;
discountRate?: number;
startDate: string;
endDate: string;
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
selectedImageId?: string;
selectedImageUrl?: string;
generatedImages?: GeneratedImage[];
channels?: string[];
aiRecommendations?: AiRecommendation[];
createdAt: string;
updatedAt: string;
}
export interface UpdateEventRequest {
eventName?: string;
description?: string;
startDate?: string;
endDate?: string;
discountRate?: number;
}
export interface EndEventRequest {
reason: string;
}
// API Functions
export const eventApi = {
// Step 1: 목적 선택 및 이벤트 생성
selectObjective: async (objective: string): Promise<EventCreatedResponse> => {
const response = await eventApiClient.post<EventCreatedResponse>('/events/objectives', {
objective,
});
return response.data;
},
// Step 2: AI 추천 요청
requestAiRecommendations: async (
eventId: string,
storeInfo: AiRecommendationRequest['storeInfo']
): Promise<JobAcceptedResponse> => {
const response = await eventApiClient.post<JobAcceptedResponse>(
`/events/${eventId}/ai-recommendations`,
{ storeInfo }
);
return response.data;
},
// Job 상태 폴링
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
return response.data;
},
// AI 추천 선택
selectRecommendation: async (
eventId: string,
request: SelectRecommendationRequest
): Promise<EventDetailResponse> => {
const response = await eventApiClient.put<EventDetailResponse>(
`/events/${eventId}/recommendations`,
request
);
return response.data;
},
// Step 3: 이미지 생성 요청
requestImageGeneration: async (
eventId: string,
request: ImageGenerationRequest
): Promise<JobAcceptedResponse> => {
const response = await eventApiClient.post<JobAcceptedResponse>(`/events/${eventId}/images`, request);
return response.data;
},
// 이미지 선택
selectImage: async (eventId: string, imageId: string): Promise<EventDetailResponse> => {
const response = await eventApiClient.put<EventDetailResponse>(
`/events/${eventId}/images/${imageId}/select`
);
return response.data;
},
// Step 4: 이미지 편집
editImage: async (
eventId: string,
imageId: string,
editRequest: any
): Promise<{ imageId: string; imageUrl: string; editedAt: string }> => {
const response = await eventApiClient.put(`/events/${eventId}/images/${imageId}/edit`, editRequest);
return response.data;
},
// Step 5: 배포 채널 선택
selectChannels: async (eventId: string, channels: string[]): Promise<EventDetailResponse> => {
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}/channels`, {
channels,
});
return response.data;
},
// Step 6: 최종 배포
publishEvent: async (eventId: string): Promise<EventPublishedResponse> => {
const response = await eventApiClient.post<EventPublishedResponse>(`/events/${eventId}/publish`);
return response.data;
},
// 이벤트 목록 조회
getEvents: async (params?: {
status?: 'DRAFT' | 'PUBLISHED' | 'ENDED';
objective?: string;
search?: string;
page?: number;
size?: number;
sort?: string;
order?: 'asc' | 'desc';
}): Promise<EventListResponse> => {
const response = await eventApiClient.get<EventListResponse>('/events', { params });
return response.data;
},
// 이벤트 상세 조회
getEventDetail: async (eventId: string): Promise<EventDetailResponse> => {
const response = await eventApiClient.get<EventDetailResponse>(`/events/${eventId}`);
return response.data;
},
// 이벤트 수정
updateEvent: async (eventId: string, request: UpdateEventRequest): Promise<EventDetailResponse> => {
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}`, request);
return response.data;
},
// 이벤트 삭제
deleteEvent: async (eventId: string): Promise<void> => {
await eventApiClient.delete(`/events/${eventId}`);
},
// 이벤트 조기 종료
endEvent: async (eventId: string, reason: string): Promise<EventDetailResponse> => {
const response = await eventApiClient.post<EventDetailResponse>(`/events/${eventId}/end`, {
reason,
});
return response.data;
},
};
export default eventApi;

View File

@ -1,2 +1,6 @@
export { apiClient } from './client';
export { apiClient, participationClient } from './client';
export type { ApiError } from './types';
export * from './contentApi';
export * from './aiApi';
export * from './eventApi';
export * from './participation.api';

View File

@ -0,0 +1,153 @@
import { participationClient } from './client';
import type {
ApiResponse,
PageResponse,
ParticipationRequest,
ParticipationResponse,
GetParticipantsParams,
} from '../types/api.types';
/**
* Participation API Service
* API
*/
/**
*
* POST /v1/events/{eventId}/participate
*/
export const participate = async (
eventId: string,
data: ParticipationRequest
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await participationClient.post<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participate`,
data
);
return response.data;
};
/**
* ()
* GET /v1/events/{eventId}/participants
*/
export const getParticipants = async (
params: GetParticipantsParams
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params;
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/participants`,
{
params: {
storeVisited,
page,
size,
sort,
},
}
);
return response.data;
};
/**
*
* GET /v1/events/{eventId}/participants/{participantId}
*/
export const getParticipant = async (
eventId: string,
participantId: string
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await participationClient.get<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participants/${participantId}`
);
return response.data;
};
/**
* ( )
* API는 ,
*
*/
export const searchParticipants = async (
eventId: string,
searchTerm: string,
storeVisited?: boolean
): Promise<ParticipationResponse[]> => {
// 모든 페이지 데이터 가져오기
let allParticipants: ParticipationResponse[] = [];
let currentPage = 0;
let hasMore = true;
while (hasMore) {
const response = await getParticipants({
eventId,
storeVisited,
page: currentPage,
size: 100, // 한 번에 많이 가져오기
});
allParticipants = [...allParticipants, ...response.data.content];
hasMore = !response.data.last;
currentPage++;
}
// 검색어로 필터링
if (searchTerm) {
const term = searchTerm.toLowerCase();
return allParticipants.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
p.phoneNumber.includes(term) ||
p.email?.toLowerCase().includes(term)
);
}
return allParticipants;
};
// ===================================
// Winner API Functions
// ===================================
/**
*
* POST /v1/events/{eventId}/draw-winners
*/
export const drawWinners = async (
eventId: string,
winnerCount: number,
applyStoreVisitBonus?: boolean
): Promise<ApiResponse<import('../types/api.types').DrawWinnersResponse>> => {
const response = await participationClient.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
`/v1/events/${eventId}/draw-winners`,
{
winnerCount,
applyStoreVisitBonus,
}
);
return response.data;
};
/**
*
* GET /v1/events/{eventId}/winners
*/
export const getWinners = async (
eventId: string,
page = 0,
size = 20,
sort: string[] = ['winnerRank,ASC']
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const response = await participationClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/winners`,
{
params: {
page,
size,
sort,
},
}
);
return response.data;
};

View File

@ -0,0 +1,151 @@
// ===================================
// Common API Response Types
// ===================================
/**
* API
*/
export interface ApiResponse<T = unknown> {
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;
}
// ===================================
// Participation API Types
// ===================================
/**
*
*/
export interface ParticipationRequest {
/** 이름 (2-50자, 필수) */
name: string;
/** 전화번호 (패턴: ^\d{3}-\d{3,4}-\d{4}$, 필수) */
phoneNumber: string;
/** 이메일 (선택) */
email?: string;
/** 참여 경로 (선택) */
channel?: string;
/** 마케팅 동의 (선택) */
agreeMarketing?: boolean;
/** 개인정보 수집 동의 (필수) */
agreePrivacy: boolean;
/** 매장 방문 여부 (선택) */
storeVisited?: boolean;
}
/**
*
*/
export interface ParticipationResponse {
/** 참여자 ID */
participantId: string;
/** 이벤트 ID */
eventId: string;
/** 이름 */
name: string;
/** 전화번호 */
phoneNumber: string;
/** 이메일 */
email?: string;
/** 참여 경로 */
channel?: string;
/** 참여 일시 */
participatedAt: string;
/** 매장 방문 여부 */
storeVisited: boolean;
/** 보너스 응모권 수 */
bonusEntries: number;
/** 당첨 여부 */
isWinner: boolean;
}
/**
*
*/
export interface GetParticipantsParams {
/** 이벤트 ID */
eventId: string;
/** 매장 방문 여부 필터 (true: 방문자만, false: 미방문자만, null: 전체) */
storeVisited?: boolean;
/** 페이지 번호 (0부터 시작) */
page?: number;
/** 페이지 크기 */
size?: number;
/** 정렬 기준 (예: createdAt,DESC) */
sort?: string[];
}
// ===================================
// Winner API Types
// ===================================
/**
*
*/
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[];
}
/**
*
*/
export interface GetWinnersParams {
/** 이벤트 ID */
eventId: string;
/** 페이지 번호 (0부터 시작) */
page?: number;
/** 페이지 크기 */
size?: number;
/** 정렬 기준 (예: winnerRank,ASC) */
sort?: string[];
}

124
test-images.html Normal file
View File

@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Event 7777 - AI Generated Images Test</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
text-align: center;
color: #333;
}
.images-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.image-card {
background: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.image-card h2 {
margin: 0 0 10px 0;
color: #666;
font-size: 18px;
}
.image-card img {
width: 100%;
height: auto;
border-radius: 8px;
aspect-ratio: 1 / 1;
object-fit: cover;
}
.image-info {
margin-top: 10px;
font-size: 12px;
color: #999;
}
.loading {
text-align: center;
padding: 50px;
font-size: 18px;
color: #666;
}
</style>
</head>
<body>
<h1>🎨 Event 7777 - AI Generated Images</h1>
<div id="content" class="loading">Loading images...</div>
<script>
const eventId = 7777;
const apiUrl = `http://localhost:8084/api/v1/content/events/${eventId}/images`;
async function loadImages() {
try {
const response = await fetch(apiUrl);
const images = await response.json();
// 스타일별로 최신 이미지 필터링
const imagesByStyle = {};
images.forEach(img => {
if (img.platform === 'INSTAGRAM') {
const existing = imagesByStyle[img.style];
if (!existing || new Date(img.createdAt) > new Date(existing.createdAt)) {
imagesByStyle[img.style] = img;
}
}
});
// HTML 생성
const container = document.getElementById('content');
container.className = 'images-grid';
container.innerHTML = '';
['SIMPLE', 'FANCY', 'TRENDY'].forEach(style => {
const image = imagesByStyle[style];
if (image) {
const card = document.createElement('div');
card.className = 'image-card';
card.innerHTML = `
<h2>스타일: ${style}</h2>
<img src="${image.cdnUrl}" alt="${style}" onerror="this.onerror=null; this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%22400%22 height=%22400%22%3E%3Crect fill=%22%23ddd%22 width=%22400%22 height=%22400%22/%3E%3Ctext fill=%22%23999%22 font-size=%2224%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22%3EImage Load Error%3C/text%3E%3C/svg%3E';">
<div class="image-info">
<div>ID: ${image.id}</div>
<div>Created: ${new Date(image.createdAt).toLocaleString('ko-KR')}</div>
</div>
`;
container.appendChild(card);
} else {
const card = document.createElement('div');
card.className = 'image-card';
card.innerHTML = `
<h2>스타일: ${style}</h2>
<div style="aspect-ratio: 1/1; background: #f0f0f0; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #999;">
이미지 없음
</div>
`;
container.appendChild(card);
}
});
} catch (error) {
document.getElementById('content').innerHTML = `
<div style="text-align: center; color: red;">
<h2>Error loading images</h2>
<p>${error.message}</p>
</div>
`;
}
}
loadImages();
</script>
</body>
</html>

182
test-localstorage.html Normal file
View File

@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LocalStorage 테스트</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 50px auto;
padding: 20px;
background: #f5f5f5;
}
.container {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
color: #666;
font-weight: 500;
}
input, textarea {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
textarea {
min-height: 80px;
resize: vertical;
}
button {
background: linear-gradient(135deg, #C084FC 0%, #F472B6 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
margin-right: 10px;
}
button:hover {
opacity: 0.9;
}
button.secondary {
background: #6b7280;
}
.success {
background: #10b981;
color: white;
padding: 15px;
border-radius: 6px;
margin-top: 20px;
display: none;
}
.current-data {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 6px;
padding: 15px;
margin-top: 20px;
}
.current-data pre {
margin: 10px 0 0 0;
overflow-x: auto;
}
</style>
</head>
<body>
<div class="container">
<h1>🎨 이벤트 생성 데이터 설정</h1>
<div class="form-group">
<label>이벤트 ID</label>
<input type="number" id="eventDraftId" value="7777">
</div>
<div class="form-group">
<label>이벤트 제목</label>
<input type="text" id="eventTitle" value="맥주 파티 이벤트">
</div>
<div class="form-group">
<label>이벤트 설명</label>
<textarea id="eventDescription">강남에서 열리는 신나는 맥주 파티에 참여하세요!</textarea>
</div>
<div class="form-group">
<label>업종</label>
<input type="text" id="industry" value="음식점">
</div>
<div class="form-group">
<label>지역</label>
<input type="text" id="location" value="강남">
</div>
<div class="form-group">
<label>트렌드 (쉼표로 구분)</label>
<input type="text" id="trends" value="파티,맥주,생맥주">
</div>
<div class="form-group">
<label>경품</label>
<input type="text" id="prize" value="생맥주 1잔">
</div>
<div style="margin-top: 25px;">
<button onclick="saveToLocalStorage()">💾 LocalStorage에 저장</button>
<button class="secondary" onclick="clearLocalStorage()">🗑️ 삭제</button>
<button class="secondary" onclick="loadCurrentData()">🔄 현재 데이터 보기</button>
</div>
<div id="success" class="success">
✅ localStorage에 저장되었습니다!<br>
이제 이벤트 생성 플로우에서 channel → contentPreview로 이동하면<br>
자동으로 AI 이미지 생성이 시작됩니다.
</div>
<div class="current-data">
<strong>현재 localStorage 데이터:</strong>
<pre id="currentData">없음</pre>
</div>
</div>
<script>
function saveToLocalStorage() {
const eventData = {
eventDraftId: parseInt(document.getElementById('eventDraftId').value),
eventTitle: document.getElementById('eventTitle').value,
eventDescription: document.getElementById('eventDescription').value,
industry: document.getElementById('industry').value,
location: document.getElementById('location').value,
trends: document.getElementById('trends').value.split(',').map(t => t.trim()),
prize: document.getElementById('prize').value
};
localStorage.setItem('eventCreationData', JSON.stringify(eventData));
document.getElementById('success').style.display = 'block';
setTimeout(() => {
document.getElementById('success').style.display = 'none';
}, 5000);
loadCurrentData();
}
function clearLocalStorage() {
localStorage.removeItem('eventCreationData');
document.getElementById('currentData').textContent = '없음';
alert('localStorage가 삭제되었습니다.');
}
function loadCurrentData() {
const data = localStorage.getItem('eventCreationData');
if (data) {
document.getElementById('currentData').textContent = JSON.stringify(JSON.parse(data), null, 2);
} else {
document.getElementById('currentData').textContent = '없음';
}
}
// 페이지 로드 시 현재 데이터 표시
window.onload = loadCurrentData;
</script>
</body>
</html>