Merge remote-tracking branch 'origin/develop' into feature/analytics

This commit is contained in:
Hyowon Yang
2025-10-30 09:48:45 +09:00
27 changed files with 4182 additions and 415 deletions
+253
View File
@@ -0,0 +1,253 @@
import { apiClient } from '@/shared/api';
import type {
GetEventsRequest,
GetEventsResponse,
EventDetail,
ApiResponse,
SelectObjectiveRequest,
EventCreatedResponse,
AiRecommendationRequest,
JobAcceptedResponse,
ImageGenerationRequest,
ImageGenerationResponse,
UpdateEventRequest,
SelectChannelsRequest,
SelectImageRequest,
} from '../model/types';
/**
* Event API 기본 경로
*
* 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로
* 별도의 event API 클라이언트를 사용하는 것이 좋습니다.
*
* 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다.
*/
const EVENT_API_BASE = '/api/v1/events';
const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
/**
* Event Service용 API 클라이언트
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
*
* 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피)
* 프로덕션 환경: 환경 변수에서 직접 호스트 사용
*/
import axios from 'axios';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
const eventApiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - JWT 토큰 추가
eventApiClient.interceptors.request.use(
(config) => {
console.log('🚀 Event API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
params: config.params,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
console.log('🔑 Token added to Event API request');
}
return config;
},
(error) => {
console.error('❌ Event API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor - 에러 처리
eventApiClient.interceptors.response.use(
(response) => {
console.log('✅ Event API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ Event API Error:', {
message: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
data: error.response?.data,
});
if (error.response?.status === 401) {
console.warn('🔒 401 Unauthorized - Redirecting to login');
localStorage.removeItem('accessToken');
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
/**
* Event API Service
* 이벤트 관리 API
*/
export const eventApi = {
/**
* 이벤트 목록 조회
*/
getEvents: async (params?: GetEventsRequest): Promise<GetEventsResponse> => {
console.log('📞 eventApi.getEvents 호출', params);
const response = await eventApiClient.get<GetEventsResponse>(EVENT_API_BASE, {
params,
});
return response.data;
},
/**
* 이벤트 상세 조회
*/
getEvent: async (eventId: string): Promise<ApiResponse<EventDetail>> => {
console.log('📞 eventApi.getEvent 호출', eventId);
const response = await eventApiClient.get<ApiResponse<EventDetail>>(
`${EVENT_API_BASE}/${eventId}`
);
return response.data;
},
/**
* 이벤트 생성 (목적 선택)
*/
createEvent: async (
data: SelectObjectiveRequest
): Promise<ApiResponse<EventCreatedResponse>> => {
console.log('📞 eventApi.createEvent 호출', data);
const response = await eventApiClient.post<ApiResponse<EventCreatedResponse>>(
`${EVENT_API_BASE}/objectives`,
data
);
return response.data;
},
/**
* 이벤트 삭제
*/
deleteEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.deleteEvent 호출', eventId);
const response = await eventApiClient.delete<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}`
);
return response.data;
},
/**
* 이벤트 배포
*/
publishEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.publishEvent 호출', eventId);
const response = await eventApiClient.post<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/publish`
);
return response.data;
},
/**
* 이벤트 종료
*/
endEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.endEvent 호출', eventId);
const response = await eventApiClient.post<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/end`
);
return response.data;
},
/**
* AI 추천 요청
*/
requestAiRecommendations: async (
eventId: string,
data: AiRecommendationRequest
): Promise<ApiResponse<JobAcceptedResponse>> => {
console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data);
const response = await eventApiClient.post<ApiResponse<JobAcceptedResponse>>(
`${EVENT_API_BASE}/${eventId}/ai-recommendations`,
data
);
return response.data;
},
/**
* 이미지 생성 요청
*/
requestImageGeneration: async (
eventId: string,
data: ImageGenerationRequest
): Promise<ApiResponse<ImageGenerationResponse>> => {
console.log('📞 eventApi.requestImageGeneration 호출', eventId, data);
const response = await eventApiClient.post<ApiResponse<ImageGenerationResponse>>(
`${EVENT_API_BASE}/${eventId}/images`,
data
);
return response.data;
},
/**
* 이벤트 수정
*/
updateEvent: async (
eventId: string,
data: UpdateEventRequest
): Promise<ApiResponse<EventDetail>> => {
console.log('📞 eventApi.updateEvent 호출', eventId, data);
const response = await eventApiClient.put<ApiResponse<EventDetail>>(
`${EVENT_API_BASE}/${eventId}`,
data
);
return response.data;
},
/**
* 배포 채널 선택
*/
selectChannels: async (
eventId: string,
data: SelectChannelsRequest
): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.selectChannels 호출', eventId, data);
const response = await eventApiClient.put<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/channels`,
data
);
return response.data;
},
/**
* 이미지 선택
*/
selectImage: async (
eventId: string,
imageId: string,
data: SelectImageRequest
): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.selectImage 호출', eventId, imageId, data);
const response = await eventApiClient.put<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/images/${imageId}/select`,
data
);
return response.data;
},
};
export default eventApi;
+198
View File
@@ -0,0 +1,198 @@
/**
* Event 도메인 타입 정의
* Event Service API 응답 형식과 일치
*/
/**
* 이벤트 상태
*/
export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED';
/**
* 이벤트 목적
*/
export type EventObjective =
| 'CUSTOMER_ACQUISITION'
| 'Sales Promotion'
| 'Customer Retention'
| 'New Customer Acquisition'
| 'awareness'
| 'sales'
| 'new_customer';
/**
* 배포 채널
*/
export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH';
/**
* 이벤트 이미지
*/
export interface EventImage {
imageId: string;
imageUrl: string;
prompt?: string;
isSelected: boolean;
createdAt: string;
}
/**
* AI 추천
*/
export interface AiRecommendation {
recommendationId: string;
eventName: string;
description: string;
reward: string;
participationMethod: string;
startDate: string;
endDate: string;
targetParticipants: number;
isSelected: boolean;
createdAt: string;
}
/**
* 이벤트 상세 정보
*/
export interface EventDetail {
eventId: string;
userId: string;
storeId: string;
eventName: string;
description: string | null;
objective: EventObjective;
startDate: string | null;
endDate: string | null;
status: EventStatus;
selectedImageId: string | null;
selectedImageUrl: string | null;
participants: number | null;
targetParticipants: number | null;
roi: number | null;
generatedImages: EventImage[];
aiRecommendations: AiRecommendation[];
channels: DistributionChannel[];
createdAt: string;
updatedAt: string;
}
/**
* 페이지 응답
*/
export interface PageResponse<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
first: boolean;
last: boolean;
}
/**
* API 표준 응답
*/
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
/**
* 이벤트 목록 조회 요청
*/
export interface GetEventsRequest {
status?: EventStatus;
search?: string;
objective?: string;
page?: number;
size?: number;
sort?: string;
order?: 'asc' | 'desc';
}
/**
* 이벤트 목록 조회 응답
*/
export type GetEventsResponse = ApiResponse<PageResponse<EventDetail>>;
/**
* 이벤트 목적 선택 요청
*/
export interface SelectObjectiveRequest {
objective: EventObjective;
}
/**
* 이벤트 생성 응답
*/
export interface EventCreatedResponse {
eventId: string;
objective: EventObjective;
status: EventStatus;
createdAt: string;
}
/**
* AI 추천 요청
*/
export interface AiRecommendationRequest {
storeCategory?: string;
targetAudience?: string;
budget?: number;
additionalInfo?: string;
}
/**
* Job 수락 응답
*/
export interface JobAcceptedResponse {
jobId: string;
eventId: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
estimatedCompletionTime?: string;
}
/**
* 이미지 생성 요청
*/
export interface ImageGenerationRequest {
prompt: string;
numberOfImages?: number;
style?: string;
}
/**
* 이미지 생성 응답
*/
export interface ImageGenerationResponse {
jobId: string;
eventId: string;
status: string;
}
/**
* 이벤트 수정 요청
*/
export interface UpdateEventRequest {
eventName?: string;
description?: string;
startDate?: string;
endDate?: string;
discountRate?: number;
}
/**
* 배포 채널 선택 요청
*/
export interface SelectChannelsRequest {
channels: string[];
}
/**
* 이미지 선택 요청
*/
export interface SelectImageRequest {
selectedImageId: string;
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { eventApi } from '../api/eventApi';
import type {
EventDetail,
GetEventsRequest,
EventStatus,
PageResponse,
} from './types';
/**
* useEvents Hook
* 이벤트 목록 조회 및 상태 관리
*/
export function useEvents(initialParams?: GetEventsRequest) {
const [events, setEvents] = useState<EventDetail[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [pageInfo, setPageInfo] = useState<Omit<PageResponse<EventDetail>, 'content'>>({
page: 0,
size: 20,
totalElements: 0,
totalPages: 0,
first: true,
last: true,
});
const fetchEvents = async (params?: GetEventsRequest) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Fetching events with params:', params);
const response = await eventApi.getEvents(params);
console.log('✅ Events fetched:', response);
if (response.success && response.data) {
setEvents(response.data.content);
setPageInfo({
page: response.data.page,
size: response.data.size,
totalElements: response.data.totalElements,
totalPages: response.data.totalPages,
first: response.data.first,
last: response.data.last,
});
}
} catch (err) {
console.error('❌ Error fetching events:', err);
setError(err as Error);
} finally {
setLoading(false);
}
};
// 초기 로드
useEffect(() => {
fetchEvents(initialParams);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
events,
loading,
error,
pageInfo,
refetch: fetchEvents,
};
}
/**
* useEvent Hook
* 단일 이벤트 조회 및 상태 관리
*/
export function useEvent(eventId: string) {
const [event, setEvent] = useState<EventDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchEvent = async () => {
if (!eventId) return;
try {
setLoading(true);
setError(null);
console.log('🔄 Fetching event:', eventId);
const response = await eventApi.getEvent(eventId);
console.log('✅ Event fetched:', response);
if (response.success && response.data) {
setEvent(response.data);
}
} catch (err) {
console.error('❌ Error fetching event:', err);
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEvent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId]);
return {
event,
loading,
error,
refetch: fetchEvent,
};
}
/**
* useEventActions Hook
* 이벤트 생성, 삭제, 배포 등의 액션 관리
*/
export function useEventActions() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const createEvent = async (objective: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Creating event with objective:', objective);
const response = await eventApi.createEvent({ objective: objective as any });
console.log('✅ Event created:', response);
return response.data;
} catch (err) {
console.error('❌ Error creating event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const deleteEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Deleting event:', eventId);
await eventApi.deleteEvent(eventId);
console.log('✅ Event deleted');
} catch (err) {
console.error('❌ Error deleting event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const publishEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Publishing event:', eventId);
await eventApi.publishEvent(eventId);
console.log('✅ Event published');
} catch (err) {
console.error('❌ Error publishing event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const endEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Ending event:', eventId);
await eventApi.endEvent(eventId);
console.log('✅ Event ended');
} catch (err) {
console.error('❌ Error ending event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
return {
createEvent,
deleteEvent,
publishEvent,
endEvent,
loading,
error,
};
}