mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 11:36:24 +00:00
Compare commits
2 Commits
517cac7c75
...
6331ab3fde
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6331ab3fde | ||
|
|
a58ca4ece1 |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
247
ROUTES.md
Normal file
247
ROUTES.md
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
# 📍 KT 이벤트 마케팅 - 전체 페이지 라우트 목록
|
||||||
|
|
||||||
|
## 🔐 인증 라우트 (auth)
|
||||||
|
**Route Group**: `(auth)` - AuthGuard 미적용
|
||||||
|
|
||||||
|
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|
||||||
|
|--------|-----------|------|-------------|
|
||||||
|
| `/login` | `(auth)/login/page.tsx` | 로그인 페이지 | ✅ 완료 |
|
||||||
|
| `/register` | `(auth)/register/page.tsx` | 회원가입 페이지 | ✅ 완료 |
|
||||||
|
| `/logout` | `(auth)/logout/page.tsx` | 로그아웃 페이지 | ✅ 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏠 메인 애플리케이션 라우트 (main)
|
||||||
|
**Route Group**: `(main)` - AuthGuard 적용, BottomNavigation 포함
|
||||||
|
|
||||||
|
### 대시보드 & 분석
|
||||||
|
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|
||||||
|
|--------|-----------|------|-------------|
|
||||||
|
| `/` | `(main)/page.tsx` | 메인 대시보드 | ✅ 완료 |
|
||||||
|
| `/analytics` | `(main)/analytics/page.tsx` | 전체 분석 페이지 | ✅ 완료 |
|
||||||
|
| `/profile` | `(main)/profile/page.tsx` | 사용자 프로필 | ✅ 완료 |
|
||||||
|
|
||||||
|
### 이벤트 관리
|
||||||
|
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|
||||||
|
|--------|-----------|------|-------------|
|
||||||
|
| `/events` | `(main)/events/page.tsx` | 이벤트 목록 | ✅ 완료 |
|
||||||
|
| `/events/create` | `(main)/events/create/page.tsx` | 이벤트 생성 (Funnel 방식) | ✅ 완료 |
|
||||||
|
| `/events/create/content-test` | `(main)/events/create/content-test/page.tsx` | 콘텐츠 테스트 페이지 | ✅ 완료 |
|
||||||
|
|
||||||
|
### 이벤트 상세 (동적 라우트)
|
||||||
|
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|
||||||
|
|--------|-----------|------|-------------|
|
||||||
|
| `/events/[eventId]` | `(main)/events/[eventId]/page.tsx` | 이벤트 상세 페이지 | ✅ 완료 |
|
||||||
|
| `/events/[eventId]/participate` | `(main)/events/[eventId]/participate/page.tsx` | 이벤트 참여 페이지 | ✅ 완료 |
|
||||||
|
| `/events/[eventId]/participants` | `(main)/events/[eventId]/participants/page.tsx` | 참여자 관리 페이지 | ✅ 완료 |
|
||||||
|
| `/events/[eventId]/draw` | `(main)/events/[eventId]/draw/page.tsx` | 추첨 페이지 | ✅ 완료 |
|
||||||
|
|
||||||
|
### 테스트 페이지
|
||||||
|
| 라우트 | 파일 경로 | 설명 | 320px 최적화 |
|
||||||
|
|--------|-----------|------|-------------|
|
||||||
|
| `/test/analytics/[eventId]` | `(main)/test/analytics/[eventId]/page.tsx` | 분석 테스트 페이지 | ✅ 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API 라우트
|
||||||
|
|
||||||
|
### User API (v1)
|
||||||
|
| 라우트 | 파일 경로 | HTTP Method |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `/api/v1/users/login` | `api/v1/users/login/route.ts` | POST |
|
||||||
|
| `/api/v1/users/logout` | `api/v1/users/logout/route.ts` | POST |
|
||||||
|
| `/api/v1/users/register` | `api/v1/users/register/route.ts` | POST |
|
||||||
|
| `/api/v1/users/profile` | `api/v1/users/profile/route.ts` | GET, PUT |
|
||||||
|
| `/api/v1/users/password` | `api/v1/users/password/route.ts` | PUT |
|
||||||
|
|
||||||
|
### Event API (v1)
|
||||||
|
| 라우트 | 파일 경로 | HTTP Method |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `/api/v1/events/objectives` | `api/v1/events/objectives/route.ts` | POST |
|
||||||
|
| `/api/v1/events/[eventId]/participate` | `api/v1/events/[eventId]/participate/route.ts` | POST |
|
||||||
|
| `/api/v1/events/[eventId]/participants` | `api/v1/events/[eventId]/participants/route.ts` | GET |
|
||||||
|
| `/api/v1/events/[eventId]/participants/[participantId]` | `api/v1/events/[eventId]/participants/[participantId]/route.ts` | GET, PATCH |
|
||||||
|
| `/api/v1/events/[eventId]/draw-winners` | `api/v1/events/[eventId]/draw-winners/route.ts` | POST |
|
||||||
|
| `/api/v1/events/[eventId]/winners` | `api/v1/events/[eventId]/winners/route.ts` | GET |
|
||||||
|
|
||||||
|
### Participation API (프록시)
|
||||||
|
백엔드 Participation Service로 프록시하는 API 라우트
|
||||||
|
|
||||||
|
| 라우트 | 파일 경로 | HTTP Method |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `/api/participations/[eventId]/participate` | `api/participations/[eventId]/participate/route.ts` | POST |
|
||||||
|
| `/api/participations/[eventId]/participants` | `api/participations/[eventId]/participants/route.ts` | GET |
|
||||||
|
| `/api/participations/[eventId]/participants/[participantId]` | `api/participations/[eventId]/participants/[participantId]/route.ts` | GET, PATCH |
|
||||||
|
| `/api/participations/[eventId]/draw-winners` | `api/participations/[eventId]/draw-winners/route.ts` | POST |
|
||||||
|
| `/api/participations/[eventId]/winners` | `api/participations/[eventId]/winners/route.ts` | GET |
|
||||||
|
|
||||||
|
### Distribution API
|
||||||
|
| 라우트 | 파일 경로 | HTTP Method |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `/api/distribution/[eventId]/status` | `api/distribution/[eventId]/status/route.ts` | GET |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 이벤트 생성 Funnel Steps
|
||||||
|
|
||||||
|
`/events/create` 페이지는 `@use-funnel/browser`를 사용한 Funnel 방식으로 다음 단계들을 포함합니다:
|
||||||
|
|
||||||
|
### Step 흐름
|
||||||
|
```
|
||||||
|
objective → recommendation → channel → contentPreview/contentEdit → approval
|
||||||
|
```
|
||||||
|
|
||||||
|
### 각 단계 상세
|
||||||
|
|
||||||
|
| Step | Component | URL Query | 설명 |
|
||||||
|
|------|-----------|-----------|------|
|
||||||
|
| 1 | `ObjectiveStep` | `?event-creation.step=objective` | 이벤트 목적 선택 (신규고객/재방문/매출/인지도) |
|
||||||
|
| 2 | `RecommendationStep` | `?event-creation.step=recommendation` | AI 추천 이벤트 3가지 옵션 제시 및 선택 |
|
||||||
|
| 3 | `ChannelStep` | `?event-creation.step=channel` | 배포 채널 선택 (우리동네TV, 지니TV, SNS 등) |
|
||||||
|
| 4a | `ContentPreviewStep` | `?event-creation.step=contentPreview` | 콘텐츠 미리보기 (채널에 콘텐츠 필요 시) |
|
||||||
|
| 4b | `ContentEditStep` | `?event-creation.step=contentEdit` | 콘텐츠 편집 (사용자가 수정 선택 시) |
|
||||||
|
| 5 | `ApprovalStep` | `?event-creation.step=approval` | 최종 이벤트 검토 및 승인 |
|
||||||
|
|
||||||
|
### Step 분기 로직
|
||||||
|
- **우리동네TV, 지니TV, SNS** 채널 선택 시 → `contentPreview` 단계로 이동
|
||||||
|
- 기타 채널만 선택 시 → `approval` 단계로 바로 이동
|
||||||
|
- `contentPreview`에서 "수정하기" 선택 시 → `contentEdit` 단계로 이동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 인증 보호 정책
|
||||||
|
|
||||||
|
### AuthGuard 적용
|
||||||
|
- **(main)** Route Group: **AuthGuard 적용**
|
||||||
|
- 로그인 필수
|
||||||
|
- `isAuthenticated && user` 체크
|
||||||
|
- 미인증 시 자동으로 `/login`으로 리다이렉트
|
||||||
|
- BottomNavigation 포함
|
||||||
|
|
||||||
|
### AuthGuard 미적용
|
||||||
|
- **(auth)** Route Group: 인증 없이 접근 가능
|
||||||
|
- `/login`, `/register`, `/logout`
|
||||||
|
|
||||||
|
### 구현 위치
|
||||||
|
- AuthGuard 컴포넌트: `src/features/auth/ui/AuthGuard.tsx`
|
||||||
|
- Layout 적용: `src/app/(main)/layout.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗂️ 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/
|
||||||
|
├── (auth)/ # 인증 관련 페이지 (AuthGuard 미적용)
|
||||||
|
│ ├── login/
|
||||||
|
│ ├── register/
|
||||||
|
│ └── logout/
|
||||||
|
├── (main)/ # 메인 애플리케이션 (AuthGuard 적용)
|
||||||
|
│ ├── page.tsx # 대시보드
|
||||||
|
│ ├── layout.tsx # AuthGuard + BottomNavigation
|
||||||
|
│ ├── analytics/
|
||||||
|
│ ├── profile/
|
||||||
|
│ ├── events/
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ ├── create/
|
||||||
|
│ │ │ ├── page.tsx # Funnel 메인
|
||||||
|
│ │ │ ├── steps/ # Funnel 각 단계 컴포넌트
|
||||||
|
│ │ │ └── content-test/
|
||||||
|
│ │ └── [eventId]/
|
||||||
|
│ │ ├── page.tsx
|
||||||
|
│ │ ├── participate/
|
||||||
|
│ │ ├── participants/
|
||||||
|
│ │ └── draw/
|
||||||
|
│ └── test/
|
||||||
|
│ └── analytics/[eventId]/
|
||||||
|
└── api/ # API 라우트 (프록시)
|
||||||
|
├── v1/
|
||||||
|
│ ├── users/
|
||||||
|
│ └── events/
|
||||||
|
├── participations/
|
||||||
|
└── distribution/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 참고사항
|
||||||
|
|
||||||
|
### Next.js 14 App Router 특징
|
||||||
|
- Route Groups: `(auth)`, `(main)` - URL에 포함되지 않는 논리적 그룹
|
||||||
|
- Dynamic Routes: `[eventId]`, `[participantId]` - 동적 세그먼트
|
||||||
|
- API Routes: `route.ts` 파일로 API 엔드포인트 구현
|
||||||
|
- Layouts: 각 라우트 그룹별 공통 레이아웃 적용
|
||||||
|
|
||||||
|
### 최근 변경사항
|
||||||
|
- ✅ AI 추천 로직 변경: Job 폴링 방식 → POST `/events/{eventId}/ai-recommendations` 직접 호출
|
||||||
|
- ✅ AuthGuard 적용: (main) 그룹 전체에 로그인 필수 적용
|
||||||
|
- ✅ 로그인 페이지: 지원하지 않는 기능 Toast → Modal 변경
|
||||||
|
- ✅ 320px 모바일 최적화: 모든 페이지 완료 (2025-10-30)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📱 320px 모바일 최적화 내역
|
||||||
|
|
||||||
|
### 최적화 내용
|
||||||
|
1. **Container Padding 조정**: `px: { xs: 6 }` → `px: { xs: 2 }`
|
||||||
|
- 320px 화면에서 좌우 패딩 48px → 16px로 줄여 콘텐츠 영역 확보
|
||||||
|
- 실제 콘텐츠 영역: 224px → 288px (29% 증가)
|
||||||
|
|
||||||
|
2. **Grid Spacing 조정**: 큰 spacing 값을 반응형으로 조정
|
||||||
|
- `spacing={6}` → `spacing={{ xs: 2, sm: 6 }}`
|
||||||
|
|
||||||
|
3. **Typography 크기 조정**: 작은 화면에서 적절한 폰트 크기 적용
|
||||||
|
- 제목: `fontSize: '2rem'` → `fontSize: { xs: '1.5rem', sm: '2rem' }`
|
||||||
|
- 본문: `fontSize: '1rem'` → `fontSize: { xs: '0.875rem', sm: '1rem' }`
|
||||||
|
|
||||||
|
4. **Vertical Spacing 대폭 축소**: 320px에서 과도한 상하 여백 문제 해결
|
||||||
|
- **Container Padding**: `pt: 8, pb: 8` → `pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }` (64px → 32px)
|
||||||
|
- **Box Padding Bottom**: `pb: 10` → `pb: { xs: 4, sm: 10 }` (80px → 32px)
|
||||||
|
- **Large Margin Bottom**: `mb: 10` → `mb: { xs: 4, sm: 10 }` (80px → 32px)
|
||||||
|
- **Medium Margin Bottom**: `mb: 8` → `mb: { xs: 3, sm: 8 }` (64px → 24px)
|
||||||
|
- **CardContent Padding**:
|
||||||
|
- `p: 8` → `p: { xs: 3, sm: 8 }` (64px → 24px)
|
||||||
|
- `p: 6` → `p: { xs: 3, sm: 6 }` (48px → 24px)
|
||||||
|
- `p: 5` → `p: { xs: 2.5, sm: 5 }` (40px → 20px)
|
||||||
|
- `py: 6` → `pt: { xs: 3, sm: 6 }, pb: { xs: 3, sm: 6 }` (상하 각 48px → 24px)
|
||||||
|
- `py: { xs: 4, sm: 6 }` → `pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 }` (상하 각 32px → 16px)
|
||||||
|
- `py: { xs: 2, sm: 6 }` → `pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }` (상하 각 16px → 12px)
|
||||||
|
- **총 효과**: 상하 여백이 평균 50-60% 감소하여 320px 화면에서 콘텐츠 밀도 향상
|
||||||
|
|
||||||
|
5. **Margin/Gap 조정**: 불필요하게 큰 여백 줄임
|
||||||
|
- `gap: 3` → `gap: { xs: 2, sm: 3 }`
|
||||||
|
|
||||||
|
### 영향받은 페이지
|
||||||
|
- 모든 인증 페이지 (login, register, logout)
|
||||||
|
- 모든 이벤트 생성 단계 (ObjectiveStep, RecommendationStep, ChannelStep, ContentPreviewStep, ContentEditStep, ApprovalStep)
|
||||||
|
- 이벤트 상세 페이지들 (detail, participate, participants, draw)
|
||||||
|
- 대시보드 및 프로필 페이지
|
||||||
|
|
||||||
|
### 참여자 관리 페이지 추가 최적화 (2025-10-30)
|
||||||
|
**검색 및 필터 UI 최적화**:
|
||||||
|
- Search TextField: 아이콘 18px, 폰트 0.75rem, 패딩 8px
|
||||||
|
- FilterList 아이콘: 28px → 20px
|
||||||
|
- FormControl: minWidth 100px/90px, 폰트 0.75rem, 패딩 8px
|
||||||
|
- MenuItem: 폰트 0.75rem
|
||||||
|
|
||||||
|
**버튼 최적화**:
|
||||||
|
- 아이콘: 20px → 16px
|
||||||
|
- 폰트: 0.875rem → 0.7rem
|
||||||
|
- 패딩: px 4→1.5, py 1.5→0.75
|
||||||
|
- 텍스트 단축: "엑셀 다운로드"→"엑셀", "당첨자 추첨"→"추첨" (320px만)
|
||||||
|
|
||||||
|
**Pagination 최적화**:
|
||||||
|
- 폰트: 1rem → 0.75rem
|
||||||
|
- 버튼 크기: 32px → 26px
|
||||||
|
- 아이콘: 1.5rem → 1rem
|
||||||
|
- 여백: 4px → 2px
|
||||||
|
|
||||||
|
**통계 카드**:
|
||||||
|
- Grid: xs={6} → xs={4} (한 줄에 3개 표시)
|
||||||
|
- 카드 내부 요소 크기 축소 (아이콘, 폰트, 패딩)
|
||||||
|
|
||||||
|
### 빌드 확인
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
# ✅ 빌드 성공 (2025-10-30)
|
||||||
|
```
|
||||||
@ -17,21 +17,20 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
InputAdornment,
|
InputAdornment,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Visibility, VisibilityOff, Email, Lock, ChatBubble } from '@mui/icons-material';
|
import { Visibility, VisibilityOff, Email, Lock, ChatBubble, Info } from '@mui/icons-material';
|
||||||
import { useAuthContext } from '@/features/auth';
|
import { useAuthContext } from '@/features/auth';
|
||||||
import { useUIStore } from '@/stores/uiStore';
|
import { useUIStore } from '@/stores/uiStore';
|
||||||
import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles';
|
import { getGradientButtonStyle, responsiveText } from '@/shared/lib/button-styles';
|
||||||
|
|
||||||
// 유효성 검사 스키마
|
// 유효성 검사 스키마
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z
|
email: z.string().min(1, '이메일을 입력해주세요').email('올바른 이메일 형식이 아닙니다'),
|
||||||
.string()
|
password: z.string().min(1, '비밀번호를 입력해주세요'),
|
||||||
.min(1, '이메일을 입력해주세요')
|
|
||||||
.email('올바른 이메일 형식이 아닙니다'),
|
|
||||||
password: z
|
|
||||||
.string()
|
|
||||||
.min(1, '비밀번호를 입력해주세요'),
|
|
||||||
rememberMe: z.boolean().optional(),
|
rememberMe: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -42,6 +41,7 @@ export default function LoginPage() {
|
|||||||
const { login } = useAuthContext();
|
const { login } = useAuthContext();
|
||||||
const { showToast, setLoading } = useUIStore();
|
const { showToast, setLoading } = useUIStore();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [openUnavailableModal, setOpenUnavailableModal] = useState(false);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -74,7 +74,10 @@ export default function LoginPage() {
|
|||||||
router.push('/');
|
router.push('/');
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ 로그인 실패:', result.error);
|
console.error('❌ 로그인 실패:', result.error);
|
||||||
showToast(result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error');
|
showToast(
|
||||||
|
result.error || '로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.',
|
||||||
|
'error'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 로그인 예외:', error);
|
console.error('💥 로그인 예외:', error);
|
||||||
@ -84,14 +87,13 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSocialLogin = (provider: 'kakao' | 'naver') => {
|
|
||||||
// TODO: 소셜 로그인 구현
|
|
||||||
showToast(`${provider === 'kakao' ? '카카오톡' : '네이버'} 로그인은 준비 중입니다`, 'info');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUnavailableFeature = (e: React.MouseEvent) => {
|
const handleUnavailableFeature = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showToast('현재는 해당 기능을 제공하지 않습니다', 'info');
|
setOpenUnavailableModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setOpenUnavailableModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -102,8 +104,8 @@ export default function LoginPage() {
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
px: 3,
|
px: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
|
||||||
py: 8,
|
py: { xs: 4, sm: 8 }, // 320px: 32px, 600px+: 64px
|
||||||
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
|
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -112,38 +114,49 @@ export default function LoginPage() {
|
|||||||
sx={{
|
sx={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: 440,
|
maxWidth: 440,
|
||||||
p: { xs: 4, sm: 6 },
|
p: { xs: 2.5, sm: 4, md: 6 }, // 320px: 20px, 600px: 32px, 960px+: 48px
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
|
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 로고 및 타이틀 */}
|
{/* 로고 및 타이틀 */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 6 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 6 } }}> {/* 320px: 32px, 600px+: 48px */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: 64,
|
width: { xs: 56, sm: 64 }, // 320px: 56px, 600px+: 64px
|
||||||
height: 64,
|
height: { xs: 56, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'primary.main',
|
bgcolor: 'primary.main',
|
||||||
mb: 3,
|
mb: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
|
<Typography sx={{ fontSize: { xs: 28, sm: 32 } }}>🎉</Typography> {/* 320px: 28px, 600px+: 32px */}
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h4" sx={{ ...responsiveText.h2, mb: 1 }}>
|
<Typography
|
||||||
KT AI 이벤트
|
variant="h4"
|
||||||
|
sx={{
|
||||||
|
...responsiveText.h2,
|
||||||
|
mb: 1,
|
||||||
|
fontSize: { xs: '1.25rem', sm: '1.5rem' } // 320px: 20px, 600px+: 24px
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
KT 이벤트 파트너
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
소상공인을 위한 스마트 마케팅
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
|
소상공인을 위한 마케팅 어시스턴트
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 로그인 폼 */}
|
{/* 로그인 폼 */}
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="email"
|
name="email"
|
||||||
control={control}
|
control={control}
|
||||||
@ -159,16 +172,21 @@ export default function LoginPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Email color="action" />
|
<Email color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} /> {/* 320px: 20px, 600px+: 24px */}
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' } // 320px: 14px, 600px+: 16px
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 2 }}>
|
<Box sx={{ mb: { xs: 1.5, sm: 2 } }}> {/* 320px: 12px, 600px+: 16px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="password"
|
name="password"
|
||||||
control={control}
|
control={control}
|
||||||
@ -184,7 +202,7 @@ export default function LoginPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Lock color="action" />
|
<Lock color="action" sx={{ fontSize: { xs: 20, sm: 24 } }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
endAdornment: (
|
endAdornment: (
|
||||||
@ -199,20 +217,29 @@ export default function LoginPage() {
|
|||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-input': {
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' }
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ mb: 3 }}>
|
<Box sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Controller
|
<Controller
|
||||||
name="rememberMe"
|
name="rememberMe"
|
||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={<Checkbox {...field} checked={field.value} />}
|
control={<Checkbox {...field} checked={field.value} size="small" />}
|
||||||
label={
|
label={
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
로그인 상태 유지
|
로그인 상태 유지
|
||||||
</Typography>
|
</Typography>
|
||||||
}
|
}
|
||||||
@ -228,26 +255,41 @@ export default function LoginPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
sx={{
|
sx={{
|
||||||
mb: 2,
|
mb: 2,
|
||||||
py: { xs: 1.5, sm: 1.75 },
|
py: { xs: 1.25, sm: 1.5, md: 1.75 }, // 320px: 10px, 600px: 12px, 960px+: 14px
|
||||||
fontSize: { xs: 15, sm: 16 },
|
fontSize: { xs: '0.938rem', sm: 15, md: 16 }, // 320px: 15px, 600px: 15px, 960px+: 16px
|
||||||
...getGradientButtonStyle('primary'),
|
...getGradientButtonStyle('primary'),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
로그인
|
로그인
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mb: 4 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
|
||||||
|
mb: { xs: 3, sm: 4 }, // 320px: 24px, 600px+: 32px
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
href="#"
|
href="#"
|
||||||
onClick={handleUnavailableFeature}
|
onClick={handleUnavailableFeature}
|
||||||
variant="body2"
|
variant="body2"
|
||||||
color="text.secondary"
|
color="text.secondary"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
sx={{ cursor: 'pointer' }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: { xs: '0.813rem', sm: '0.875rem' } // 320px: 13px, 600px+: 14px
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
비밀번호 찾기
|
비밀번호 찾기
|
||||||
</Link>
|
</Link>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }}
|
||||||
|
>
|
||||||
|
|
|
|
||||||
</Typography>
|
</Typography>
|
||||||
<Link
|
<Link
|
||||||
@ -256,7 +298,11 @@ export default function LoginPage() {
|
|||||||
variant="body2"
|
variant="body2"
|
||||||
color="primary"
|
color="primary"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
sx={{ cursor: 'pointer', fontWeight: 600 }}
|
sx={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '0.813rem', sm: '0.875rem' }
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
회원가입
|
회원가입
|
||||||
</Link>
|
</Link>
|
||||||
@ -264,20 +310,32 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* 소셜 로그인 */}
|
{/* 소셜 로그인 */}
|
||||||
<Divider sx={{ mb: 3 }}>
|
<Divider sx={{ mb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: { xs: '0.813rem', sm: '0.875rem' } }} // 320px: 13px, 600px+: 14px
|
||||||
|
>
|
||||||
또는
|
또는
|
||||||
</Typography>
|
</Typography>
|
||||||
</Divider>
|
</Divider>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
|
||||||
|
mb: { xs: 2, sm: 3 } // 320px: 16px, 600px+: 24px
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="large"
|
||||||
onClick={handleUnavailableFeature}
|
onClick={handleUnavailableFeature}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.5,
|
py: { xs: 1.25, sm: 1.5 }, // 320px: 10px, 600px+: 12px
|
||||||
|
fontSize: { xs: '0.875rem', sm: '0.938rem' }, // 320px: 14px, 600px+: 15px
|
||||||
borderColor: '#FEE500',
|
borderColor: '#FEE500',
|
||||||
bgcolor: '#FEE500',
|
bgcolor: '#FEE500',
|
||||||
color: '#000000',
|
color: '#000000',
|
||||||
@ -287,7 +345,7 @@ export default function LoginPage() {
|
|||||||
borderColor: '#FDD835',
|
borderColor: '#FDD835',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
startIcon={<ChatBubble />}
|
startIcon={<ChatBubble sx={{ fontSize: { xs: 20, sm: 24 } }} />}
|
||||||
>
|
>
|
||||||
카카오톡으로 시작하기
|
카카오톡으로 시작하기
|
||||||
</Button>
|
</Button>
|
||||||
@ -298,7 +356,8 @@ export default function LoginPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
onClick={handleUnavailableFeature}
|
onClick={handleUnavailableFeature}
|
||||||
sx={{
|
sx={{
|
||||||
py: 1.5,
|
py: { xs: 1.25, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.875rem', sm: '0.938rem' },
|
||||||
borderColor: '#03C75A',
|
borderColor: '#03C75A',
|
||||||
bgcolor: '#03C75A',
|
bgcolor: '#03C75A',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
@ -311,8 +370,8 @@ export default function LoginPage() {
|
|||||||
startIcon={
|
startIcon={
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 20,
|
width: { xs: 18, sm: 20 }, // 320px: 18px, 600px+: 20px
|
||||||
height: 20,
|
height: { xs: 18, sm: 20 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -320,7 +379,7 @@ export default function LoginPage() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: '#03C75A',
|
color: '#03C75A',
|
||||||
fontSize: 14,
|
fontSize: { xs: 12, sm: 14 }, // 320px: 12px, 600px+: 14px
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
N
|
N
|
||||||
@ -332,18 +391,106 @@ export default function LoginPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 약관 동의 안내 */}
|
{/* 약관 동의 안내 */}
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{
|
||||||
|
textAlign: 'center',
|
||||||
|
display: 'block',
|
||||||
|
fontSize: { xs: '0.75rem', sm: '0.813rem' }, // 320px: 12px, 600px+: 13px
|
||||||
|
lineHeight: 1.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
회원가입 시{' '}
|
회원가입 시{' '}
|
||||||
<Link href="#" onClick={handleUnavailableFeature} underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
이용약관
|
이용약관
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
및{' '}
|
및{' '}
|
||||||
<Link href="#" onClick={handleUnavailableFeature} underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
fontSize: 'inherit'
|
||||||
|
}}
|
||||||
|
>
|
||||||
개인정보처리방침
|
개인정보처리방침
|
||||||
</Link>
|
</Link>
|
||||||
에 동의하게 됩니다.
|
에 동의하게 됩니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* 지원하지 않는 기능 모달 */}
|
||||||
|
<Dialog
|
||||||
|
open={openUnavailableModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
px: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
|
||||||
|
py: { xs: 0.5, sm: 1 }, // 320px: 4px, 600px+: 8px
|
||||||
|
m: { xs: 2, sm: 3 }, // 320px: 16px, 600px+: 24px
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: { xs: 1, sm: 1.5 }, // 320px: 8px, 600px+: 12px
|
||||||
|
pb: { xs: 1.5, sm: 2 }, // 320px: 12px, 600px+: 16px
|
||||||
|
px: { xs: 1, sm: 2 }, // 320px: 8px, 600px+: 16px
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info color="info" sx={{ fontSize: { xs: 24, sm: 28 } }} /> {/* 320px: 24px, 600px+: 28px */}
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
sx={{
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '1rem', sm: '1.125rem' } // 320px: 16px, 600px+: 18px
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
안내
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ px: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
|
<Typography
|
||||||
|
variant="body1"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary',
|
||||||
|
lineHeight: 1.7,
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' } // 320px: 14px, 600px+: 16px
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
현재 지원하지 않는 기능입니다.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: { xs: 2, sm: 3 }, pb: { xs: 2, sm: 3 } }}> {/* 320px: 16px, 600px+: 24px */}
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
py: { xs: 1.25, sm: 1.5 }, // 320px: 10px, 600px+: 12px
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' }, // 320px: 14px, 600px+: 16px
|
||||||
|
...getGradientButtonStyle('primary'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -97,7 +97,15 @@ export default function AnalyticsPage() {
|
|||||||
setLastUpdate(new Date());
|
setLastUpdate(new Date());
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Analytics 데이터 로드 실패:', error);
|
console.error('❌ Analytics 데이터 로드 실패:', error);
|
||||||
// 에러 발생 시에도 로딩 상태 해제
|
|
||||||
|
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
|
||||||
|
if (error.response?.status === 404 || error.response?.status === 400) {
|
||||||
|
console.log('ℹ️ Analytics 데이터가 아직 생성되지 않았습니다.');
|
||||||
|
// 에러 상태를 설정하지 않고 빈 데이터로 표시
|
||||||
|
} else {
|
||||||
|
// 다른 에러는 에러로 처리
|
||||||
|
console.error('❌ 예상치 못한 에러:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -145,7 +153,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -167,7 +175,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -326,7 +334,7 @@ export default function AnalyticsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@ -603,7 +611,7 @@ export default function AnalyticsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<Box sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
채널별 참여 데이터가 없습니다.
|
채널별 참여 데이터가 없습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@ -248,8 +248,8 @@ export default function DrawPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* 에러 메시지 */}
|
{/* 에러 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
|
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
|
||||||
@ -261,7 +261,7 @@ export default function DrawPage() {
|
|||||||
{!showResults && (
|
{!showResults && (
|
||||||
<>
|
<>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
||||||
🎲 당첨자 추첨
|
🎲 당첨자 추첨
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -271,7 +271,7 @@ export default function DrawPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Event Info Summary Cards */}
|
{/* Event Info Summary Cards */}
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={6} md={6}>
|
<Grid item xs={6} md={6}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@ -281,12 +281,12 @@ export default function DrawPage() {
|
|||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<EventNote sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<EventNote sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<Typography variant="caption" display="block" sx={{ mb: { xs: 1, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||||||
이벤트명
|
이벤트명
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
|
||||||
{eventName}
|
{eventName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -301,12 +301,12 @@ export default function DrawPage() {
|
|||||||
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
|
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<People sx={{ fontSize: { xs: 32, sm: 40 }, mb: { xs: 1, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<Typography variant="caption" display="block" sx={{ mb: { xs: 1, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||||||
총 참여자
|
총 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '1rem', sm: '1.75rem' } }}>
|
||||||
{totalParticipants}명
|
{totalParticipants}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -315,8 +315,8 @@ export default function DrawPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Drawing Settings */}
|
{/* Drawing Settings */}
|
||||||
<Card elevation={0} sx={{ mb: 10, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
<Card elevation={0} sx={{ mb: { xs: 4, sm: 10 }, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
||||||
<Tune sx={{ fontSize: 32, color: colors.pink }} />
|
<Tune sx={{ fontSize: 32, color: colors.pink }} />
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||||
@ -428,7 +428,7 @@ export default function DrawPage() {
|
|||||||
startIcon={<Casino sx={{ fontSize: 28 }} />}
|
startIcon={<Casino sx={{ fontSize: 28 }} />}
|
||||||
onClick={handleStartDrawing}
|
onClick={handleStartDrawing}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
py: 3,
|
py: 3,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
@ -452,7 +452,7 @@ export default function DrawPage() {
|
|||||||
{showResults && (
|
{showResults && (
|
||||||
<>
|
<>
|
||||||
{/* Results Header */}
|
{/* Results Header */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 10 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
||||||
🎉 추첨 완료!
|
🎉 추첨 완료!
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -467,7 +467,7 @@ export default function DrawPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Winner List */}
|
{/* Winner List */}
|
||||||
<Box sx={{ mb: 10 }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
|
||||||
🏆 당첨자 목록
|
🏆 당첨자 목록
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -482,7 +482,7 @@ export default function DrawPage() {
|
|||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 5 }}>
|
<CardContent sx={{ p: { xs: 2.5, sm: 5 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
|||||||
@ -155,74 +155,64 @@ export default function EventDetailPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||||||
|
|
||||||
// Analytics API 호출 (임시 주석처리 - 서버 이슈)
|
// Analytics API 호출
|
||||||
const fetchAnalytics = async (forceRefresh = false) => {
|
const fetchAnalytics = async (forceRefresh = false) => {
|
||||||
try {
|
try {
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
console.log('🔄 Mock 데이터 새로고침...');
|
console.log('🔄 Analytics 데이터 새로고침...');
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
} else {
|
} else {
|
||||||
console.log('📊 Mock Analytics 데이터 로딩...');
|
console.log('📊 Analytics 데이터 로딩...');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// TODO: Analytics API 서버 이슈 해결 후 주석 해제
|
|
||||||
// Event Analytics API 병렬 호출
|
// Event Analytics API 병렬 호출
|
||||||
// const [dashboard, timeline, roi, channels] = await Promise.all([
|
const [dashboard, timeline, roi, channels] = await Promise.all([
|
||||||
// analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
|
analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
|
||||||
// analyticsApi.getEventTimelineAnalytics(eventId, {
|
analyticsApi.getEventTimelineAnalytics(eventId, {
|
||||||
// interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
|
interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
|
||||||
// refresh: forceRefresh
|
}),
|
||||||
// }),
|
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true }),
|
||||||
// analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true, refresh: forceRefresh }),
|
analyticsApi.getEventChannelAnalytics(eventId, {}),
|
||||||
// analyticsApi.getEventChannelAnalytics(eventId, { refresh: forceRefresh }),
|
]);
|
||||||
// ]);
|
|
||||||
|
|
||||||
// 임시 Mock 데이터
|
console.log('✅ Dashboard 데이터:', dashboard);
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
console.log('✅ Timeline 데이터:', timeline);
|
||||||
|
console.log('✅ ROI 데이터:', roi);
|
||||||
const mockAnalyticsData = {
|
console.log('✅ Channels 데이터:', channels);
|
||||||
dashboard: {
|
|
||||||
summary: {
|
|
||||||
participants: mockEventData.participants,
|
|
||||||
totalViews: mockEventData.views,
|
|
||||||
conversionRate: mockEventData.conversion / 100,
|
|
||||||
},
|
|
||||||
roi: {
|
|
||||||
roi: mockEventData.roi,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timeline: {
|
|
||||||
participations: [
|
|
||||||
{ date: '2025-01-15', count: 12 },
|
|
||||||
{ date: '2025-01-16', count: 18 },
|
|
||||||
{ date: '2025-01-17', count: 25 },
|
|
||||||
{ date: '2025-01-18', count: 31 },
|
|
||||||
{ date: '2025-01-19', count: 22 },
|
|
||||||
{ date: '2025-01-20', count: 20 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
roi: {
|
|
||||||
currentRoi: mockEventData.roi,
|
|
||||||
projectedRoi: mockEventData.roi + 50,
|
|
||||||
},
|
|
||||||
channels: {
|
|
||||||
distribution: [
|
|
||||||
{ channel: '우리동네TV', participants: 45 },
|
|
||||||
{ channel: '링고비즈', participants: 38 },
|
|
||||||
{ channel: 'SNS', participants: 45 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Analytics 데이터 저장
|
// Analytics 데이터 저장
|
||||||
setAnalyticsData(mockAnalyticsData);
|
const formattedAnalyticsData = {
|
||||||
|
dashboard,
|
||||||
|
timeline,
|
||||||
|
roi,
|
||||||
|
channels,
|
||||||
|
};
|
||||||
|
|
||||||
console.log('✅ Mock Analytics 데이터 로딩 완료');
|
setAnalyticsData(formattedAnalyticsData);
|
||||||
|
|
||||||
|
// Event 객체 업데이트 - Analytics 데이터 반영
|
||||||
|
setEvent(prev => ({
|
||||||
|
...prev,
|
||||||
|
participants: dashboard.summary.participants,
|
||||||
|
views: dashboard.summary.totalViews,
|
||||||
|
conversion: dashboard.summary.conversionRate * 100,
|
||||||
|
roi: dashboard.roi.roi,
|
||||||
|
title: dashboard.eventTitle,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('✅ Analytics 데이터 로딩 완료');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Analytics 데이터 로딩 실패:', err);
|
console.error('❌ Analytics 데이터 로딩 실패:', err);
|
||||||
|
|
||||||
|
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
|
||||||
|
if (err.response?.status === 404 || err.response?.status === 400) {
|
||||||
|
console.log('ℹ️ Analytics 데이터가 아직 생성되지 않았습니다.');
|
||||||
|
setError('이벤트의 Analytics 데이터가 아직 생성되지 않았습니다. 참여자가 생기면 자동으로 생성됩니다.');
|
||||||
|
} else {
|
||||||
setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.');
|
setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
@ -579,7 +569,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -612,7 +603,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -642,7 +634,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@ -672,7 +665,8 @@ export default function EventDetailPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 },
|
||||||
|
pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@ -168,82 +168,82 @@ export default function ParticipantsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.5rem', sm: '2rem' }, mb: 2 }}>
|
||||||
👥 참여자 목록
|
👥 참여자 목록
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
이벤트에 참여한 사용자들의 정보를 확인하고 관리하세요
|
이벤트에 참여한 사용자들의 정보를 확인하고 관리하세요
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Error Alert */}
|
{/* Error Alert */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
|
<Alert severity="error" sx={{ mb: { xs: 3, sm: 6 } }} icon={<ErrorIcon />} onClose={() => setError('')}>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={{ xs: 1, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<People sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
|
||||||
전체 참여자
|
전체 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.total}명
|
{loading ? '...' : stats.total}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.yellow} 0%, #FCD34D 100%)`,
|
background: `linear-gradient(135deg, ${colors.yellow} 0%, #FCD34D 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<AccessTime sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<AccessTime sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
|
||||||
대기중
|
대기중
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.waiting}명
|
{loading ? '...' : stats.waiting}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={4}>
|
<Grid item xs={4} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: { xs: 2, sm: 4 },
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
|
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<CardContent sx={{ textAlign: 'center', py: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<TrendingUp sx={{ fontSize: 40, mb: 2, color: 'white' }} />
|
<TrendingUp sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
|
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.2 }}>
|
||||||
당첨자
|
당첨자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.875rem', sm: '1.75rem' } }}>
|
||||||
{loading ? '...' : stats.winner}명
|
{loading ? '...' : stats.winner}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -252,7 +252,7 @@ export default function ParticipantsPage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{/* Search Section */}
|
{/* Search Section */}
|
||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="이름, 전화번호 또는 이메일 검색..."
|
placeholder="이름, 전화번호 또는 이메일 검색..."
|
||||||
@ -261,7 +261,7 @@ export default function ParticipantsPage() {
|
|||||||
InputProps={{
|
InputProps={{
|
||||||
startAdornment: (
|
startAdornment: (
|
||||||
<InputAdornment position="start">
|
<InputAdornment position="start">
|
||||||
<Search />
|
<Search sx={{ fontSize: { xs: 18, sm: 24 } }} />
|
||||||
</InputAdornment>
|
</InputAdornment>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
@ -269,6 +269,10 @@ export default function ParticipantsPage() {
|
|||||||
'& .MuiOutlinedInput-root': {
|
'& .MuiOutlinedInput-root': {
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
},
|
||||||
|
'& .MuiOutlinedInput-input': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@ -276,11 +280,11 @@ export default function ParticipantsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 }, flexWrap: 'wrap' }}>
|
||||||
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
|
<FilterList sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.pink }} />
|
||||||
<FormControl sx={{ flex: 1, minWidth: 160 }}>
|
<FormControl sx={{ flex: 1, minWidth: { xs: 100, sm: 160 } }}>
|
||||||
<InputLabel>매장 방문</InputLabel>
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>매장 방문</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
|
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
|
||||||
label="매장 방문"
|
label="매장 방문"
|
||||||
@ -291,47 +295,60 @@ export default function ParticipantsPage() {
|
|||||||
);
|
);
|
||||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||||
}}
|
}}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체</MenuItem>
|
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>전체</MenuItem>
|
||||||
<MenuItem value="visited">방문</MenuItem>
|
<MenuItem value="visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>방문</MenuItem>
|
||||||
<MenuItem value="not_visited">미방문</MenuItem>
|
<MenuItem value="not_visited" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>미방문</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ flex: 1, minWidth: 140 }}>
|
<FormControl sx={{ flex: 1, minWidth: { xs: 90, sm: 140 } }}>
|
||||||
<InputLabel>상태</InputLabel>
|
<InputLabel sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>상태</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
label="상태"
|
label="상태"
|
||||||
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{
|
||||||
|
borderRadius: 2,
|
||||||
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
|
'& .MuiSelect-select': {
|
||||||
|
padding: { xs: '8px 14px', sm: '16.5px 14px' },
|
||||||
|
},
|
||||||
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체</MenuItem>
|
<MenuItem value="all" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>전체</MenuItem>
|
||||||
<MenuItem value="waiting">당첨 대기</MenuItem>
|
<MenuItem value="waiting" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>당첨 대기</MenuItem>
|
||||||
<MenuItem value="winner">당첨</MenuItem>
|
<MenuItem value="winner" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>당첨</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Total Count & Drawing Button */}
|
{/* Total Count & Drawing Button */}
|
||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: { xs: 2, sm: 4 } }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '0.875rem', sm: '1.5rem' } }}>
|
||||||
총 <span style={{ color: colors.pink }}>{filteredParticipants.length}</span>명 표시
|
총 <span style={{ color: colors.pink }}>{filteredParticipants.length}</span>명 표시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
<Box sx={{ display: 'flex', gap: { xs: 1.5, sm: 3 } }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<Download />}
|
startIcon={<Download sx={{ fontSize: { xs: 16, sm: 20 } }} />}
|
||||||
onClick={handleDownloadClick}
|
onClick={handleDownloadClick}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: { xs: 1.5, sm: 4 },
|
||||||
py: 1.5,
|
py: { xs: 0.75, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.7rem', sm: '0.875rem' },
|
||||||
borderColor: colors.blue,
|
borderColor: colors.blue,
|
||||||
color: colors.blue,
|
color: colors.blue,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
@ -340,17 +357,23 @@ export default function ParticipantsPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||||
엑셀 다운로드
|
엑셀 다운로드
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
|
||||||
|
엑셀
|
||||||
|
</Box>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<Casino />}
|
startIcon={<Casino sx={{ fontSize: { xs: 16, sm: 20 } }} />}
|
||||||
onClick={handleDrawClick}
|
onClick={handleDrawClick}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: { xs: 1.5, sm: 4 },
|
||||||
py: 1.5,
|
py: { xs: 0.75, sm: 1.5 },
|
||||||
|
fontSize: { xs: '0.7rem', sm: '0.875rem' },
|
||||||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
|
||||||
@ -358,7 +381,12 @@ export default function ParticipantsPage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
|
||||||
당첨자 추첨
|
당첨자 추첨
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'inline', sm: 'none' } }}>
|
||||||
|
추첨
|
||||||
|
</Box>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@ -391,7 +419,7 @@ export default function ParticipantsPage() {
|
|||||||
{/* Participant List */}
|
{/* Participant List */}
|
||||||
{!loading && filteredParticipants.length > 0 && (
|
{!loading && filteredParticipants.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Box sx={{ mb: 10 }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{filteredParticipants.map((participant) => (
|
{filteredParticipants.map((participant) => (
|
||||||
<Card
|
<Card
|
||||||
@ -409,21 +437,23 @@ export default function ParticipantsPage() {
|
|||||||
}}
|
}}
|
||||||
onClick={() => handleParticipantClick(participant)}
|
onClick={() => handleParticipantClick(participant)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 5 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 5 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
mb: 4,
|
mb: { xs: 2, sm: 4 },
|
||||||
|
flexWrap: { xs: 'wrap', sm: 'nowrap' },
|
||||||
|
gap: { xs: 2, sm: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 56,
|
width: { xs: 48, sm: 56 },
|
||||||
height: 56,
|
height: { xs: 48, sm: 56 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: colors.purpleLight,
|
bgcolor: colors.purpleLight,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -431,16 +461,16 @@ export default function ParticipantsPage() {
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 32, color: colors.purple }} />
|
<Person sx={{ fontSize: { xs: 28, sm: 32 }, color: colors.purple }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block', fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||||||
#{participant.participantId}
|
#{participant.participantId}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.875rem', sm: '1.25rem' } }}>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
{participant.phoneNumber}
|
{participant.phoneNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -449,7 +479,7 @@ export default function ParticipantsPage() {
|
|||||||
label={getStatusText(participant.isWinner)}
|
label={getStatusText(participant.isWinner)}
|
||||||
color={getStatusColor(participant.isWinner) as any}
|
color={getStatusColor(participant.isWinner) as any}
|
||||||
size="medium"
|
size="medium"
|
||||||
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
|
sx={{ fontWeight: 600, px: { xs: 1.5, sm: 2 }, py: { xs: 2, sm: 2.5 }, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -458,15 +488,15 @@ export default function ParticipantsPage() {
|
|||||||
sx={{
|
sx={{
|
||||||
borderTop: '1px solid',
|
borderTop: '1px solid',
|
||||||
borderColor: colors.gray[100],
|
borderColor: colors.gray[100],
|
||||||
pt: 4,
|
pt: { xs: 2, sm: 4 },
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 2,
|
gap: { xs: 1.5, sm: 2 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{participant.channel && (
|
{participant.channel && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
참여 경로
|
참여 경로
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
@ -476,24 +506,25 @@ export default function ParticipantsPage() {
|
|||||||
bgcolor: colors.purpleLight,
|
bgcolor: colors.purpleLight,
|
||||||
color: colors.purple,
|
color: colors.purple,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
fontSize: { xs: '0.625rem', sm: '0.75rem' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
참여 일시
|
참여 일시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.75rem', sm: '1rem' }, textAlign: 'right' }}>
|
||||||
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
|
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
{participant.storeVisited && (
|
{participant.storeVisited && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '1rem' } }}>
|
||||||
매장 방문
|
매장 방문
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip label="방문" size="small" color="success" />
|
<Chip label="방문" size="small" color="success" sx={{ fontSize: { xs: '0.625rem', sm: '0.75rem' } }} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@ -505,7 +536,7 @@ export default function ParticipantsPage() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 10 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<Pagination
|
<Pagination
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
@ -514,8 +545,14 @@ export default function ParticipantsPage() {
|
|||||||
size="large"
|
size="large"
|
||||||
sx={{
|
sx={{
|
||||||
'& .MuiPaginationItem-root': {
|
'& .MuiPaginationItem-root': {
|
||||||
fontSize: '1rem',
|
fontSize: { xs: '0.75rem', sm: '1rem' },
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
|
minWidth: { xs: '26px', sm: '32px' },
|
||||||
|
height: { xs: '26px', sm: '32px' },
|
||||||
|
margin: { xs: '0 2px', sm: '0 4px' },
|
||||||
|
},
|
||||||
|
'& .MuiPaginationItem-icon': {
|
||||||
|
fontSize: { xs: '1rem', sm: '1.5rem' },
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -558,7 +595,7 @@ export default function ParticipantsPage() {
|
|||||||
mb: 3,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Person sx={{ fontSize: 40, color: colors.purple }} />
|
<Person sx={{ fontSize: { xs: 32, sm: 40 }, color: colors.purple }} />
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||||
{selectedParticipant.name}
|
{selectedParticipant.name}
|
||||||
|
|||||||
@ -104,7 +104,6 @@ export default function EventCreatePage() {
|
|||||||
recommendation={({ context, history }) => (
|
recommendation={({ context, history }) => (
|
||||||
<RecommendationStep
|
<RecommendationStep
|
||||||
eventId={context.eventId}
|
eventId={context.eventId}
|
||||||
objective={context.objective}
|
|
||||||
onNext={(recommendation) => {
|
onNext={(recommendation) => {
|
||||||
history.push('channel', { ...context, recommendation });
|
history.push('channel', { ...context, recommendation });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -156,10 +156,10 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -169,7 +169,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<Box sx={{ textAlign: 'center', mb: 10 }}>
|
<Box sx={{ textAlign: 'center', mb: { xs: 4, sm: 10 } }}>
|
||||||
<CheckCircle sx={{ fontSize: 64, color: colors.purple, mb: 2 }} />
|
<CheckCircle sx={{ fontSize: 64, color: colors.purple, mb: 2 }} />
|
||||||
<Typography variant="h5" sx={{ ...responsiveText.h3, fontWeight: 700, mb: 2 }}>
|
<Typography variant="h5" sx={{ ...responsiveText.h3, fontWeight: 700, mb: 2 }}>
|
||||||
이벤트를 확인해주세요
|
이벤트를 확인해주세요
|
||||||
@ -180,7 +180,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Event Summary Statistics */}
|
{/* Event Summary Statistics */}
|
||||||
<Grid container spacing={4} sx={{ mb: 10 }}>
|
<Grid container spacing={4} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
<Grid item xs={12} sm={6} md={3}>
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
@ -392,7 +392,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
@ -412,7 +412,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
배포 채널
|
배포 채널
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 4 }}>
|
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2, mb: 4 }}>
|
||||||
{getChannelNames(eventData.channels).map((channel) => (
|
{getChannelNames(eventData.channels).map((channel) => (
|
||||||
@ -445,7 +445,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Terms Agreement */}
|
{/* Terms Agreement */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, bgcolor: colors.gray[50], mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
@ -616,7 +616,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
|||||||
<Typography variant="h5" sx={{ fontSize: '1.5rem', fontWeight: 700, mb: 3 }}>
|
<Typography variant="h5" sx={{ fontSize: '1.5rem', fontWeight: 700, mb: 3 }}>
|
||||||
배포 완료!
|
배포 완료!
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: 8 }}>
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1rem', mb: { xs: 3, sm: 8 } }}>
|
||||||
이벤트가 성공적으로 배포되었습니다.
|
이벤트가 성공적으로 배포되었습니다.
|
||||||
<br />
|
<br />
|
||||||
실시간으로 참여자를 확인할 수 있습니다.
|
실시간으로 참여자를 확인할 수 있습니다.
|
||||||
|
|||||||
@ -108,9 +108,9 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -119,7 +119,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
|
||||||
(최소 1개 이상)
|
(최소 1개 이상)
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
@ -136,7 +136,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -211,7 +211,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -270,7 +270,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -347,7 +347,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: channels[3].selected ? 2 : 1,
|
border: channels[3].selected ? 2 : 1,
|
||||||
borderColor: channels[3].selected ? colors.purple : 'divider',
|
borderColor: channels[3].selected ? colors.purple : 'divider',
|
||||||
@ -356,7 +356,7 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
transition: 'all 0.3s',
|
transition: 'all 0.3s',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -465,13 +465,13 @@ export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: 'grey.50',
|
bgcolor: 'grey.50',
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 8 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 4 }}>
|
||||||
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
||||||
총 예상 비용
|
총 예상 비용
|
||||||
|
|||||||
@ -40,10 +40,10 @@ export default function ContentEditStep({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: colors.gray[50], pt: { xs: 7, sm: 8 }, pb: { xs: 4, sm: 10 } }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 10 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 4, sm: 10 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|||||||
@ -310,8 +310,8 @@ export default function ContentPreviewStep({
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -442,9 +442,9 @@ export default function ContentPreviewStep({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack}>
|
<IconButton onClick={onBack}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -453,7 +453,7 @@ export default function ContentPreviewStep({
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: { xs: 3, sm: 8 } }}>
|
||||||
{generatedImages.size > 0 && (
|
{generatedImages.size > 0 && (
|
||||||
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
|
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
|
||||||
✨ 생성된 이미지를 확인하고 스타일을 선택하세요
|
✨ 생성된 이미지를 확인하고 스타일을 선택하세요
|
||||||
@ -477,12 +477,12 @@ export default function ContentPreviewStep({
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
|
||||||
이벤트에 어울리는 스타일을 선택하세요
|
이벤트에 어울리는 스타일을 선택하세요
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
|
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{imageStyles.map((style) => (
|
{imageStyles.map((style) => (
|
||||||
<Grid item xs={12} md={4} key={style.id}>
|
<Grid item xs={12} md={4} key={style.id}>
|
||||||
<Card
|
<Card
|
||||||
|
|||||||
@ -127,21 +127,21 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="md" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="md" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Title Section */}
|
{/* Title Section */}
|
||||||
<Box sx={{ mb: 10, textAlign: 'center' }}>
|
<Box sx={{ mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
|
||||||
<AutoAwesome sx={{ fontSize: 80, color: colors.purple, mb: 4 }} />
|
<AutoAwesome sx={{ fontSize: { xs: 60, sm: 80 }, color: colors.purple, mb: { xs: 2, sm: 4 } }} />
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, mb: { xs: 2, sm: 4 }, fontSize: { xs: '1.5rem', sm: '2rem' } }}>
|
||||||
이벤트 목적을 선택해주세요
|
이벤트 목적을 선택해주세요
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.125rem' }}>
|
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
|
||||||
AI가 목적에 맞는 최적의 이벤트를 추천해드립니다
|
AI가 목적에 맞는 최적의 이벤트를 추천해드립니다
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Purpose Options */}
|
{/* Purpose Options */}
|
||||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
|
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{objectives.map((objective) => (
|
{objectives.map((objective) => (
|
||||||
<Grid item xs={12} sm={6} key={objective.id}>
|
<Grid item xs={12} sm={6} key={objective.id}>
|
||||||
<Card
|
<Card
|
||||||
@ -162,14 +162,14 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
}}
|
}}
|
||||||
onClick={() => setSelected(objective.id)}
|
onClick={() => setSelected(objective.id)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 3, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 2, sm: 3 }, mb: { xs: 2, sm: 3 } }}>
|
||||||
<Box sx={{ color: colors.purple }}>{objective.icon}</Box>
|
<Box sx={{ color: colors.purple }}>{objective.icon}</Box>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: { xs: 1, sm: 2 }, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
{objective.title}
|
{objective.title}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
{objective.description}
|
{objective.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -191,15 +191,15 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blue}20 100%)`,
|
background: `linear-gradient(135deg, ${colors.purpleLight} 0%, ${colors.blue}20 100%)`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ display: 'flex', gap: 3, p: 6 }}>
|
<CardContent sx={{ display: 'flex', gap: { xs: 2, sm: 3 }, p: { xs: 3, sm: 6 } }}>
|
||||||
<AutoAwesome sx={{ color: colors.purple, fontSize: 28 }} />
|
<AutoAwesome sx={{ color: colors.purple, fontSize: { xs: 24, sm: 28 } }} />
|
||||||
<Typography variant="body2" sx={{ fontSize: '1rem', lineHeight: 1.8, color: colors.gray[700] }}>
|
<Typography variant="body2" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' }, lineHeight: 1.8, color: colors.gray[700] }}>
|
||||||
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -214,9 +214,9 @@ export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
|||||||
disabled={!selected}
|
disabled={!selected}
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
sx={{
|
sx={{
|
||||||
py: 3,
|
py: { xs: 2, sm: 3 },
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
fontSize: '1rem',
|
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
|
|||||||
@ -42,11 +42,7 @@ const colors = {
|
|||||||
|
|
||||||
interface RecommendationStepProps {
|
interface RecommendationStepProps {
|
||||||
eventId?: string; // 이전 단계에서 생성된 eventId
|
eventId?: string; // 이전 단계에서 생성된 eventId
|
||||||
objective?: string; // 이전 단계에서 선택된 objective
|
onNext: (data: { recommendation: EventRecommendation; eventId: string }) => void;
|
||||||
onNext: (data: {
|
|
||||||
recommendation: EventRecommendation;
|
|
||||||
eventId: string;
|
|
||||||
}) => void;
|
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,37 +58,27 @@ const getCookie = (name: string): string | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 쿠키 저장 함수
|
|
||||||
const setCookie = (name: string, value: string, days: number = 1) => {
|
|
||||||
if (typeof document === 'undefined') return;
|
|
||||||
|
|
||||||
const expires = new Date();
|
|
||||||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
|
||||||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RecommendationStep({
|
export default function RecommendationStep({
|
||||||
eventId: initialEventId,
|
eventId: initialEventId,
|
||||||
objective,
|
|
||||||
onNext,
|
onNext,
|
||||||
onBack
|
onBack,
|
||||||
}: RecommendationStepProps) {
|
}: RecommendationStepProps) {
|
||||||
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [polling, setPolling] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [aiResult, setAiResult] = useState<AiRecommendationResult | null>(null);
|
const [aiResult, setAiResult] = useState<AiRecommendationResult | null>(null);
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
|
const [editedData, setEditedData] = useState<
|
||||||
|
Record<number, { title: string; description: string }>
|
||||||
|
>({});
|
||||||
|
|
||||||
// 중복 호출 방지를 위한 ref
|
// 중복 호출 방지를 위한 ref
|
||||||
const requestedEventIdRef = useRef<string | null>(null);
|
const requestedEventIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// 컴포넌트 마운트 시 AI 추천 요청
|
// 컴포넌트 마운트 시 AI 추천 결과 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// props에서만 eventId를 받음 (쿠키 사용 안 함)
|
// props에서만 eventId를 받음
|
||||||
if (initialEventId) {
|
if (initialEventId) {
|
||||||
// 이미 요청한 eventId면 중복 요청하지 않음
|
// 이미 요청한 eventId면 중복 요청하지 않음
|
||||||
if (requestedEventIdRef.current === initialEventId) {
|
if (requestedEventIdRef.current === initialEventId) {
|
||||||
@ -103,159 +89,40 @@ export default function RecommendationStep({
|
|||||||
requestedEventIdRef.current = initialEventId;
|
requestedEventIdRef.current = initialEventId;
|
||||||
setEventId(initialEventId);
|
setEventId(initialEventId);
|
||||||
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
|
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
|
||||||
// eventId가 있으면 바로 AI 추천 요청
|
// eventId가 있으면 바로 AI 추천 결과 조회
|
||||||
requestAIRecommendations(initialEventId);
|
fetchAIRecommendations(initialEventId);
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
|
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
|
||||||
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||||||
}
|
}
|
||||||
}, [initialEventId]);
|
}, [initialEventId]);
|
||||||
|
|
||||||
const requestAIRecommendations = async (evtId: string) => {
|
const fetchAIRecommendations = async (evtId: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// 로그인한 사용자 정보에서 매장 정보 가져오기
|
console.log('📡 AI 추천 요청 시작, eventId:', evtId);
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
|
||||||
console.log('📋 localStorage user:', user);
|
|
||||||
|
|
||||||
// UUID v4 생성 함수 (테스트용)
|
// POST /events/{eventId}/ai-recommendations 엔드포인트로 AI 추천 요청
|
||||||
const generateUUID = () => {
|
const recommendations = await eventApi.requestAiRecommendations(evtId);
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
|
||||||
const r = Math.random() * 16 | 0;
|
|
||||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
|
||||||
return v.toString(16);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// storeId: 로그인한 경우 user.storeId 사용, 아니면 테스트용 UUID 생성
|
console.log('✅ AI 추천 요청 성공:', recommendations);
|
||||||
const storeInfo = {
|
|
||||||
storeId: user.storeId ? String(user.storeId) : generateUUID(),
|
|
||||||
storeName: user.storeName || '테스트 매장',
|
|
||||||
category: user.industry || '음식점',
|
|
||||||
description: user.businessHours || '테스트 설명',
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('📤 전송할 storeInfo:', storeInfo);
|
|
||||||
console.log('🎯 eventId:', evtId);
|
|
||||||
console.log('🎯 objective:', objective || '테스트 목적');
|
|
||||||
|
|
||||||
// AI 추천 요청
|
|
||||||
const jobResponse = await eventApi.requestAiRecommendations(
|
|
||||||
evtId,
|
|
||||||
objective || '테스트 목적',
|
|
||||||
storeInfo
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('📦 백엔드 응답 (전체):', JSON.stringify(jobResponse, null, 2));
|
|
||||||
|
|
||||||
// 백엔드 응답 구조 확인: { success, data: { jobId, status, message }, timestamp }
|
|
||||||
const actualJobId = (jobResponse as any).data?.jobId || jobResponse.jobId;
|
|
||||||
|
|
||||||
console.log('📦 jobResponse.data?.jobId:', (jobResponse as any).data?.jobId);
|
|
||||||
console.log('📦 jobResponse.jobId:', jobResponse.jobId);
|
|
||||||
console.log('📦 실제 사용할 jobId:', actualJobId);
|
|
||||||
|
|
||||||
if (!actualJobId) {
|
|
||||||
console.error('❌ 백엔드에서 jobId를 반환하지 않았습니다!');
|
|
||||||
console.error('📦 응답 구조:', JSON.stringify(jobResponse, null, 2));
|
|
||||||
setError('백엔드에서 Job ID를 받지 못했습니다. 백엔드 응답을 확인해주세요.');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// jobId를 쿠키에 저장
|
|
||||||
setCookie('jobId', actualJobId, 1);
|
|
||||||
setJobId(actualJobId);
|
|
||||||
console.log('✅ AI 추천 Job 생성 완료, jobId:', actualJobId);
|
|
||||||
console.log('🍪 jobId를 쿠키에 저장:', actualJobId);
|
|
||||||
|
|
||||||
// Job 폴링 시작 (2초 후 시작하여 백엔드에서 Job 저장 시간 확보)
|
|
||||||
setTimeout(() => {
|
|
||||||
pollJobStatus(actualJobId, evtId);
|
|
||||||
}, 2000);
|
|
||||||
} 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 {
|
|
||||||
// jobId 확인: 파라미터 우선, 없으면 쿠키에서 읽기
|
|
||||||
const currentJobId = jId || getCookie('jobId');
|
|
||||||
|
|
||||||
if (!currentJobId) {
|
|
||||||
console.error('❌ jobId를 찾을 수 없습니다 (파라미터와 쿠키 모두 없음)');
|
|
||||||
setError('jobId를 찾을 수 없습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`🔄 Job 상태 조회 시도 (${attempts + 1}/${maxAttempts}), jobId: ${currentJobId}`);
|
|
||||||
console.log(`🍪 jobId 출처: ${jId ? '파라미터' : '쿠키'}`);
|
|
||||||
|
|
||||||
const status = await eventApi.getJobStatus(currentJobId);
|
|
||||||
console.log('✅ Job 상태:', status);
|
|
||||||
|
|
||||||
if (status.status === 'COMPLETED') {
|
|
||||||
// AI 추천 결과 조회 (Event Service API 사용)
|
|
||||||
const recommendations = await eventApi.getAiRecommendations(evtId);
|
|
||||||
setAiResult(recommendations);
|
setAiResult(recommendations);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPolling(false);
|
|
||||||
return;
|
|
||||||
} else if (status.status === 'FAILED') {
|
|
||||||
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 계속 폴링
|
|
||||||
attempts++;
|
|
||||||
if (attempts < maxAttempts) {
|
|
||||||
setTimeout(poll, 5000); // 5초 후 재시도
|
|
||||||
} else {
|
|
||||||
setError('AI 추천 생성 시간이 초과되었습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Job 상태 조회 실패:', err);
|
console.error('❌ AI 추천 요청 실패:', err);
|
||||||
|
|
||||||
// Job을 찾을 수 없는 경우 (404 또는 JOB_001) - 초기 몇 번은 재시도
|
const errorMessage =
|
||||||
if (err.response?.data?.errorCode === 'JOB_001' || err.response?.status === 404) {
|
err.response?.data?.message ||
|
||||||
attempts++;
|
err.response?.data?.error ||
|
||||||
if (attempts < 5) { // 처음 5번 시도는 Job 생성 대기
|
'AI 추천을 생성하는데 실패했습니다';
|
||||||
console.log(`⏳ Job이 아직 준비되지 않음. ${attempts}/5 재시도 예정...`);
|
|
||||||
setTimeout(poll, 3000); // 3초 후 재시도
|
|
||||||
return;
|
|
||||||
} else if (attempts < maxAttempts) {
|
|
||||||
console.log(`⏳ Job 폴링 계속... ${attempts}/${maxAttempts}`);
|
|
||||||
setTimeout(poll, 5000); // 5초 후 재시도
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다른 에러이거나 재시도 횟수 초과
|
setError(errorMessage);
|
||||||
setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setPolling(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
poll();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (selected === null || !aiResult || !eventId) return;
|
if (selected === null || !aiResult || !eventId) return;
|
||||||
|
|
||||||
@ -296,7 +163,7 @@ export default function RecommendationStep({
|
|||||||
...prev,
|
...prev,
|
||||||
[optionNumber]: {
|
[optionNumber]: {
|
||||||
...prev[optionNumber],
|
...prev[optionNumber],
|
||||||
title
|
title,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@ -306,31 +173,33 @@ export default function RecommendationStep({
|
|||||||
...prev,
|
...prev,
|
||||||
[optionNumber]: {
|
[optionNumber]: {
|
||||||
...prev[optionNumber],
|
...prev[optionNumber],
|
||||||
description
|
description,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 로딩 상태 표시
|
// 로딩 상태 표시
|
||||||
if (loading || polling) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 2, sm: 3 }, mb: { xs: 4, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '1.5rem' } }}>
|
||||||
AI 이벤트 추천
|
AI 이벤트 추천
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
|
<Box
|
||||||
|
sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: { xs: 2, sm: 4 }, py: { xs: 6, sm: 12 } }}
|
||||||
|
>
|
||||||
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
AI가 최적의 이벤트를 생성하고 있습니다...
|
AI가 최적의 이벤트를 생성하고 있습니다...
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -343,8 +212,8 @@ export default function RecommendationStep({
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -382,7 +251,7 @@ export default function RecommendationStep({
|
|||||||
const evtId = initialEventId || getCookie('eventId');
|
const evtId = initialEventId || getCookie('eventId');
|
||||||
|
|
||||||
if (evtId) {
|
if (evtId) {
|
||||||
requestAIRecommendations(evtId);
|
fetchAIRecommendations(evtId);
|
||||||
} else {
|
} else {
|
||||||
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||||||
}
|
}
|
||||||
@ -407,7 +276,7 @@ export default function RecommendationStep({
|
|||||||
if (!aiResult) {
|
if (!aiResult) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
<CircularProgress />
|
<CircularProgress />
|
||||||
</Container>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
@ -416,9 +285,9 @@ export default function RecommendationStep({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
|
||||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||||
<ArrowBack />
|
<ArrowBack />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@ -431,12 +300,12 @@ export default function RecommendationStep({
|
|||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mb: 10,
|
mb: { xs: 4, sm: 10 },
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 8 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 8 } }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
|
||||||
<Insights sx={{ fontSize: 32, color: colors.purple }} />
|
<Insights sx={{ fontSize: 32, color: colors.purple }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
|
||||||
@ -449,7 +318,12 @@ export default function RecommendationStep({
|
|||||||
📍 업종 트렌드
|
📍 업종 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
|
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
|
||||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
<Typography
|
||||||
|
key={idx}
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.95rem', mb: 1 }}
|
||||||
|
>
|
||||||
• {trend.description}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -459,7 +333,12 @@ export default function RecommendationStep({
|
|||||||
🗺️ 지역 트렌드
|
🗺️ 지역 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
|
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
|
||||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
<Typography
|
||||||
|
key={idx}
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.95rem', mb: 1 }}
|
||||||
|
>
|
||||||
• {trend.description}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -469,7 +348,12 @@ export default function RecommendationStep({
|
|||||||
☀️ 시즌 트렌드
|
☀️ 시즌 트렌드
|
||||||
</Typography>
|
</Typography>
|
||||||
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
|
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
|
||||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
<Typography
|
||||||
|
key={idx}
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.95rem', mb: 1 }}
|
||||||
|
>
|
||||||
• {trend.description}
|
• {trend.description}
|
||||||
</Typography>
|
</Typography>
|
||||||
))}
|
))}
|
||||||
@ -479,18 +363,19 @@ export default function RecommendationStep({
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* AI Recommendations */}
|
{/* AI Recommendations */}
|
||||||
<Box sx={{ mb: 8 }}>
|
<Box sx={{ mb: { xs: 3, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
||||||
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
||||||
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다.
|
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수
|
||||||
|
있습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Recommendations */}
|
{/* Recommendations */}
|
||||||
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
|
||||||
{aiResult.recommendations.map((rec) => (
|
{aiResult.recommendations.map((rec) => (
|
||||||
<Grid item xs={12} key={rec.optionNumber}>
|
<Grid item xs={12} key={rec.optionNumber}>
|
||||||
<Card
|
<Card
|
||||||
@ -500,9 +385,15 @@ export default function RecommendationStep({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
border: selected === rec.optionNumber ? 2 : 1,
|
border: selected === rec.optionNumber ? 2 : 1,
|
||||||
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
||||||
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
|
bgcolor:
|
||||||
|
selected === rec.optionNumber
|
||||||
|
? `${colors.purpleLight}40`
|
||||||
|
: 'background.paper',
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
boxShadow:
|
||||||
|
selected === rec.optionNumber
|
||||||
|
? '0 4px 12px rgba(0, 0, 0, 0.15)'
|
||||||
|
: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
borderColor: colors.purple,
|
borderColor: colors.purple,
|
||||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||||
@ -511,8 +402,15 @@ export default function RecommendationStep({
|
|||||||
}}
|
}}
|
||||||
onClick={() => setSelected(rec.optionNumber)}
|
onClick={() => setSelected(rec.optionNumber)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={`옵션 ${rec.optionNumber}`}
|
label={`옵션 ${rec.optionNumber}`}
|
||||||
@ -564,39 +462,73 @@ export default function RecommendationStep({
|
|||||||
|
|
||||||
<Grid container spacing={4} sx={{ mt: 2 }}>
|
<Grid container spacing={4} sx={{ mt: 2 }}>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
타겟 고객
|
타겟 고객
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
{rec.targetAudience}
|
{rec.targetAudience}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
예상 비용
|
예상 비용
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
|
{(rec.estimatedCost.min / 10000).toFixed(0)}~
|
||||||
|
{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
예상 신규 고객
|
예상 신규 고객
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
|
{rec.expectedMetrics.newCustomers.min}~
|
||||||
|
{rec.expectedMetrics.newCustomers.max}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={3}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
ROI
|
ROI
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}
|
||||||
|
>
|
||||||
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
|
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12}>
|
<Grid item xs={12}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ fontSize: '0.875rem' }}
|
||||||
|
>
|
||||||
차별점
|
차별점
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
||||||
|
|||||||
@ -276,7 +276,7 @@ export default function EventsPage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import BottomNavigation from '@/shared/ui/BottomNavigation';
|
import BottomNavigation from '@/shared/ui/BottomNavigation';
|
||||||
|
import { AuthGuard } from '@/features/auth';
|
||||||
|
|
||||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
|
<AuthGuard>
|
||||||
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
||||||
{children}
|
{children}
|
||||||
<BottomNavigation />
|
<BottomNavigation />
|
||||||
</Box>
|
</Box>
|
||||||
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab } from '@mui/material';
|
import { Box, Container, Typography, Grid, Card, CardContent, Button, Fab, CircularProgress, Alert } from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
Celebration,
|
Celebration,
|
||||||
@ -19,34 +20,9 @@ import {
|
|||||||
cardStyles,
|
cardStyles,
|
||||||
colors,
|
colors,
|
||||||
} from '@/shared/lib/button-styles';
|
} from '@/shared/lib/button-styles';
|
||||||
|
import { useAuth } from '@/features/auth/model/useAuth';
|
||||||
// Mock 사용자 데이터 (API 연동 전까지 임시 사용)
|
import { analyticsApi } from '@/entities/analytics/api/analyticsApi';
|
||||||
const mockUser = {
|
import type { UserAnalyticsDashboardResponse } from '@/entities/analytics/model/types';
|
||||||
name: '홍길동',
|
|
||||||
email: 'test@example.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock 데이터 (추후 API 연동 시 교체)
|
|
||||||
const mockEvents = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
title: 'SNS 팔로우 이벤트',
|
|
||||||
status: '진행중',
|
|
||||||
startDate: '2025-01-20',
|
|
||||||
endDate: '2025-02-28',
|
|
||||||
participants: 1245,
|
|
||||||
roi: 320,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
title: '설 맞이 할인 이벤트',
|
|
||||||
status: '진행중',
|
|
||||||
startDate: '2025-01-25',
|
|
||||||
endDate: '2025-02-10',
|
|
||||||
participants: 856,
|
|
||||||
roi: 280,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockActivities = [
|
const mockActivities = [
|
||||||
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
||||||
@ -56,14 +32,47 @@ const mockActivities = [
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [analyticsData, setAnalyticsData] = useState<UserAnalyticsDashboardResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// KPI 계산
|
// Analytics API 호출
|
||||||
const activeEvents = mockEvents.filter((e) => e.status === '진행중');
|
useEffect(() => {
|
||||||
const totalParticipants = mockEvents.reduce((sum, e) => sum + e.participants, 0);
|
const fetchAnalytics = async () => {
|
||||||
const avgROI =
|
if (!user?.userId) {
|
||||||
mockEvents.length > 0
|
setLoading(false);
|
||||||
? Math.round(mockEvents.reduce((sum, e) => sum + e.roi, 0) / mockEvents.length)
|
return;
|
||||||
: 0;
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await analyticsApi.getUserAnalytics(user.userId, { refresh: false });
|
||||||
|
setAnalyticsData(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Failed to fetch analytics:', err);
|
||||||
|
|
||||||
|
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
|
||||||
|
if (err.response?.status === 404 || err.response?.status === 400) {
|
||||||
|
console.log('ℹ️ Analytics 데이터가 아직 생성되지 않았습니다.');
|
||||||
|
setError('아직 분석 데이터가 없습니다. 이벤트를 생성하고 참여자가 생기면 자동으로 생성됩니다.');
|
||||||
|
} else {
|
||||||
|
setError('분석 데이터를 불러오는데 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchAnalytics();
|
||||||
|
}, [user?.userId]);
|
||||||
|
|
||||||
|
// KPI 계산 - Analytics API 데이터 사용
|
||||||
|
const activeEventsCount = analyticsData?.activeEvents ?? 0;
|
||||||
|
const totalParticipants = analyticsData?.overallSummary?.participants ?? 0;
|
||||||
|
const avgROI = Math.round((analyticsData?.overallRoi?.roi ?? 0) * 100) / 100;
|
||||||
|
const eventPerformances = analyticsData?.eventPerformances ?? [];
|
||||||
|
|
||||||
const handleCreateEvent = () => {
|
const handleCreateEvent = () => {
|
||||||
router.push('/events/create');
|
router.push('/events/create');
|
||||||
@ -83,7 +92,7 @@ export default function HomePage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
@ -98,16 +107,30 @@ export default function HomePage() {
|
|||||||
mb: { xs: 2, sm: 4 },
|
mb: { xs: 2, sm: 4 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
안녕하세요, {mockUser.name}님! 👋
|
안녕하세요, {user?.userName || '사용자'}님! 👋
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ ...responsiveText.body1 }}>
|
<Typography variant="body1" sx={{ ...responsiveText.body1 }}>
|
||||||
이벤트 현황을 한눈에 확인하고 성과를 분석해보세요
|
이벤트 현황을 한눈에 확인하고 성과를 분석해보세요
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<Grid container spacing={{ xs: 3, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
|
<Grid container spacing={{ xs: 1.5, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -116,22 +139,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Celebration sx={{
|
<Celebration sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -142,8 +165,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
진행 중인 이벤트
|
진행 중인 이벤트
|
||||||
@ -153,16 +177,16 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{activeEvents.length}
|
{activeEventsCount}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -171,22 +195,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Group sx={{
|
<Group sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -197,8 +221,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
총 참여자
|
총 참여자
|
||||||
@ -208,7 +233,7 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -217,7 +242,7 @@ export default function HomePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={12} sm={4}>
|
<Grid item xs={4} sm={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -226,22 +251,22 @@ export default function HomePage() {
|
|||||||
borderColor: 'transparent',
|
borderColor: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 }, px: { xs: 0.5, sm: 4 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 48, sm: 64 },
|
width: { xs: 32, sm: 64 },
|
||||||
height: { xs: 48, sm: 64 },
|
height: { xs: 32, sm: 64 },
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
bgcolor: 'rgba(0, 0, 0, 0.05)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
mb: { xs: 2, sm: 3 },
|
mb: { xs: 0.75, sm: 3 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TrendingUp sx={{
|
<TrendingUp sx={{
|
||||||
fontSize: { xs: 24, sm: 32 },
|
fontSize: { xs: 18, sm: 32 },
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
filter: 'drop-shadow(0px 2px 4px rgba(0,0,0,0.2))',
|
||||||
}} />
|
}} />
|
||||||
@ -252,8 +277,9 @@ export default function HomePage() {
|
|||||||
mb: 0.5,
|
mb: 0.5,
|
||||||
color: colors.gray[700],
|
color: colors.gray[700],
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
fontSize: { xs: '0.6875rem', sm: '0.875rem' },
|
||||||
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
textShadow: '0px 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
lineHeight: 1.2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
평균 ROI
|
평균 ROI
|
||||||
@ -263,7 +289,7 @@ export default function HomePage() {
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: colors.gray[900],
|
color: colors.gray[900],
|
||||||
fontSize: { xs: '1.5rem', sm: '2.25rem' },
|
fontSize: { xs: '1.375rem', sm: '2.25rem' },
|
||||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -288,7 +314,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
onClick={handleCreateEvent}
|
onClick={handleCreateEvent}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 56, sm: 72 },
|
width: { xs: 56, sm: 72 },
|
||||||
@ -319,7 +345,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
onClick={handleViewAnalytics}
|
onClick={handleViewAnalytics}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ textAlign: 'center', py: { xs: 4, sm: 6 } }}>
|
<CardContent sx={{ textAlign: 'center', pt: { xs: 2, sm: 6 }, pb: { xs: 2, sm: 6 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: { xs: 56, sm: 72 },
|
width: { xs: 56, sm: 72 },
|
||||||
@ -368,7 +394,7 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{activeEvents.length === 0 ? (
|
{!loading && eventPerformances.length === 0 ? (
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -402,16 +428,16 @@ export default function HomePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : !loading && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 3, sm: 6 } }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 3, sm: 6 } }}>
|
||||||
{activeEvents.map((event) => (
|
{eventPerformances.slice(0, 2).map((event) => (
|
||||||
<Card
|
<Card
|
||||||
key={event.id}
|
key={event.eventId}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
...cardStyles.clickable,
|
...cardStyles.clickable,
|
||||||
}}
|
}}
|
||||||
onClick={() => handleEventClick(event.id)}
|
onClick={() => handleEventClick(event.eventId)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: { xs: 4, sm: 6, md: 8 } }}>
|
<CardContent sx={{ p: { xs: 4, sm: 6, md: 8 } }}>
|
||||||
<Box
|
<Box
|
||||||
@ -424,7 +450,7 @@ export default function HomePage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
{event.title}
|
{event.eventTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -441,23 +467,7 @@ export default function HomePage() {
|
|||||||
{event.status}
|
{event.status}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography
|
<Box sx={{ display: 'flex', gap: { xs: 6, sm: 12 }, mt: { xs: 3, sm: 6 } }}>
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
mb: { xs: 3, sm: 6 },
|
|
||||||
color: colors.gray[600],
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>📅</span>
|
|
||||||
<span>
|
|
||||||
{event.startDate} ~ {event.endDate}
|
|
||||||
</span>
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ display: 'flex', gap: { xs: 6, sm: 12 } }}>
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@ -479,6 +489,27 @@ export default function HomePage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{ mb: 0.5, color: colors.gray[600], fontWeight: 500, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||||
|
>
|
||||||
|
조회수
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="h5"
|
||||||
|
sx={{ fontWeight: 700, color: colors.gray[900], fontSize: { xs: '1.125rem', sm: '1.5rem' } }}
|
||||||
|
>
|
||||||
|
{event.views.toLocaleString()}
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ ml: 0.5, color: colors.gray[600], fontSize: { xs: '0.75rem', sm: '0.875rem' } }}
|
||||||
|
>
|
||||||
|
회
|
||||||
|
</Typography>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
@ -487,7 +518,7 @@ export default function HomePage() {
|
|||||||
ROI
|
ROI
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
<Typography variant="h5" sx={{ fontWeight: 700, color: colors.mint, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||||||
{event.roi}%
|
{Math.round(event.roi * 100) / 100}%
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -242,14 +242,14 @@ export default function ProfilePage() {
|
|||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
pt: { xs: 7, sm: 8 },
|
pt: { xs: 7, sm: 8 },
|
||||||
pb: 10,
|
pb: { xs: 4, sm: 10 },
|
||||||
bgcolor: colors.gray[50],
|
bgcolor: colors.gray[50],
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
|
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 2, sm: 8, md: 10 } }}>
|
||||||
{/* 사용자 정보 섹션 */}
|
{/* 사용자 정보 섹션 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10, textAlign: 'center' }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 }, textAlign: 'center' }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -280,7 +280,7 @@ export default function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
||||||
기본 정보
|
기본 정보
|
||||||
@ -338,7 +338,7 @@ export default function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 매장 정보 */}
|
{/* 매장 정보 */}
|
||||||
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
|
<Card elevation={0} sx={{ ...cardStyles.default, mb: { xs: 4, sm: 10 } }}>
|
||||||
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
|
||||||
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
|
||||||
매장 정보
|
매장 정보
|
||||||
@ -458,12 +458,12 @@ export default function ProfilePage() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
<DialogContent sx={{ textAlign: 'center', py: { xs: 3, sm: 6 }, px: { xs: 2, sm: 4 } }}>
|
||||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
<CheckCircle sx={{ fontSize: { xs: 48, sm: 64 }, color: 'success.main', mb: { xs: 1, sm: 2 } }} />
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: { xs: '1rem', sm: '1.25rem' } }}>
|
||||||
저장 완료
|
저장 완료
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||||||
프로필 정보가 업데이트되었습니다.
|
프로필 정보가 업데이트되었습니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -519,7 +519,7 @@ export default function TestAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
@ -544,7 +544,7 @@ export default function TestAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
@ -569,7 +569,7 @@ export default function TestAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
@ -594,7 +594,7 @@ export default function TestAnalyticsPage() {
|
|||||||
>
|
>
|
||||||
<CardContent sx={{
|
<CardContent sx={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
py: { xs: 2, sm: 6 },
|
pt: { xs: 1.5, sm: 6 }, pb: { xs: 1.5, sm: 6 },
|
||||||
px: { xs: 1, sm: 4 },
|
px: { xs: 1, sm: 4 },
|
||||||
}}>
|
}}>
|
||||||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||||||
|
|||||||
44
src/app/api/analytics/events/[eventId]/channels/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/channels/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { eventId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { eventId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/channels${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] Event channels error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Event Channels 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/roi/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/roi/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { eventId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { eventId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/roi${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] Event ROI error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Event ROI 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { eventId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { eventId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] Event analytics error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Event Analytics 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/events/[eventId]/timeline/route.ts
Normal file
44
src/app/api/analytics/events/[eventId]/timeline/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { eventId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { eventId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/events/${eventId}/analytics/timeline${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] Event timeline error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Event Timeline 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/channels/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/channels/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/channels${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] User channels error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Channels 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/roi/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/roi/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/roi${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] User ROI error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'ROI 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/analytics/users/[userId]/route.ts
Normal file
59
src/app/api/analytics/users/[userId]/route.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/analytics/users/{userId}
|
||||||
|
* Proxy for User Analytics Dashboard
|
||||||
|
*/
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
console.log('📊 [Analytics Proxy] Get user analytics request:', {
|
||||||
|
userId,
|
||||||
|
hasToken: !!token,
|
||||||
|
params: Object.fromEntries(searchParams),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
console.log('✅ [Analytics Proxy] User analytics response:', {
|
||||||
|
status: response.status,
|
||||||
|
success: response.ok,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] User analytics error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Analytics 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/app/api/analytics/users/[userId]/timeline/route.ts
Normal file
44
src/app/api/analytics/users/[userId]/timeline/route.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { userId: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { userId } = params;
|
||||||
|
const token = request.headers.get('Authorization');
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
const url = `${ANALYTICS_HOST}/api/v1/analytics/users/${userId}/analytics/timeline${queryString ? `?${queryString}` : ''}`;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json(data, { status: response.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ [Analytics Proxy] User timeline error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ message: 'Timeline 데이터 조회 중 오류가 발생했습니다.' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ eventDraftId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { eventDraftId } = await context.params;
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const style = searchParams.get('style');
|
|
||||||
const platform = searchParams.get('platform');
|
|
||||||
|
|
||||||
// eventDraftId is now eventId in the API
|
|
||||||
let url = `${CONTENT_API_BASE_URL}/api/v1/content/events/${eventDraftId}/images`;
|
|
||||||
const queryParams = [];
|
|
||||||
if (style) queryParams.push(`style=${style}`);
|
|
||||||
if (platform) queryParams.push(`platform=${platform}`);
|
|
||||||
if (queryParams.length > 0) {
|
|
||||||
url += `?${queryParams.join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Proxying images request to Content API:', { url });
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Content API error:', response.status, errorText);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to get images', details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Proxy error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
export async function GET(
|
|
||||||
request: NextRequest,
|
|
||||||
context: { params: Promise<{ eventDraftId: string }> }
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const { eventDraftId } = await context.params;
|
|
||||||
const { searchParams } = new URL(request.url);
|
|
||||||
const style = searchParams.get('style');
|
|
||||||
const platform = searchParams.get('platform');
|
|
||||||
|
|
||||||
// eventDraftId is now eventId in the API
|
|
||||||
let url = `${CONTENT_API_BASE_URL}/api/v1/content/events/${eventDraftId}/images`;
|
|
||||||
const queryParams = [];
|
|
||||||
if (style) queryParams.push(`style=${style}`);
|
|
||||||
if (platform) queryParams.push(`platform=${platform}`);
|
|
||||||
if (queryParams.length > 0) {
|
|
||||||
url += `?${queryParams.join('&')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🔄 Proxying images request to Content API:', { url });
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Content API error:', response.status, errorText);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to get images', details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Proxy error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
console.log('🔄 Proxying image generation request to Content API:', {
|
|
||||||
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/generate`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ Content API error:', response.status, errorText);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Failed to generate images', details: errorText },
|
|
||||||
{ status: response.status }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
console.log('✅ Image generation job created:', data);
|
|
||||||
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Proxy error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await request.json();
|
|
||||||
|
|
||||||
console.log('🔄 Proxying image generation request to Content API:', {
|
|
||||||
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/generate`,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 이미지 생성은 시간이 오래 걸리므로 타임아웃을 3분으로 설정
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 180000); // 3분
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
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 (fetchError) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
|
|
||||||
console.error('❌ Request timeout after 3 minutes');
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Request timeout', details: 'Image generation request timed out after 3 minutes' },
|
|
||||||
{ status: 504 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
throw fetchError;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Proxy error:', error);
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
|
||||||
|
|
||||||
const CONTENT_API_BASE_URL = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|
||||||
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -4,10 +4,10 @@ const GATEWAY_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
|||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { eventId: string } }
|
context: { params: Promise<{ eventId: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { eventId } = params;
|
const { eventId } = await context.params;
|
||||||
const token = request.headers.get('Authorization');
|
const token = request.headers.get('Authorization');
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|
||||||
|
|||||||
@ -15,11 +15,11 @@ import type {
|
|||||||
RoiQueryParams,
|
RoiQueryParams,
|
||||||
} from '../model/types';
|
} from '../model/types';
|
||||||
|
|
||||||
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'v1';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Analytics API Service
|
* Analytics API Service
|
||||||
* 실시간 효과 측정 및 통합 대시보드 API
|
* 실시간 효과 측정 및 통합 대시보드 API
|
||||||
|
*
|
||||||
|
* Note: Proxy routes handle /api/v1 prefix, so paths start with /users or /events
|
||||||
*/
|
*/
|
||||||
export const analyticsApi = {
|
export const analyticsApi = {
|
||||||
// ============= User Analytics (사용자 전체 이벤트 통합) =============
|
// ============= User Analytics (사용자 전체 이벤트 통합) =============
|
||||||
@ -32,7 +32,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams
|
params?: AnalyticsQueryParams
|
||||||
): Promise<UserAnalyticsDashboardResponse> => {
|
): Promise<UserAnalyticsDashboardResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserAnalyticsDashboardResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics`,
|
`/users/${userId}`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -46,7 +46,7 @@ export const analyticsApi = {
|
|||||||
params?: TimelineQueryParams
|
params?: TimelineQueryParams
|
||||||
): Promise<UserTimelineAnalyticsResponse> => {
|
): Promise<UserTimelineAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserTimelineAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/timeline`,
|
`/users/${userId}/timeline`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -60,7 +60,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams & RoiQueryParams
|
params?: AnalyticsQueryParams & RoiQueryParams
|
||||||
): Promise<UserRoiAnalyticsResponse> => {
|
): Promise<UserRoiAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserRoiAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/roi`,
|
`/users/${userId}/roi`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -74,7 +74,7 @@ export const analyticsApi = {
|
|||||||
params?: ChannelQueryParams
|
params?: ChannelQueryParams
|
||||||
): Promise<UserChannelAnalyticsResponse> => {
|
): Promise<UserChannelAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<UserChannelAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/users/${userId}/analytics/channels`,
|
`/users/${userId}/channels`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -90,7 +90,7 @@ export const analyticsApi = {
|
|||||||
params?: AnalyticsQueryParams
|
params?: AnalyticsQueryParams
|
||||||
): Promise<AnalyticsDashboardResponse> => {
|
): Promise<AnalyticsDashboardResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
|
const response = await analyticsClient.get<ApiResponse<AnalyticsDashboardResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics`,
|
`/events/${eventId}`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -104,7 +104,7 @@ export const analyticsApi = {
|
|||||||
params?: TimelineQueryParams
|
params?: TimelineQueryParams
|
||||||
): Promise<TimelineAnalyticsResponse> => {
|
): Promise<TimelineAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<TimelineAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/timeline`,
|
`/events/${eventId}/timeline`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -118,7 +118,7 @@ export const analyticsApi = {
|
|||||||
params?: RoiQueryParams
|
params?: RoiQueryParams
|
||||||
): Promise<RoiAnalyticsResponse> => {
|
): Promise<RoiAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<RoiAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/roi`,
|
`/events/${eventId}/roi`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
@ -132,7 +132,7 @@ export const analyticsApi = {
|
|||||||
params?: ChannelQueryParams
|
params?: ChannelQueryParams
|
||||||
): Promise<ChannelAnalyticsResponse> => {
|
): Promise<ChannelAnalyticsResponse> => {
|
||||||
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
|
const response = await analyticsClient.get<ApiResponse<ChannelAnalyticsResponse>>(
|
||||||
`/api/${API_VERSION}/events/${eventId}/analytics/channels`,
|
`/events/${eventId}/channels`,
|
||||||
{ params }
|
{ params }
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
|
||||||
const ANALYTICS_HOST = 'http://kt-event-marketing-api.20.214.196.128.nip.io';
|
// Next.js API Proxy를 통해 Analytics API 호출 (CORS 회피)
|
||||||
|
const ANALYTICS_PROXY_BASE = '/api/analytics';
|
||||||
|
|
||||||
export const analyticsClient: AxiosInstance = axios.create({
|
export const analyticsClient: AxiosInstance = axios.create({
|
||||||
baseURL: ANALYTICS_HOST,
|
baseURL: ANALYTICS_PROXY_BASE,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@ -1,2 +1,3 @@
|
|||||||
export { useAuth } from './model/useAuth';
|
export { useAuth } from './model/useAuth';
|
||||||
export { AuthProvider, useAuthContext } from './model/AuthProvider';
|
export { AuthProvider, useAuthContext } from './model/AuthProvider';
|
||||||
|
export { AuthGuard } from './ui/AuthGuard';
|
||||||
|
|||||||
70
src/features/auth/ui/AuthGuard.tsx
Normal file
70
src/features/auth/ui/AuthGuard.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import { useAuthContext } from '../model/AuthProvider';
|
||||||
|
import { colors } from '@/shared/lib/button-styles';
|
||||||
|
|
||||||
|
interface AuthGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 가드 컴포넌트
|
||||||
|
* - 로그인하지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||||
|
* - user 정보가 없으면 접근 불가
|
||||||
|
*/
|
||||||
|
export const AuthGuard = ({ children }: AuthGuardProps) => {
|
||||||
|
const { isAuthenticated, user, isLoading } = useAuthContext();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 로딩이 완료되고 인증되지 않았거나 사용자 정보가 없으면 로그인 페이지로 리다이렉트
|
||||||
|
if (!isLoading && (!isAuthenticated || !user)) {
|
||||||
|
console.log('🚫 인증되지 않은 접근 시도 - 로그인 페이지로 리다이렉트');
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, user, router]);
|
||||||
|
|
||||||
|
// 로딩 중
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
bgcolor: colors.gray[50],
|
||||||
|
gap: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress
|
||||||
|
size={60}
|
||||||
|
sx={{
|
||||||
|
color: colors.purple,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
color: colors.gray[600],
|
||||||
|
fontSize: { xs: '0.875rem', sm: '1rem' },
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
인증 확인 중...
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증되지 않았거나 사용자 정보가 없으면 아무것도 렌더링하지 않음 (리다이렉트 처리 중)
|
||||||
|
if (!isAuthenticated || !user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 인증된 사용자만 children 렌더링
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@ -305,36 +305,16 @@ export const eventApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Step 2: AI 추천 요청
|
// Step 2: AI 추천 요청 (POST)
|
||||||
requestAiRecommendations: async (
|
requestAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
||||||
eventId: string,
|
const response = await eventApiClient.post<AiRecommendationResult>(
|
||||||
objective: string,
|
`/events/${eventId}/ai-recommendations`
|
||||||
storeInfo: AiRecommendationRequest['storeInfo']
|
|
||||||
): Promise<JobAcceptedResponse> => {
|
|
||||||
const response = await eventApiClient.post<JobAcceptedResponse>(
|
|
||||||
`/events/${eventId}/ai-recommendations`,
|
|
||||||
{ objective, storeInfo }
|
|
||||||
);
|
);
|
||||||
|
console.log('✅ AI 추천 요청 성공:', response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Job 상태 폴링
|
// AI 추천 결과 조회 (GET)
|
||||||
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
|
|
||||||
const response = await eventApiClient.get<any>(`/jobs/${jobId}`);
|
|
||||||
// 백엔드 응답 구조: { success, data: { jobId, jobType, status, ... }, timestamp }
|
|
||||||
console.log('📦 getJobStatus 원본 응답:', response.data);
|
|
||||||
|
|
||||||
// data 안에 실제 job 정보가 있는지 확인
|
|
||||||
if (response.data.data) {
|
|
||||||
console.log('✅ response.data.data 사용');
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ response.data 직접 사용');
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// AI 추천 결과 조회 (Job COMPLETED 후)
|
|
||||||
getAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
getAiRecommendations: async (eventId: string): Promise<AiRecommendationResult> => {
|
||||||
const response = await eventApiClient.get<AiRecommendationResult>(
|
const response = await eventApiClient.get<AiRecommendationResult>(
|
||||||
`/events/${eventId}/ai-recommendations`
|
`/events/${eventId}/ai-recommendations`
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user