diff --git a/src/app/(main)/events/page.tsx b/src/app/(main)/events/page.tsx index bf80422..40bfc67 100644 --- a/src/app/(main)/events/page.tsx +++ b/src/app/(main)/events/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { Box, @@ -37,78 +37,12 @@ import { } from '@mui/icons-material'; import Header from '@/shared/ui/Header'; import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles'; +import { useEvents } from '@/entities/event/model/useEvents'; +import type { EventStatus as ApiEventStatus } from '@/entities/event/model/types'; -// Mock 데이터 -const mockEvents = [ - { - id: '1', - title: '신규고객 유치 이벤트', - status: 'active' as const, - daysLeft: 5, - participants: 128, - targetParticipants: 200, - roi: 450, - startDate: '2025-11-01', - endDate: '2025-11-15', - prize: '커피 쿠폰', - method: '전화번호 입력', - isUrgent: true, - isPopular: false, - isHighROI: true, - isNew: false, - }, - { - id: '2', - title: '재방문 유도 이벤트', - status: 'active' as const, - daysLeft: 12, - participants: 56, - targetParticipants: 100, - roi: 320, - startDate: '2025-11-05', - endDate: '2025-11-20', - prize: '할인 쿠폰', - method: 'SNS 팔로우', - isUrgent: false, - isPopular: false, - isHighROI: false, - isNew: false, - }, - { - id: '3', - title: '매출증대 프로모션', - status: 'ended' as const, - daysLeft: 0, - participants: 234, - targetParticipants: 150, - roi: 580, - startDate: '2025-10-15', - endDate: '2025-10-31', - prize: '상품권', - method: '구매 인증', - isUrgent: false, - isPopular: true, - isHighROI: true, - isNew: false, - }, - { - id: '4', - title: '봄맞이 특별 이벤트', - status: 'scheduled' as const, - daysLeft: 30, - participants: 0, - targetParticipants: 300, - roi: 0, - startDate: '2025-12-01', - endDate: '2025-12-15', - prize: '체험권', - method: '이메일 등록', - isUrgent: false, - isPopular: false, - isHighROI: false, - isNew: true, - }, -]; +// ==================== API 연동 ==================== +// Mock 데이터를 실제 API 호출로 교체 +// 백업 파일: page.tsx.backup type EventStatus = 'all' | 'active' | 'scheduled' | 'ended'; type Period = '1month' | '3months' | '6months' | '1year' | 'all'; @@ -123,8 +57,57 @@ export default function EventsPage() { const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; + // API 데이터 가져오기 + const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({ + page: currentPage - 1, + size: itemsPerPage, + sort: 'createdAt', + order: 'desc' + }); + + // API 상태를 UI 상태로 매핑 + const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => { + switch (apiStatus) { + case 'PUBLISHED': + return 'active'; + case 'DRAFT': + return 'scheduled'; + case 'ENDED': + return 'ended'; + default: + return 'all'; + } + }; + + // API 이벤트를 UI 형식으로 변환 + const transformedEvents = apiEvents.map(event => ({ + id: event.eventId, + title: event.eventName || '제목 없음', + status: mapApiStatus(event.status), + startDate: event.startDate ? new Date(event.startDate).toLocaleDateString('ko-KR') : '-', + endDate: event.endDate ? new Date(event.endDate).toLocaleDateString('ko-KR') : '-', + prize: event.aiRecommendations[0]?.reward || '경품 정보 없음', + method: event.aiRecommendations[0]?.participationMethod || '참여 방법 없음', + participants: event.participants || 0, + targetParticipants: event.targetParticipants || 0, + roi: event.roi || 0, + daysLeft: event.endDate + ? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) + : 0, + isUrgent: event.endDate + ? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) <= 3 + : false, + isPopular: event.participants && event.targetParticipants + ? (event.participants / event.targetParticipants) >= 0.8 + : false, + isHighROI: event.roi ? event.roi >= 300 : false, + isNew: event.createdAt + ? (Date.now() - new Date(event.createdAt).getTime()) < (7 * 24 * 60 * 60 * 1000) + : false, + })); + // 필터링 및 정렬 - const filteredEvents = mockEvents + const filteredEvents = transformedEvents .filter((event) => { const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase()); const matchesStatus = statusFilter === 'all' || event.status === statusFilter; @@ -204,22 +187,26 @@ export default function EventsPage() { } }; - const calculateProgress = (event: (typeof mockEvents)[0]) => { + const calculateProgress = (event: typeof transformedEvents[0]) => { if (event.status !== 'active') return 0; - const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime(); - const elapsed = Date.now() - new Date(event.startDate).getTime(); + const startTime = new Date(event.startDate).getTime(); + const endTime = new Date(event.endDate).getTime(); + const total = endTime - startTime; + const elapsed = Date.now() - startTime; return Math.min(Math.max((elapsed / total) * 100, 0), 100); }; // 통계 계산 const stats = { - total: mockEvents.length, - active: mockEvents.filter((e) => e.status === 'active').length, - totalParticipants: mockEvents.reduce((sum, e) => sum + e.participants, 0), - avgROI: Math.round( - mockEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) / - mockEvents.filter((e) => e.roi > 0).length - ), + total: transformedEvents.length, + active: transformedEvents.filter((e) => e.status === 'active').length, + totalParticipants: transformedEvents.reduce((sum, e) => sum + e.participants, 0), + avgROI: transformedEvents.filter((e) => e.roi > 0).length > 0 + ? Math.round( + transformedEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) / + transformedEvents.filter((e) => e.roi > 0).length + ) + : 0, }; return ( @@ -237,6 +224,59 @@ export default function EventsPage() { maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }} > + {/* Loading State */} + {loading && ( + + + + 이벤트 목록을 불러오는 중... + + + )} + + {/* Error State */} + {error && ( + + + + + 이벤트 목록을 불러오는데 실패했습니다 + + + {error.message} + + refetch()} + sx={{ + px: 3, + py: 1.5, + borderRadius: 2, + border: 'none', + bgcolor: '#DC2626', + color: 'white', + fontSize: '0.875rem', + fontWeight: 600, + cursor: 'pointer', + '&:hover': { bgcolor: '#B91C1C' }, + }} + > + 다시 시도 + + + + )} + {/* Summary Statistics */} diff --git a/src/entities/event/api/eventApi.ts b/src/entities/event/api/eventApi.ts new file mode 100644 index 0000000..929eefe --- /dev/null +++ b/src/entities/event/api/eventApi.ts @@ -0,0 +1,198 @@ +import { apiClient } from '@/shared/api'; +import type { + GetEventsRequest, + GetEventsResponse, + EventDetail, + ApiResponse, + SelectObjectiveRequest, + EventCreatedResponse, + AiRecommendationRequest, + JobAcceptedResponse, + ImageGenerationRequest, + ImageGenerationResponse, +} from '../model/types'; + +/** + * Event API 기본 경로 + * + * 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로 + * 별도의 event API 클라이언트를 사용하는 것이 좋습니다. + * + * 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다. + */ +const EVENT_API_BASE = '/api/v1/events'; +const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'; + +/** + * Event Service용 API 클라이언트 + * Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성 + */ +import axios from 'axios'; + +const eventApiClient = axios.create({ + baseURL: EVENT_HOST, + timeout: 30000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor - JWT 토큰 추가 +eventApiClient.interceptors.request.use( + (config) => { + console.log('🚀 Event API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + params: config.params, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Token added to Event API request'); + } + return config; + }, + (error) => { + console.error('❌ Event API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor - 에러 처리 +eventApiClient.interceptors.response.use( + (response) => { + console.log('✅ Event API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ Event API Error:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data, + }); + + if (error.response?.status === 401) { + console.warn('🔒 401 Unauthorized - Redirecting to login'); + localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + window.location.href = '/login'; + } + } + return Promise.reject(error); + } +); + +/** + * Event API Service + * 이벤트 관리 API + */ +export const eventApi = { + /** + * 이벤트 목록 조회 + */ + getEvents: async (params?: GetEventsRequest): Promise => { + console.log('📞 eventApi.getEvents 호출', params); + const response = await eventApiClient.get(EVENT_API_BASE, { + params, + }); + return response.data; + }, + + /** + * 이벤트 상세 조회 + */ + getEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.getEvent 호출', eventId); + const response = await eventApiClient.get>( + `${EVENT_API_BASE}/${eventId}` + ); + return response.data; + }, + + /** + * 이벤트 생성 (목적 선택) + */ + createEvent: async ( + data: SelectObjectiveRequest + ): Promise> => { + console.log('📞 eventApi.createEvent 호출', data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/objectives`, + data + ); + return response.data; + }, + + /** + * 이벤트 삭제 + */ + deleteEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.deleteEvent 호출', eventId); + const response = await eventApiClient.delete>( + `${EVENT_API_BASE}/${eventId}` + ); + return response.data; + }, + + /** + * 이벤트 배포 + */ + publishEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.publishEvent 호출', eventId); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/publish` + ); + return response.data; + }, + + /** + * 이벤트 종료 + */ + endEvent: async (eventId: string): Promise> => { + console.log('📞 eventApi.endEvent 호출', eventId); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/end` + ); + return response.data; + }, + + /** + * AI 추천 요청 + */ + requestAiRecommendations: async ( + eventId: string, + data: AiRecommendationRequest + ): Promise> => { + console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/ai-recommendations`, + data + ); + return response.data; + }, + + /** + * 이미지 생성 요청 + */ + requestImageGeneration: async ( + eventId: string, + data: ImageGenerationRequest + ): Promise> => { + console.log('📞 eventApi.requestImageGeneration 호출', eventId, data); + const response = await eventApiClient.post>( + `${EVENT_API_BASE}/${eventId}/images`, + data + ); + return response.data; + }, +}; + +export default eventApi; diff --git a/src/entities/event/model/types.ts b/src/entities/event/model/types.ts new file mode 100644 index 0000000..b3de860 --- /dev/null +++ b/src/entities/event/model/types.ts @@ -0,0 +1,173 @@ +/** + * Event 도메인 타입 정의 + * Event Service API 응답 형식과 일치 + */ + +/** + * 이벤트 상태 + */ +export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED'; + +/** + * 이벤트 목적 + */ +export type EventObjective = + | 'CUSTOMER_ACQUISITION' + | 'Sales Promotion' + | 'Customer Retention' + | 'New Customer Acquisition' + | 'awareness' + | 'sales' + | 'new_customer'; + +/** + * 배포 채널 + */ +export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH'; + +/** + * 이벤트 이미지 + */ +export interface EventImage { + imageId: string; + imageUrl: string; + prompt?: string; + isSelected: boolean; + createdAt: string; +} + +/** + * AI 추천 + */ +export interface AiRecommendation { + recommendationId: string; + eventName: string; + description: string; + reward: string; + participationMethod: string; + startDate: string; + endDate: string; + targetParticipants: number; + isSelected: boolean; + createdAt: string; +} + +/** + * 이벤트 상세 정보 + */ +export interface EventDetail { + eventId: string; + userId: string; + storeId: string; + eventName: string; + description: string | null; + objective: EventObjective; + startDate: string | null; + endDate: string | null; + status: EventStatus; + selectedImageId: string | null; + selectedImageUrl: string | null; + participants: number | null; + targetParticipants: number | null; + roi: number | null; + generatedImages: EventImage[]; + aiRecommendations: AiRecommendation[]; + channels: DistributionChannel[]; + createdAt: string; + updatedAt: string; +} + +/** + * 페이지 응답 + */ +export interface PageResponse { + content: T[]; + page: number; + size: number; + totalElements: number; + totalPages: number; + first: boolean; + last: boolean; +} + +/** + * API 표준 응답 + */ +export interface ApiResponse { + success: boolean; + data: T; + timestamp: string; +} + +/** + * 이벤트 목록 조회 요청 + */ +export interface GetEventsRequest { + status?: EventStatus; + search?: string; + objective?: string; + page?: number; + size?: number; + sort?: string; + order?: 'asc' | 'desc'; +} + +/** + * 이벤트 목록 조회 응답 + */ +export type GetEventsResponse = ApiResponse>; + +/** + * 이벤트 목적 선택 요청 + */ +export interface SelectObjectiveRequest { + objective: EventObjective; +} + +/** + * 이벤트 생성 응답 + */ +export interface EventCreatedResponse { + eventId: string; + objective: EventObjective; + status: EventStatus; + createdAt: string; +} + +/** + * AI 추천 요청 + */ +export interface AiRecommendationRequest { + storeCategory?: string; + targetAudience?: string; + budget?: number; + additionalInfo?: string; +} + +/** + * Job 수락 응답 + */ +export interface JobAcceptedResponse { + jobId: string; + eventId: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + estimatedCompletionTime?: string; +} + +/** + * 이미지 생성 요청 + */ +export interface ImageGenerationRequest { + prompt: string; + numberOfImages?: number; + style?: string; +} + +/** + * 이미지 생성 응답 + */ +export interface ImageGenerationResponse { + jobId: string; + eventId: string; + status: string; +} diff --git a/src/entities/event/model/useEvents.ts b/src/entities/event/model/useEvents.ts new file mode 100644 index 0000000..7eeb1d7 --- /dev/null +++ b/src/entities/event/model/useEvents.ts @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { eventApi } from '../api/eventApi'; +import type { + EventDetail, + GetEventsRequest, + EventStatus, + PageResponse, +} from './types'; + +/** + * useEvents Hook + * 이벤트 목록 조회 및 상태 관리 + */ +export function useEvents(initialParams?: GetEventsRequest) { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pageInfo, setPageInfo] = useState, 'content'>>({ + page: 0, + size: 20, + totalElements: 0, + totalPages: 0, + first: true, + last: true, + }); + + const fetchEvents = async (params?: GetEventsRequest) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Fetching events with params:', params); + + const response = await eventApi.getEvents(params); + console.log('✅ Events fetched:', response); + + if (response.success && response.data) { + setEvents(response.data.content); + setPageInfo({ + page: response.data.page, + size: response.data.size, + totalElements: response.data.totalElements, + totalPages: response.data.totalPages, + first: response.data.first, + last: response.data.last, + }); + } + } catch (err) { + console.error('❌ Error fetching events:', err); + setError(err as Error); + } finally { + setLoading(false); + } + }; + + // 초기 로드 + useEffect(() => { + fetchEvents(initialParams); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + events, + loading, + error, + pageInfo, + refetch: fetchEvents, + }; +} + +/** + * useEvent Hook + * 단일 이벤트 조회 및 상태 관리 + */ +export function useEvent(eventId: string) { + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchEvent = async () => { + if (!eventId) return; + + try { + setLoading(true); + setError(null); + console.log('🔄 Fetching event:', eventId); + + const response = await eventApi.getEvent(eventId); + console.log('✅ Event fetched:', response); + + if (response.success && response.data) { + setEvent(response.data); + } + } catch (err) { + console.error('❌ Error fetching event:', err); + setError(err as Error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchEvent(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [eventId]); + + return { + event, + loading, + error, + refetch: fetchEvent, + }; +} + +/** + * useEventActions Hook + * 이벤트 생성, 삭제, 배포 등의 액션 관리 + */ +export function useEventActions() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const createEvent = async (objective: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Creating event with objective:', objective); + + const response = await eventApi.createEvent({ objective: objective as any }); + console.log('✅ Event created:', response); + + return response.data; + } catch (err) { + console.error('❌ Error creating event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const deleteEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Deleting event:', eventId); + + await eventApi.deleteEvent(eventId); + console.log('✅ Event deleted'); + } catch (err) { + console.error('❌ Error deleting event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const publishEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Publishing event:', eventId); + + await eventApi.publishEvent(eventId); + console.log('✅ Event published'); + } catch (err) { + console.error('❌ Error publishing event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + const endEvent = async (eventId: string) => { + try { + setLoading(true); + setError(null); + console.log('🔄 Ending event:', eventId); + + await eventApi.endEvent(eventId); + console.log('✅ Event ended'); + } catch (err) { + console.error('❌ Error ending event:', err); + setError(err as Error); + throw err; + } finally { + setLoading(false); + } + }; + + return { + createEvent, + deleteEvent, + publishEvent, + endEvent, + loading, + error, + }; +}