mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 12:16:24 +00:00
Merge remote-tracking branch 'origin/develop' into feature/analytics
This commit is contained in:
commit
ac9e7125d1
263
API_CHANGES.md
Normal file
263
API_CHANGES.md
Normal 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
221
CORS_FIX.md
Normal 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
182
FIX_EVENTID_MISMATCH.md
Normal 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
149
MOCK_DATA_SETUP.md
Normal 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
145
QUICK_TEST.md
Normal 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
117
TEST_URLS.md
Normal 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();
|
||||||
|
```
|
||||||
@ -6,7 +6,7 @@ const nextConfig = {
|
|||||||
emotion: true,
|
emotion: true,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['localhost'],
|
domains: ['localhost', 'blobkteventstorage.blob.core.windows.net'],
|
||||||
formats: ['image/webp', 'image/avif'],
|
formats: ['image/webp', 'image/avif'],
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
@ -19,6 +19,11 @@ const nextConfig = {
|
|||||||
source: '/api/proxy/:path*',
|
source: '/api/proxy/:path*',
|
||||||
destination: 'http://localhost:8084/api/:path*',
|
destination: 'http://localhost:8084/api/:path*',
|
||||||
},
|
},
|
||||||
|
// Event Service API Proxy (8080 포트)
|
||||||
|
{
|
||||||
|
source: '/api/v1/events/:path*',
|
||||||
|
destination: 'http://localhost:8080/api/v1/events/:path*',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
205
public/init-mock-data.html
Normal file
205
public/init-mock-data.html
Normal 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
17
setup-mock-data.js
Normal 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));
|
||||||
@ -15,19 +15,50 @@ export type BudgetLevel = 'low' | 'medium' | 'high';
|
|||||||
export type EventMethod = 'online' | 'offline';
|
export type EventMethod = 'online' | 'offline';
|
||||||
|
|
||||||
export interface EventData {
|
export interface EventData {
|
||||||
|
eventDraftId?: number;
|
||||||
objective?: EventObjective;
|
objective?: EventObjective;
|
||||||
recommendation?: {
|
recommendation?: {
|
||||||
budget: BudgetLevel;
|
recommendation: {
|
||||||
method: EventMethod;
|
optionNumber: number;
|
||||||
title: string;
|
concept: string;
|
||||||
prize: string;
|
title: string;
|
||||||
participationMethod: string;
|
description: string;
|
||||||
expectedParticipants: number;
|
targetAudience: string;
|
||||||
estimatedCost: number;
|
duration: {
|
||||||
roi: number;
|
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?: {
|
contentPreview?: {
|
||||||
imageStyle: string;
|
imageStyle: string;
|
||||||
|
images?: any[];
|
||||||
};
|
};
|
||||||
contentEdit?: {
|
contentEdit?: {
|
||||||
title: string;
|
title: string;
|
||||||
@ -89,6 +120,18 @@ export default function EventCreatePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (needsContent) {
|
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 });
|
history.push('contentPreview', { ...context, channels });
|
||||||
} else {
|
} else {
|
||||||
history.push('approval', { ...context, channels });
|
history.push('approval', { ...context, channels });
|
||||||
@ -101,12 +144,13 @@ export default function EventCreatePage() {
|
|||||||
)}
|
)}
|
||||||
contentPreview={({ context, history }) => (
|
contentPreview={({ context, history }) => (
|
||||||
<ContentPreviewStep
|
<ContentPreviewStep
|
||||||
title={context.recommendation?.title || ''}
|
eventId={context.recommendation?.eventId}
|
||||||
prize={context.recommendation?.prize || ''}
|
eventTitle={context.recommendation?.recommendation.title}
|
||||||
onNext={(imageStyle) => {
|
eventDescription={context.recommendation?.recommendation.description}
|
||||||
|
onNext={(imageStyle, images) => {
|
||||||
history.push('contentEdit', {
|
history.push('contentEdit', {
|
||||||
...context,
|
...context,
|
||||||
contentPreview: { imageStyle },
|
contentPreview: { imageStyle, images },
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onSkip={() => {
|
onSkip={() => {
|
||||||
@ -119,8 +163,8 @@ export default function EventCreatePage() {
|
|||||||
)}
|
)}
|
||||||
contentEdit={({ context, history }) => (
|
contentEdit={({ context, history }) => (
|
||||||
<ContentEditStep
|
<ContentEditStep
|
||||||
initialTitle={context.recommendation?.title || ''}
|
initialTitle={context.recommendation?.recommendation.title || ''}
|
||||||
initialPrize={context.recommendation?.prize || ''}
|
initialPrize={''}
|
||||||
onNext={(contentEdit) => {
|
onNext={(contentEdit) => {
|
||||||
history.push('approval', { ...context, contentEdit });
|
history.push('approval', { ...context, contentEdit });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -20,6 +20,8 @@ import {
|
|||||||
import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, TrendingUp } from '@mui/icons-material';
|
import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, TrendingUp } from '@mui/icons-material';
|
||||||
import { EventData } from '../page';
|
import { EventData } from '../page';
|
||||||
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
||||||
|
import { eventApi } from '@/entities/event/api/eventApi';
|
||||||
|
import type { EventObjective } from '@/entities/event/model/types';
|
||||||
|
|
||||||
interface ApprovalStepProps {
|
interface ApprovalStepProps {
|
||||||
eventData: EventData;
|
eventData: EventData;
|
||||||
@ -33,16 +35,98 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
||||||
const [isDeploying, setIsDeploying] = useState(false);
|
const [isDeploying, setIsDeploying] = useState(false);
|
||||||
|
|
||||||
const handleApprove = () => {
|
const handleApprove = async () => {
|
||||||
if (!agreeTerms) return;
|
if (!agreeTerms) return;
|
||||||
|
|
||||||
setIsDeploying(true);
|
setIsDeploying(true);
|
||||||
|
|
||||||
// 배포 시뮬레이션
|
try {
|
||||||
setTimeout(() => {
|
// 1. 이벤트 생성 API 호출
|
||||||
|
console.log('📞 Creating event with objective:', eventData.objective);
|
||||||
|
|
||||||
|
// objective 매핑 (Frontend → Backend)
|
||||||
|
const objectiveMap: Record<string, EventObjective> = {
|
||||||
|
'new_customer': 'CUSTOMER_ACQUISITION',
|
||||||
|
'revisit': 'Customer Retention',
|
||||||
|
'sales': 'Sales Promotion',
|
||||||
|
'awareness': 'awareness',
|
||||||
|
};
|
||||||
|
|
||||||
|
const backendObjective: EventObjective = (objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION') as EventObjective;
|
||||||
|
|
||||||
|
const createResponse = await eventApi.createEvent({
|
||||||
|
objective: backendObjective,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Event created:', createResponse);
|
||||||
|
|
||||||
|
if (createResponse.success && createResponse.data) {
|
||||||
|
const eventId = createResponse.data.eventId;
|
||||||
|
console.log('🎯 Event ID:', eventId);
|
||||||
|
|
||||||
|
// 2. 이벤트 상세 정보 업데이트
|
||||||
|
console.log('📞 Updating event details:', eventId);
|
||||||
|
|
||||||
|
// 이벤트명 가져오기 (contentEdit.title 또는 recommendation.title)
|
||||||
|
const eventName = eventData.contentEdit?.title || eventData.recommendation?.recommendation?.title || '이벤트';
|
||||||
|
|
||||||
|
// 날짜 설정 (오늘부터 30일간)
|
||||||
|
const today = new Date();
|
||||||
|
const endDate = new Date(today);
|
||||||
|
endDate.setDate(endDate.getDate() + 30);
|
||||||
|
|
||||||
|
const startDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
const endDateStr = endDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
await eventApi.updateEvent(eventId, {
|
||||||
|
eventName: eventName,
|
||||||
|
description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '',
|
||||||
|
startDate: startDateStr,
|
||||||
|
endDate: endDateStr,
|
||||||
|
});
|
||||||
|
console.log('✅ Event details updated');
|
||||||
|
|
||||||
|
// 3. 배포 채널 선택
|
||||||
|
if (eventData.channels && eventData.channels.length > 0) {
|
||||||
|
console.log('📞 Selecting channels:', eventData.channels);
|
||||||
|
|
||||||
|
// 채널명 매핑 (Frontend → Backend)
|
||||||
|
const channelMap: Record<string, string> = {
|
||||||
|
'uriTV': 'WEBSITE',
|
||||||
|
'ringoBiz': 'EMAIL',
|
||||||
|
'genieTV': 'KAKAO',
|
||||||
|
'sns': 'INSTAGRAM',
|
||||||
|
};
|
||||||
|
|
||||||
|
const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase());
|
||||||
|
|
||||||
|
await eventApi.selectChannels(eventId, {
|
||||||
|
channels: backendChannels,
|
||||||
|
});
|
||||||
|
console.log('✅ Channels selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. TODO: 이미지 선택
|
||||||
|
// 현재 frontend에서 selectedImageId를 추적하지 않음
|
||||||
|
// 향후 contentPreview 단계에서 선택된 이미지 ID를 eventData에 저장 필요
|
||||||
|
console.log('⚠️ Image selection skipped - imageId not tracked in frontend');
|
||||||
|
|
||||||
|
// 5. 이벤트 배포 API 호출
|
||||||
|
console.log('📞 Publishing event:', eventId);
|
||||||
|
const publishResponse = await eventApi.publishEvent(eventId);
|
||||||
|
console.log('✅ Event published:', publishResponse);
|
||||||
|
|
||||||
|
// 성공 다이얼로그 표시
|
||||||
|
setIsDeploying(false);
|
||||||
|
setSuccessDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Event creation failed: No event ID returned');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Event deployment failed:', error);
|
||||||
setIsDeploying(false);
|
setIsDeploying(false);
|
||||||
setSuccessDialogOpen(true);
|
alert('이벤트 배포에 실패했습니다. 다시 시도해 주세요.');
|
||||||
}, 2000);
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const handleSaveDraft = () => {
|
||||||
@ -120,7 +204,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eventData.recommendation?.title || '이벤트 제목'}
|
{eventData.recommendation?.recommendation.title || '이벤트 제목'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -158,7 +242,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
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={{
|
<Typography component="span" sx={{
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
ml: 0.5,
|
ml: 0.5,
|
||||||
@ -204,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
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={{
|
<Typography component="span" sx={{
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
ml: 0.5,
|
ml: 0.5,
|
||||||
@ -250,7 +334,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{eventData.recommendation?.roi || 0}%
|
{eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -270,7 +354,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
이벤트 제목
|
이벤트 제목
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||||
{eventData.recommendation?.title}
|
{eventData.recommendation?.recommendation.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small">
|
<IconButton size="small">
|
||||||
@ -288,7 +372,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
경품
|
경품
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||||
{eventData.recommendation?.prize}
|
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="small">
|
<IconButton size="small">
|
||||||
@ -306,7 +390,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
참여 방법
|
참여 방법
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||||
{eventData.recommendation?.participationMethod}
|
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -12,8 +12,11 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Dialog,
|
Dialog,
|
||||||
Grid,
|
Grid,
|
||||||
|
Alert,
|
||||||
} from '@mui/material';
|
} 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 = {
|
const colors = {
|
||||||
@ -34,7 +37,7 @@ const colors = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface ImageStyle {
|
interface ImageStyle {
|
||||||
id: string;
|
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
|
||||||
name: string;
|
name: string;
|
||||||
gradient?: string;
|
gradient?: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
@ -43,19 +46,19 @@ interface ImageStyle {
|
|||||||
|
|
||||||
const imageStyles: ImageStyle[] = [
|
const imageStyles: ImageStyle[] = [
|
||||||
{
|
{
|
||||||
id: 'simple',
|
id: 'SIMPLE',
|
||||||
name: '스타일 1: 심플',
|
name: '스타일 1: 심플',
|
||||||
icon: 'celebration',
|
icon: 'celebration',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'fancy',
|
id: 'FANCY',
|
||||||
name: '스타일 2: 화려',
|
name: '스타일 2: 화려',
|
||||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
icon: 'auto_awesome',
|
icon: 'auto_awesome',
|
||||||
textColor: 'white',
|
textColor: 'white',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'trendy',
|
id: 'TRENDY',
|
||||||
name: '스타일 3: 트렌디',
|
name: '스타일 3: 트렌디',
|
||||||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||||
icon: 'trending_up',
|
icon: 'trending_up',
|
||||||
@ -64,50 +67,246 @@ const imageStyles: ImageStyle[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
interface ContentPreviewStepProps {
|
interface ContentPreviewStepProps {
|
||||||
title: string;
|
eventId?: string;
|
||||||
prize: string;
|
eventTitle?: string;
|
||||||
onNext: (imageStyle: string) => void;
|
eventDescription?: string;
|
||||||
|
onNext: (imageStyle: string, images: ImageInfo[]) => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EventCreationData {
|
||||||
|
eventDraftId: string;
|
||||||
|
eventTitle: string;
|
||||||
|
eventDescription: string;
|
||||||
|
industry: string;
|
||||||
|
location: string;
|
||||||
|
trends: string[];
|
||||||
|
prize: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContentPreviewStep({
|
export default function ContentPreviewStep({
|
||||||
title,
|
eventId: propsEventId,
|
||||||
prize,
|
eventTitle: propsEventTitle,
|
||||||
|
eventDescription: propsEventDescription,
|
||||||
onNext,
|
onNext,
|
||||||
onSkip,
|
onSkip,
|
||||||
onBack,
|
onBack,
|
||||||
}: ContentPreviewStepProps) {
|
}: ContentPreviewStepProps) {
|
||||||
const [loading, setLoading] = useState(true);
|
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 [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(() => {
|
useEffect(() => {
|
||||||
// AI 이미지 생성 시뮬레이션
|
// localStorage에서 이벤트 데이터 읽기
|
||||||
const timer = setTimeout(() => {
|
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);
|
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);
|
setSelectedStyle(styleId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreview = (style: ImageStyle, e: React.MouseEvent) => {
|
const handlePreview = (image: ImageInfo, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setFullscreenStyle(style);
|
setFullscreenImage(image);
|
||||||
setFullscreenOpen(true);
|
setFullscreenOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (selectedStyle) {
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
@ -121,7 +320,7 @@ export default function ContentPreviewStep({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ textAlign: 'center', mt: 15, mb: 15 }}>
|
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
|
||||||
{/* 그라데이션 스피너 */}
|
{/* 그라데이션 스피너 */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -161,17 +360,69 @@ export default function ContentPreviewStep({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, fontSize: '1.5rem' }}>
|
|
||||||
AI 이미지 생성 중
|
{/* 진행률 바 */}
|
||||||
</Typography>
|
<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' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, fontSize: '1.125rem' }}>
|
||||||
딥러닝 모델이 이벤트에 어울리는
|
{generatedImages.size > 0 ? (
|
||||||
<br />
|
<>
|
||||||
이미지를 생성하고 있어요...
|
생성된 이미지를 확인하고 있어요
|
||||||
</Typography>
|
<br />
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
잠시만 기다려주세요!
|
||||||
예상 시간: 5초
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
AI가 이벤트에 맞는 이미지를 생성하고 있어요
|
||||||
|
<br />
|
||||||
|
약 60초 정도 소요됩니다
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
@ -179,7 +430,18 @@ export default function ContentPreviewStep({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
||||||
@ -191,11 +453,35 @@ export default function ContentPreviewStep({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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 variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
|
||||||
이벤트에 어울리는 스타일을 선택하세요
|
이벤트에 어울리는 스타일을 선택하세요
|
||||||
</Typography>
|
</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 }}>
|
<Grid container spacing={6} sx={{ mb: 10 }}>
|
||||||
{imageStyles.map((style) => (
|
{imageStyles.map((style) => (
|
||||||
<Grid item xs={12} md={4} key={style.id}>
|
<Grid item xs={12} md={4} key={style.id}>
|
||||||
@ -237,46 +523,82 @@ export default function ContentPreviewStep({
|
|||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
background: style.gradient || colors.gray[100],
|
position: 'relative',
|
||||||
display: 'flex',
|
overflow: 'hidden',
|
||||||
flexDirection: 'column',
|
bgcolor: colors.gray[100],
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
p: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{(() => {
|
||||||
className="material-icons"
|
const hasImage = generatedImages.has(style.id);
|
||||||
style={{
|
const imageData = generatedImages.get(style.id);
|
||||||
fontSize: 64,
|
console.log(`🖼️ Rendering ${style.id}:`, {
|
||||||
marginBottom: 24,
|
hasImage,
|
||||||
color: style.textColor || colors.gray[700],
|
imageDataExists: !!imageData,
|
||||||
}}
|
fullCdnUrl: imageData?.cdnUrl,
|
||||||
>
|
mapSize: generatedImages.size,
|
||||||
{style.icon}
|
mapKeys: Array.from(generatedImages.keys()),
|
||||||
</span>
|
});
|
||||||
<Typography
|
return hasImage && imageData ? (
|
||||||
variant="h6"
|
<Image
|
||||||
sx={{
|
src={imageData.cdnUrl}
|
||||||
fontWeight: 700,
|
alt={style.name}
|
||||||
mb: 2,
|
fill
|
||||||
color: style.textColor || 'text.primary',
|
style={{ objectFit: 'cover' }}
|
||||||
fontSize: '1.25rem',
|
unoptimized
|
||||||
}}
|
onLoad={() => console.log(`✅ ${style.id} image loaded successfully from:`, imageData.cdnUrl)}
|
||||||
>
|
onError={(e) => {
|
||||||
{title}
|
console.error(`❌ ${style.id} image load error:`, e);
|
||||||
</Typography>
|
console.error(` Failed URL:`, imageData.cdnUrl);
|
||||||
<Typography
|
}}
|
||||||
variant="body1"
|
/>
|
||||||
sx={{
|
) : (
|
||||||
color: style.textColor || 'text.secondary',
|
<Box
|
||||||
opacity: style.textColor ? 0.9 : 1,
|
sx={{
|
||||||
fontSize: '1rem',
|
width: '100%',
|
||||||
}}
|
height: '100%',
|
||||||
>
|
background: style.gradient || colors.gray[100],
|
||||||
{prize}
|
display: 'flex',
|
||||||
</Typography>
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
p: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-icons"
|
||||||
|
style={{
|
||||||
|
fontSize: 64,
|
||||||
|
marginBottom: 24,
|
||||||
|
color: style.textColor || colors.gray[700],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{style.icon}
|
||||||
|
</span>
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 700,
|
||||||
|
mb: 2,
|
||||||
|
color: style.textColor || 'text.primary',
|
||||||
|
fontSize: '1.25rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventData?.eventTitle || '이벤트'}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
color: style.textColor || 'text.secondary',
|
||||||
|
opacity: style.textColor ? 0.9 : 1,
|
||||||
|
fontSize: '1rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventData?.prize || '경품'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 크게보기 버튼 */}
|
{/* 크게보기 버튼 */}
|
||||||
@ -284,7 +606,13 @@ export default function ContentPreviewStep({
|
|||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<ZoomIn />}
|
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={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
py: 1.5,
|
py: 1.5,
|
||||||
@ -387,51 +715,24 @@ export default function ContentPreviewStep({
|
|||||||
<span className="material-icons">close</span>
|
<span className="material-icons">close</span>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
||||||
{fullscreenStyle && (
|
{fullscreenImage && (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 600,
|
maxWidth: 800,
|
||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
background: fullscreenStyle.gradient || '#f5f5f5',
|
position: 'relative',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
display: 'flex',
|
overflow: 'hidden',
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
p: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<Image
|
||||||
className="material-icons"
|
src={fullscreenImage.cdnUrl}
|
||||||
style={{
|
alt={`${fullscreenImage.style} style`}
|
||||||
fontSize: 80,
|
fill
|
||||||
marginBottom: 24,
|
style={{ objectFit: 'contain' }}
|
||||||
color: fullscreenStyle.textColor || 'inherit',
|
unoptimized
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { useState } from 'react';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@ -13,11 +15,12 @@ import {
|
|||||||
RadioGroup,
|
RadioGroup,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tabs,
|
CircularProgress,
|
||||||
Tab,
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
||||||
import { EventObjective, BudgetLevel, EventMethod } from '../page';
|
import { EventObjective, BudgetLevel, EventMethod } from '../page';
|
||||||
|
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
|
||||||
|
|
||||||
// 디자인 시스템 색상
|
// 디자인 시스템 색상
|
||||||
const colors = {
|
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 {
|
interface RecommendationStepProps {
|
||||||
objective?: EventObjective;
|
objective?: EventObjective;
|
||||||
onNext: (data: Recommendation) => void;
|
eventId?: string; // 이전 단계에서 생성된 eventId
|
||||||
|
onNext: (data: {
|
||||||
|
recommendation: EventRecommendation;
|
||||||
|
eventId: string;
|
||||||
|
}) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) {
|
export default function RecommendationStep({
|
||||||
const [selectedBudget, setSelectedBudget] = useState<BudgetLevel>('low');
|
objective,
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
eventId: initialEventId,
|
||||||
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({});
|
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 = () => {
|
// 컴포넌트 마운트 시 AI 추천 요청
|
||||||
const selectedRec = mockRecommendations.find((r) => r.id === selected);
|
useEffect(() => {
|
||||||
if (selectedRec && selected) {
|
if (!eventId && objective) {
|
||||||
const edited = editedData[selected];
|
// Step 1: 이벤트 생성
|
||||||
onNext({
|
createEventAndRequestAI();
|
||||||
...selectedRec,
|
} else if (eventId) {
|
||||||
title: edited?.title || selectedRec.title,
|
// 이미 eventId가 있으면 AI 추천 요청
|
||||||
prize: edited?.prize || selectedRec.prize,
|
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 || err.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) => ({
|
setEditedData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[id]: { ...prev[id], title },
|
[optionNumber]: {
|
||||||
|
...prev[optionNumber],
|
||||||
|
title
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditPrize = (id: string, prize: string) => {
|
const handleEditDescription = (optionNumber: number, description: string) => {
|
||||||
setEditedData((prev) => ({
|
setEditedData((prev) => ({
|
||||||
...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 (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: 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 variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||||
📍 업종 트렌드
|
📍 업종 트렌드
|
||||||
</Typography>
|
</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 }}>
|
||||||
</Typography>
|
• {trend.description}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||||
🗺️ 지역 트렌드
|
🗺️ 지역 트렌드
|
||||||
</Typography>
|
</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 }}>
|
||||||
</Typography>
|
• {trend.description}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} md={4}>
|
<Grid item xs={12} md={4}>
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||||
☀️ 시즌 트렌드
|
☀️ 시즌 트렌드
|
||||||
</Typography>
|
</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 }}>
|
||||||
</Typography>
|
• {trend.description}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Budget Selection */}
|
{/* AI Recommendations */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: 8 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
||||||
예산별 추천 이벤트
|
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
||||||
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
|
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다.
|
||||||
</Typography>
|
</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>
|
</Box>
|
||||||
|
|
||||||
{/* Recommendations */}
|
{/* 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 }}>
|
<Grid container spacing={6} sx={{ mb: 10 }}>
|
||||||
{budgetRecommendations.map((rec) => (
|
{aiResult.recommendations.map((rec) => (
|
||||||
<Grid item xs={12} md={6} key={rec.id}>
|
<Grid item xs={12} key={rec.optionNumber}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: selected === rec.id ? 2 : 1,
|
border: selected === rec.optionNumber ? 2 : 1,
|
||||||
borderColor: selected === rec.id ? colors.purple : 'divider',
|
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
||||||
bgcolor: selected === rec.id ? `${colors.purpleLight}40` : 'background.paper',
|
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
boxShadow: selected === rec.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': {
|
'&:hover': {
|
||||||
borderColor: colors.purple,
|
borderColor: colors.purple,
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
transform: 'translateY(-2px)',
|
transform: 'translateY(-2px)',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onClick={() => setSelected(rec.id)}
|
onClick={() => setSelected(rec.optionNumber)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: 6 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
||||||
<Chip
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
|
<Chip
|
||||||
color={rec.method === 'online' ? 'primary' : 'secondary'}
|
label={`옵션 ${rec.optionNumber}`}
|
||||||
size="medium"
|
color="primary"
|
||||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
size="medium"
|
||||||
|
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||||
|
/>
|
||||||
|
<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 }}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={editedData[rec.id]?.title || rec.title}
|
value={editedData[rec.optionNumber]?.title || rec.title}
|
||||||
onChange={(e) => handleEditTitle(rec.id, e.target.value)}
|
onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
sx={{ mb: 4 }}
|
sx={{ mb: 4 }}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <Edit fontSize="small" color="action" />,
|
endAdornment: <Edit fontSize="small" color="action" />,
|
||||||
sx: { fontSize: '1rem', py: 2 },
|
sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box sx={{ mb: 4 }}>
|
<TextField
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}>
|
fullWidth
|
||||||
경품
|
multiline
|
||||||
</Typography>
|
rows={2}
|
||||||
<TextField
|
variant="outlined"
|
||||||
fullWidth
|
value={editedData[rec.optionNumber]?.description || rec.description}
|
||||||
size="medium"
|
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
|
||||||
variant="outlined"
|
onClick={(e) => e.stopPropagation()}
|
||||||
value={editedData[rec.id]?.prize || rec.prize}
|
sx={{ mb: 4 }}
|
||||||
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
|
InputProps={{
|
||||||
onClick={(e) => e.stopPropagation()}
|
sx: { fontSize: '1rem' },
|
||||||
InputProps={{
|
}}
|
||||||
endAdornment: <Edit fontSize="small" color="action" />,
|
/>
|
||||||
sx: { fontSize: '1rem' },
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Grid container spacing={4} sx={{ mt: 4 }}>
|
<Grid container spacing={4} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={6}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||||
참여 방법
|
타겟 고객
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||||
{rec.participationMethod}
|
{rec.targetAudience}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</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.expectedParticipants}명
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={6}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||||
예상 비용
|
예상 비용
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||||
{(rec.estimatedCost / 10000).toFixed(0)}만원
|
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||||
투자대비수익률
|
예상 신규 고객
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||||
|
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6} md={3}>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||||
|
ROI
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
|
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
|
||||||
{rec.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>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
|||||||
fullWidth
|
fullWidth
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="large"
|
size="large"
|
||||||
disabled={!selected}
|
disabled={selected === null || loading}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
sx={{
|
sx={{
|
||||||
py: 3,
|
py: 3,
|
||||||
@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
다음
|
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -37,78 +37,12 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import Header from '@/shared/ui/Header';
|
import Header from '@/shared/ui/Header';
|
||||||
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
|
||||||
|
import { useEvents } from '@/entities/event/model/useEvents';
|
||||||
|
import type { EventStatus as ApiEventStatus } from '@/entities/event/model/types';
|
||||||
|
|
||||||
// Mock 데이터
|
// ==================== API 연동 ====================
|
||||||
const mockEvents = [
|
// Mock 데이터를 실제 API 호출로 교체
|
||||||
{
|
// 백업 파일: page.tsx.backup
|
||||||
id: '1',
|
|
||||||
title: '신규고객 유치 이벤트',
|
|
||||||
status: 'active' as const,
|
|
||||||
daysLeft: 5,
|
|
||||||
participants: 128,
|
|
||||||
targetParticipants: 200,
|
|
||||||
roi: 450,
|
|
||||||
startDate: '2025-11-01',
|
|
||||||
endDate: '2025-11-15',
|
|
||||||
prize: '커피 쿠폰',
|
|
||||||
method: '전화번호 입력',
|
|
||||||
isUrgent: true,
|
|
||||||
isPopular: false,
|
|
||||||
isHighROI: true,
|
|
||||||
isNew: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: '재방문 유도 이벤트',
|
|
||||||
status: 'active' as const,
|
|
||||||
daysLeft: 12,
|
|
||||||
participants: 56,
|
|
||||||
targetParticipants: 100,
|
|
||||||
roi: 320,
|
|
||||||
startDate: '2025-11-05',
|
|
||||||
endDate: '2025-11-20',
|
|
||||||
prize: '할인 쿠폰',
|
|
||||||
method: 'SNS 팔로우',
|
|
||||||
isUrgent: false,
|
|
||||||
isPopular: false,
|
|
||||||
isHighROI: false,
|
|
||||||
isNew: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
title: '매출증대 프로모션',
|
|
||||||
status: 'ended' as const,
|
|
||||||
daysLeft: 0,
|
|
||||||
participants: 234,
|
|
||||||
targetParticipants: 150,
|
|
||||||
roi: 580,
|
|
||||||
startDate: '2025-10-15',
|
|
||||||
endDate: '2025-10-31',
|
|
||||||
prize: '상품권',
|
|
||||||
method: '구매 인증',
|
|
||||||
isUrgent: false,
|
|
||||||
isPopular: true,
|
|
||||||
isHighROI: true,
|
|
||||||
isNew: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
title: '봄맞이 특별 이벤트',
|
|
||||||
status: 'scheduled' as const,
|
|
||||||
daysLeft: 30,
|
|
||||||
participants: 0,
|
|
||||||
targetParticipants: 300,
|
|
||||||
roi: 0,
|
|
||||||
startDate: '2025-12-01',
|
|
||||||
endDate: '2025-12-15',
|
|
||||||
prize: '체험권',
|
|
||||||
method: '이메일 등록',
|
|
||||||
isUrgent: false,
|
|
||||||
isPopular: false,
|
|
||||||
isHighROI: false,
|
|
||||||
isNew: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type EventStatus = 'all' | 'active' | 'scheduled' | 'ended';
|
type EventStatus = 'all' | 'active' | 'scheduled' | 'ended';
|
||||||
type Period = '1month' | '3months' | '6months' | '1year' | 'all';
|
type Period = '1month' | '3months' | '6months' | '1year' | 'all';
|
||||||
@ -123,8 +57,57 @@ export default function EventsPage() {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
|
|
||||||
|
// API 데이터 가져오기
|
||||||
|
const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({
|
||||||
|
page: currentPage - 1,
|
||||||
|
size: itemsPerPage,
|
||||||
|
sort: 'createdAt',
|
||||||
|
order: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 상태를 UI 상태로 매핑
|
||||||
|
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
|
||||||
|
switch (apiStatus) {
|
||||||
|
case 'PUBLISHED':
|
||||||
|
return 'active';
|
||||||
|
case 'DRAFT':
|
||||||
|
return 'scheduled';
|
||||||
|
case 'ENDED':
|
||||||
|
return 'ended';
|
||||||
|
default:
|
||||||
|
return 'all';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 이벤트를 UI 형식으로 변환
|
||||||
|
const transformedEvents = apiEvents.map(event => ({
|
||||||
|
id: event.eventId,
|
||||||
|
title: event.eventName || '제목 없음',
|
||||||
|
status: mapApiStatus(event.status),
|
||||||
|
startDate: event.startDate ? new Date(event.startDate).toLocaleDateString('ko-KR') : '-',
|
||||||
|
endDate: event.endDate ? new Date(event.endDate).toLocaleDateString('ko-KR') : '-',
|
||||||
|
prize: event.aiRecommendations[0]?.reward || '경품 정보 없음',
|
||||||
|
method: event.aiRecommendations[0]?.participationMethod || '참여 방법 없음',
|
||||||
|
participants: event.participants || 0,
|
||||||
|
targetParticipants: event.targetParticipants || 0,
|
||||||
|
roi: event.roi || 0,
|
||||||
|
daysLeft: event.endDate
|
||||||
|
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
|
||||||
|
: 0,
|
||||||
|
isUrgent: event.endDate
|
||||||
|
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) <= 3
|
||||||
|
: false,
|
||||||
|
isPopular: event.participants && event.targetParticipants
|
||||||
|
? (event.participants / event.targetParticipants) >= 0.8
|
||||||
|
: false,
|
||||||
|
isHighROI: event.roi ? event.roi >= 300 : false,
|
||||||
|
isNew: event.createdAt
|
||||||
|
? (Date.now() - new Date(event.createdAt).getTime()) < (7 * 24 * 60 * 60 * 1000)
|
||||||
|
: false,
|
||||||
|
}));
|
||||||
|
|
||||||
// 필터링 및 정렬
|
// 필터링 및 정렬
|
||||||
const filteredEvents = mockEvents
|
const filteredEvents = transformedEvents
|
||||||
.filter((event) => {
|
.filter((event) => {
|
||||||
const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase());
|
const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
|
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
|
||||||
@ -204,22 +187,26 @@ export default function EventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateProgress = (event: (typeof mockEvents)[0]) => {
|
const calculateProgress = (event: typeof transformedEvents[0]) => {
|
||||||
if (event.status !== 'active') return 0;
|
if (event.status !== 'active') return 0;
|
||||||
const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime();
|
const startTime = new Date(event.startDate).getTime();
|
||||||
const elapsed = Date.now() - new Date(event.startDate).getTime();
|
const endTime = new Date(event.endDate).getTime();
|
||||||
|
const total = endTime - startTime;
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
return Math.min(Math.max((elapsed / total) * 100, 0), 100);
|
return Math.min(Math.max((elapsed / total) * 100, 0), 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 통계 계산
|
// 통계 계산
|
||||||
const stats = {
|
const stats = {
|
||||||
total: mockEvents.length,
|
total: transformedEvents.length,
|
||||||
active: mockEvents.filter((e) => e.status === 'active').length,
|
active: transformedEvents.filter((e) => e.status === 'active').length,
|
||||||
totalParticipants: mockEvents.reduce((sum, e) => sum + e.participants, 0),
|
totalParticipants: transformedEvents.reduce((sum, e) => sum + e.participants, 0),
|
||||||
avgROI: Math.round(
|
avgROI: transformedEvents.filter((e) => e.roi > 0).length > 0
|
||||||
mockEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
|
? Math.round(
|
||||||
mockEvents.filter((e) => e.roi > 0).length
|
transformedEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
|
||||||
),
|
transformedEvents.filter((e) => e.roi > 0).length
|
||||||
|
)
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -237,6 +224,59 @@ export default function EventsPage() {
|
|||||||
maxWidth="lg"
|
maxWidth="lg"
|
||||||
sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }}
|
sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }}
|
||||||
>
|
>
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ mb: 4 }}>
|
||||||
|
<LinearProgress sx={{ borderRadius: 1 }} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
mt: 2,
|
||||||
|
textAlign: 'center',
|
||||||
|
color: colors.gray[600],
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
이벤트 목록을 불러오는 중...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: 4, bgcolor: '#FEE2E2' }}>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Warning sx={{ fontSize: 48, color: '#DC2626', mb: 2 }} />
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{ mb: 1, color: '#991B1B', fontSize: { xs: '1rem', sm: '1.25rem' } }}
|
||||||
|
>
|
||||||
|
이벤트 목록을 불러오는데 실패했습니다
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: '#7F1D1D', mb: 2 }}>
|
||||||
|
{error.message}
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
py: 1.5,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: 'none',
|
||||||
|
bgcolor: '#DC2626',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: '#B91C1C' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Summary Statistics */}
|
{/* Summary Statistics */}
|
||||||
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
|
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
<Grid item xs={6} sm={3}>
|
<Grid item xs={6} sm={3}>
|
||||||
|
|||||||
52
src/app/api/content/events/[eventDraftId]/images/route.ts
Normal file
52
src/app/api/content/events/[eventDraftId]/images/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/content/images/generate/route.ts
Normal file
42
src/app/api/content/images/generate/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/app/api/content/images/jobs/[jobId]/route.ts
Normal file
42
src/app/api/content/images/jobs/[jobId]/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/app/api/v1/events/objectives/route.ts
Normal file
63
src/app/api/v1/events/objectives/route.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock API: 이벤트 목적 선택 (Step 1)
|
||||||
|
* 백엔드 API가 준비될 때까지 사용하는 임시 Mock API
|
||||||
|
*
|
||||||
|
* POST /api/v1/events/objectives
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { objective } = body;
|
||||||
|
|
||||||
|
// 백엔드 API 호출 시도
|
||||||
|
const backendUrl = 'http://localhost:8080/api/events/objectives';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backendResponse = await fetch(backendUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': request.headers.get('Authorization') || '',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 백엔드가 정상 응답하면 그대로 반환
|
||||||
|
if (backendResponse.ok) {
|
||||||
|
const data = await backendResponse.json();
|
||||||
|
return NextResponse.json(data, { status: backendResponse.status });
|
||||||
|
}
|
||||||
|
} catch (backendError) {
|
||||||
|
console.warn('⚠️ 백엔드 API 호출 실패, Mock 데이터 반환:', backendError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 백엔드 실패 시 Mock 데이터 반환
|
||||||
|
// shared/api/eventApi의 selectObjective가 반환하는 형식과 일치
|
||||||
|
const mockEventId = `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
eventId: mockEventId,
|
||||||
|
objective: objective,
|
||||||
|
status: 'DRAFT' as const,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('🎭 Mock API Response:', mockResponse);
|
||||||
|
|
||||||
|
return NextResponse.json(mockResponse, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Mock API Error:', error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
errorCode: 'MOCK_ERROR',
|
||||||
|
message: 'Mock API 오류가 발생했습니다',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/entities/event/api/eventApi.ts
Normal file
253
src/entities/event/api/eventApi.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import { apiClient } from '@/shared/api';
|
||||||
|
import type {
|
||||||
|
GetEventsRequest,
|
||||||
|
GetEventsResponse,
|
||||||
|
EventDetail,
|
||||||
|
ApiResponse,
|
||||||
|
SelectObjectiveRequest,
|
||||||
|
EventCreatedResponse,
|
||||||
|
AiRecommendationRequest,
|
||||||
|
JobAcceptedResponse,
|
||||||
|
ImageGenerationRequest,
|
||||||
|
ImageGenerationResponse,
|
||||||
|
UpdateEventRequest,
|
||||||
|
SelectChannelsRequest,
|
||||||
|
SelectImageRequest,
|
||||||
|
} from '../model/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event API 기본 경로
|
||||||
|
*
|
||||||
|
* 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로
|
||||||
|
* 별도의 event API 클라이언트를 사용하는 것이 좋습니다.
|
||||||
|
*
|
||||||
|
* 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다.
|
||||||
|
*/
|
||||||
|
const EVENT_API_BASE = '/api/v1/events';
|
||||||
|
const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event Service용 API 클라이언트
|
||||||
|
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
|
||||||
|
*
|
||||||
|
* 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피)
|
||||||
|
* 프로덕션 환경: 환경 변수에서 직접 호스트 사용
|
||||||
|
*/
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
|
||||||
|
|
||||||
|
const eventApiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - JWT 토큰 추가
|
||||||
|
eventApiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
console.log('🚀 Event API Request:', {
|
||||||
|
method: config.method?.toUpperCase(),
|
||||||
|
url: config.url,
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
params: config.params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
console.log('🔑 Token added to Event API request');
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
url: error.config?.url,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event API Service
|
||||||
|
* 이벤트 관리 API
|
||||||
|
*/
|
||||||
|
export const eventApi = {
|
||||||
|
/**
|
||||||
|
* 이벤트 목록 조회
|
||||||
|
*/
|
||||||
|
getEvents: async (params?: GetEventsRequest): Promise<GetEventsResponse> => {
|
||||||
|
console.log('📞 eventApi.getEvents 호출', params);
|
||||||
|
const response = await eventApiClient.get<GetEventsResponse>(EVENT_API_BASE, {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 상세 조회
|
||||||
|
*/
|
||||||
|
getEvent: async (eventId: string): Promise<ApiResponse<EventDetail>> => {
|
||||||
|
console.log('📞 eventApi.getEvent 호출', eventId);
|
||||||
|
const response = await eventApiClient.get<ApiResponse<EventDetail>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 생성 (목적 선택)
|
||||||
|
*/
|
||||||
|
createEvent: async (
|
||||||
|
data: SelectObjectiveRequest
|
||||||
|
): Promise<ApiResponse<EventCreatedResponse>> => {
|
||||||
|
console.log('📞 eventApi.createEvent 호출', data);
|
||||||
|
const response = await eventApiClient.post<ApiResponse<EventCreatedResponse>>(
|
||||||
|
`${EVENT_API_BASE}/objectives`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 삭제
|
||||||
|
*/
|
||||||
|
deleteEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||||
|
console.log('📞 eventApi.deleteEvent 호출', eventId);
|
||||||
|
const response = await eventApiClient.delete<ApiResponse<void>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 배포
|
||||||
|
*/
|
||||||
|
publishEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||||
|
console.log('📞 eventApi.publishEvent 호출', eventId);
|
||||||
|
const response = await eventApiClient.post<ApiResponse<void>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/publish`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 종료
|
||||||
|
*/
|
||||||
|
endEvent: async (eventId: string): Promise<ApiResponse<void>> => {
|
||||||
|
console.log('📞 eventApi.endEvent 호출', eventId);
|
||||||
|
const response = await eventApiClient.post<ApiResponse<void>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/end`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 요청
|
||||||
|
*/
|
||||||
|
requestAiRecommendations: async (
|
||||||
|
eventId: string,
|
||||||
|
data: AiRecommendationRequest
|
||||||
|
): Promise<ApiResponse<JobAcceptedResponse>> => {
|
||||||
|
console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data);
|
||||||
|
const response = await eventApiClient.post<ApiResponse<JobAcceptedResponse>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/ai-recommendations`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 생성 요청
|
||||||
|
*/
|
||||||
|
requestImageGeneration: async (
|
||||||
|
eventId: string,
|
||||||
|
data: ImageGenerationRequest
|
||||||
|
): Promise<ApiResponse<ImageGenerationResponse>> => {
|
||||||
|
console.log('📞 eventApi.requestImageGeneration 호출', eventId, data);
|
||||||
|
const response = await eventApiClient.post<ApiResponse<ImageGenerationResponse>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/images`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 수정
|
||||||
|
*/
|
||||||
|
updateEvent: async (
|
||||||
|
eventId: string,
|
||||||
|
data: UpdateEventRequest
|
||||||
|
): Promise<ApiResponse<EventDetail>> => {
|
||||||
|
console.log('📞 eventApi.updateEvent 호출', eventId, data);
|
||||||
|
const response = await eventApiClient.put<ApiResponse<EventDetail>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 채널 선택
|
||||||
|
*/
|
||||||
|
selectChannels: async (
|
||||||
|
eventId: string,
|
||||||
|
data: SelectChannelsRequest
|
||||||
|
): Promise<ApiResponse<void>> => {
|
||||||
|
console.log('📞 eventApi.selectChannels 호출', eventId, data);
|
||||||
|
const response = await eventApiClient.put<ApiResponse<void>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/channels`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 선택
|
||||||
|
*/
|
||||||
|
selectImage: async (
|
||||||
|
eventId: string,
|
||||||
|
imageId: string,
|
||||||
|
data: SelectImageRequest
|
||||||
|
): Promise<ApiResponse<void>> => {
|
||||||
|
console.log('📞 eventApi.selectImage 호출', eventId, imageId, data);
|
||||||
|
const response = await eventApiClient.put<ApiResponse<void>>(
|
||||||
|
`${EVENT_API_BASE}/${eventId}/images/${imageId}/select`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventApi;
|
||||||
198
src/entities/event/model/types.ts
Normal file
198
src/entities/event/model/types.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* Event 도메인 타입 정의
|
||||||
|
* Event Service API 응답 형식과 일치
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 상태
|
||||||
|
*/
|
||||||
|
export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 목적
|
||||||
|
*/
|
||||||
|
export type EventObjective =
|
||||||
|
| 'CUSTOMER_ACQUISITION'
|
||||||
|
| 'Sales Promotion'
|
||||||
|
| 'Customer Retention'
|
||||||
|
| 'New Customer Acquisition'
|
||||||
|
| 'awareness'
|
||||||
|
| 'sales'
|
||||||
|
| 'new_customer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 채널
|
||||||
|
*/
|
||||||
|
export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 이미지
|
||||||
|
*/
|
||||||
|
export interface EventImage {
|
||||||
|
imageId: string;
|
||||||
|
imageUrl: string;
|
||||||
|
prompt?: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천
|
||||||
|
*/
|
||||||
|
export interface AiRecommendation {
|
||||||
|
recommendationId: string;
|
||||||
|
eventName: string;
|
||||||
|
description: string;
|
||||||
|
reward: string;
|
||||||
|
participationMethod: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
targetParticipants: number;
|
||||||
|
isSelected: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 상세 정보
|
||||||
|
*/
|
||||||
|
export interface EventDetail {
|
||||||
|
eventId: string;
|
||||||
|
userId: string;
|
||||||
|
storeId: string;
|
||||||
|
eventName: string;
|
||||||
|
description: string | null;
|
||||||
|
objective: EventObjective;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
status: EventStatus;
|
||||||
|
selectedImageId: string | null;
|
||||||
|
selectedImageUrl: string | null;
|
||||||
|
participants: number | null;
|
||||||
|
targetParticipants: number | null;
|
||||||
|
roi: number | null;
|
||||||
|
generatedImages: EventImage[];
|
||||||
|
aiRecommendations: AiRecommendation[];
|
||||||
|
channels: DistributionChannel[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 응답
|
||||||
|
*/
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[];
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
first: boolean;
|
||||||
|
last: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 표준 응답
|
||||||
|
*/
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 목록 조회 요청
|
||||||
|
*/
|
||||||
|
export interface GetEventsRequest {
|
||||||
|
status?: EventStatus;
|
||||||
|
search?: string;
|
||||||
|
objective?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 목록 조회 응답
|
||||||
|
*/
|
||||||
|
export type GetEventsResponse = ApiResponse<PageResponse<EventDetail>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 목적 선택 요청
|
||||||
|
*/
|
||||||
|
export interface SelectObjectiveRequest {
|
||||||
|
objective: EventObjective;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 생성 응답
|
||||||
|
*/
|
||||||
|
export interface EventCreatedResponse {
|
||||||
|
eventId: string;
|
||||||
|
objective: EventObjective;
|
||||||
|
status: EventStatus;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 추천 요청
|
||||||
|
*/
|
||||||
|
export interface AiRecommendationRequest {
|
||||||
|
storeCategory?: string;
|
||||||
|
targetAudience?: string;
|
||||||
|
budget?: number;
|
||||||
|
additionalInfo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 수락 응답
|
||||||
|
*/
|
||||||
|
export interface JobAcceptedResponse {
|
||||||
|
jobId: string;
|
||||||
|
eventId: string;
|
||||||
|
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||||
|
estimatedCompletionTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 생성 요청
|
||||||
|
*/
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
prompt: string;
|
||||||
|
numberOfImages?: number;
|
||||||
|
style?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 생성 응답
|
||||||
|
*/
|
||||||
|
export interface ImageGenerationResponse {
|
||||||
|
jobId: string;
|
||||||
|
eventId: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 수정 요청
|
||||||
|
*/
|
||||||
|
export interface UpdateEventRequest {
|
||||||
|
eventName?: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
discountRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 배포 채널 선택 요청
|
||||||
|
*/
|
||||||
|
export interface SelectChannelsRequest {
|
||||||
|
channels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 선택 요청
|
||||||
|
*/
|
||||||
|
export interface SelectImageRequest {
|
||||||
|
selectedImageId: string;
|
||||||
|
}
|
||||||
200
src/entities/event/model/useEvents.ts
Normal file
200
src/entities/event/model/useEvents.ts
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { eventApi } from '../api/eventApi';
|
||||||
|
import type {
|
||||||
|
EventDetail,
|
||||||
|
GetEventsRequest,
|
||||||
|
EventStatus,
|
||||||
|
PageResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useEvents Hook
|
||||||
|
* 이벤트 목록 조회 및 상태 관리
|
||||||
|
*/
|
||||||
|
export function useEvents(initialParams?: GetEventsRequest) {
|
||||||
|
const [events, setEvents] = useState<EventDetail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [pageInfo, setPageInfo] = useState<Omit<PageResponse<EventDetail>, 'content'>>({
|
||||||
|
page: 0,
|
||||||
|
size: 20,
|
||||||
|
totalElements: 0,
|
||||||
|
totalPages: 0,
|
||||||
|
first: true,
|
||||||
|
last: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchEvents = async (params?: GetEventsRequest) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Fetching events with params:', params);
|
||||||
|
|
||||||
|
const response = await eventApi.getEvents(params);
|
||||||
|
console.log('✅ Events fetched:', response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setEvents(response.data.content);
|
||||||
|
setPageInfo({
|
||||||
|
page: response.data.page,
|
||||||
|
size: response.data.size,
|
||||||
|
totalElements: response.data.totalElements,
|
||||||
|
totalPages: response.data.totalPages,
|
||||||
|
first: response.data.first,
|
||||||
|
last: response.data.last,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error fetching events:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기 로드
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents(initialParams);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
events,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
pageInfo,
|
||||||
|
refetch: fetchEvents,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useEvent Hook
|
||||||
|
* 단일 이벤트 조회 및 상태 관리
|
||||||
|
*/
|
||||||
|
export function useEvent(eventId: string) {
|
||||||
|
const [event, setEvent] = useState<EventDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchEvent = async () => {
|
||||||
|
if (!eventId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Fetching event:', eventId);
|
||||||
|
|
||||||
|
const response = await eventApi.getEvent(eventId);
|
||||||
|
console.log('✅ Event fetched:', response);
|
||||||
|
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setEvent(response.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error fetching event:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvent();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [eventId]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
event,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch: fetchEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useEventActions Hook
|
||||||
|
* 이벤트 생성, 삭제, 배포 등의 액션 관리
|
||||||
|
*/
|
||||||
|
export function useEventActions() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const createEvent = async (objective: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Creating event with objective:', objective);
|
||||||
|
|
||||||
|
const response = await eventApi.createEvent({ objective: objective as any });
|
||||||
|
console.log('✅ Event created:', response);
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error creating event:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEvent = async (eventId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Deleting event:', eventId);
|
||||||
|
|
||||||
|
await eventApi.deleteEvent(eventId);
|
||||||
|
console.log('✅ Event deleted');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error deleting event:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publishEvent = async (eventId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Publishing event:', eventId);
|
||||||
|
|
||||||
|
await eventApi.publishEvent(eventId);
|
||||||
|
console.log('✅ Event published');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error publishing event:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const endEvent = async (eventId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
console.log('🔄 Ending event:', eventId);
|
||||||
|
|
||||||
|
await eventApi.endEvent(eventId);
|
||||||
|
console.log('✅ Event ended');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ Error ending event:', err);
|
||||||
|
setError(err as Error);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
createEvent,
|
||||||
|
deleteEvent,
|
||||||
|
publishEvent,
|
||||||
|
endEvent,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
178
src/shared/api/aiApi.ts
Normal file
178
src/shared/api/aiApi.ts
Normal 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;
|
||||||
160
src/shared/api/contentApi.ts
Normal file
160
src/shared/api/contentApi.ts
Normal 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;
|
||||||
334
src/shared/api/eventApi.ts
Normal file
334
src/shared/api/eventApi.ts
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
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 || 'v1';
|
||||||
|
|
||||||
|
// 개발 환경에서는 상대 경로 사용 (Next.js rewrites 프록시 또는 Mock API 사용)
|
||||||
|
// 프로덕션 환경에서는 환경 변수의 호스트 사용
|
||||||
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
const BASE_URL = isProduction ? `${EVENT_API_BASE_URL}/api/${API_VERSION}` : `/api/${API_VERSION}`;
|
||||||
|
|
||||||
|
export const eventApiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: BASE_URL,
|
||||||
|
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;
|
||||||
@ -1,2 +1,6 @@
|
|||||||
export { apiClient } from './client';
|
export { apiClient, participationClient } from './client';
|
||||||
export type { ApiError } from './types';
|
export type { ApiError } from './types';
|
||||||
|
export * from './contentApi';
|
||||||
|
export * from './aiApi';
|
||||||
|
export * from './eventApi';
|
||||||
|
export * from './participation.api';
|
||||||
|
|||||||
124
test-images.html
Normal file
124
test-images.html
Normal 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
182
test-localstorage.html
Normal 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>
|
||||||
Loading…
x
Reference in New Issue
Block a user