mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 08:16:23 +00:00
mobile1
This commit is contained in:
parent
517cac7c75
commit
a58ca4ece1
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/cache
|
||||||
@ -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 (
|
||||||
@ -134,10 +136,10 @@ export default function LoginPage() {
|
|||||||
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
|
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="h4" sx={{ ...responsiveText.h2, mb: 1 }}>
|
<Typography variant="h4" sx={{ ...responsiveText.h2, mb: 1 }}>
|
||||||
KT AI 이벤트
|
KT 이벤트 파트너
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
소상공인을 위한 스마트 마케팅
|
소상공인을 위한 마케팅 어시스턴트
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -332,18 +334,79 @@ 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' }}
|
||||||
|
>
|
||||||
회원가입 시{' '}
|
회원가입 시{' '}
|
||||||
<Link href="#" onClick={handleUnavailableFeature} underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
이용약관
|
이용약관
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
및{' '}
|
및{' '}
|
||||||
<Link href="#" onClick={handleUnavailableFeature} underline="hover" sx={{ color: 'text.secondary' }}>
|
<Link
|
||||||
|
href="#"
|
||||||
|
onClick={handleUnavailableFeature}
|
||||||
|
underline="hover"
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
>
|
||||||
개인정보처리방침
|
개인정보처리방침
|
||||||
</Link>
|
</Link>
|
||||||
에 동의하게 됩니다.
|
에 동의하게 됩니다.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
{/* 지원하지 않는 기능 모달 */}
|
||||||
|
<Dialog
|
||||||
|
open={openUnavailableModal}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
maxWidth="xs"
|
||||||
|
fullWidth
|
||||||
|
PaperProps={{
|
||||||
|
sx: {
|
||||||
|
borderRadius: 3,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
pb: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Info color="info" sx={{ fontSize: 28 }} />
|
||||||
|
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||||
|
안내
|
||||||
|
</Typography>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" sx={{ color: 'text.secondary', lineHeight: 1.7 }}>
|
||||||
|
현재 지원하지 않는 기능입니다.
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions sx={{ px: 3, pb: 3 }}>
|
||||||
|
<Button
|
||||||
|
onClick={handleCloseModal}
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
sx={{
|
||||||
|
py: 1.5,
|
||||||
|
...getGradientButtonStyle('primary'),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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 = {
|
setAiResult(recommendations);
|
||||||
storeId: user.storeId ? String(user.storeId) : generateUUID(),
|
setLoading(false);
|
||||||
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) {
|
} catch (err: any) {
|
||||||
console.error('AI 추천 요청 실패:', err);
|
console.error('❌ AI 추천 요청 실패:', err);
|
||||||
setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다');
|
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message ||
|
||||||
|
err.response?.data?.error ||
|
||||||
|
'AI 추천을 생성하는데 실패했습니다';
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
setLoading(false);
|
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);
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
return;
|
|
||||||
} else if (status.status === 'FAILED') {
|
|
||||||
setError(status.errorMessage || 'AI 추천 생성에 실패했습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 계속 폴링
|
|
||||||
attempts++;
|
|
||||||
if (attempts < maxAttempts) {
|
|
||||||
setTimeout(poll, 5000); // 5초 후 재시도
|
|
||||||
} else {
|
|
||||||
setError('AI 추천 생성 시간이 초과되었습니다');
|
|
||||||
setLoading(false);
|
|
||||||
setPolling(false);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('❌ Job 상태 조회 실패:', err);
|
|
||||||
|
|
||||||
// Job을 찾을 수 없는 경우 (404 또는 JOB_001) - 초기 몇 번은 재시도
|
|
||||||
if (err.response?.data?.errorCode === 'JOB_001' || err.response?.status === 404) {
|
|
||||||
attempts++;
|
|
||||||
if (attempts < 5) { // 처음 5번 시도는 Job 생성 대기
|
|
||||||
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(err.response?.data?.message || 'Job 상태 조회에 실패했습니다');
|
|
||||||
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,13 +173,13 @@ 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: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||||
@ -325,7 +192,9 @@ export default function RecommendationStep({
|
|||||||
</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: 4, py: 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: '1.25rem' }}>
|
||||||
AI가 최적의 이벤트를 생성하고 있습니다...
|
AI가 최적의 이벤트를 생성하고 있습니다...
|
||||||
@ -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가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
@ -484,7 +368,8 @@ export default function RecommendationStep({
|
|||||||
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>
|
||||||
|
|
||||||
@ -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)',
|
||||||
@ -512,7 +403,14 @@ export default function RecommendationStep({
|
|||||||
onClick={() => setSelected(rec.optionNumber)}
|
onClick={() => setSelected(rec.optionNumber)}
|
||||||
>
|
>
|
||||||
<CardContent sx={{ p: 6 }}>
|
<CardContent sx={{ p: 6 }}>
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
mb: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<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 }}>
|
||||||
|
|||||||
@ -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 (
|
||||||
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
<AuthGuard>
|
||||||
{children}
|
<Box sx={{ pb: { xs: 7, sm: 8 }, pt: { xs: 7, sm: 8 } }}>
|
||||||
<BottomNavigation />
|
{children}
|
||||||
</Box>
|
<BottomNavigation />
|
||||||
|
</Box>
|
||||||
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|
||||||
|
|||||||
@ -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