mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:16:24 +00:00
event 기능 추가
This commit is contained in:
parent
4df7ba0697
commit
01d91e194a
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Box,
|
||||
@ -18,21 +18,24 @@ import {
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
Link,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Visibility, VisibilityOff } from '@mui/icons-material';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
// 각 단계별 유효성 검사 스키마
|
||||
const step1Schema = z.object({
|
||||
const step1Schema = z
|
||||
.object({
|
||||
email: z.string().email('올바른 이메일 형식이 아닙니다'),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, '비밀번호는 8자 이상이어야 합니다')
|
||||
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'),
|
||||
confirmPassword: z.string(),
|
||||
}).refine((data) => data.password === data.confirmPassword, {
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: '비밀번호가 일치하지 않습니다',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
@ -47,6 +50,10 @@ const step2Schema = z.object({
|
||||
|
||||
const step3Schema = z.object({
|
||||
businessName: z.string().min(2, '상호명은 2자 이상이어야 합니다'),
|
||||
businessNumber: z
|
||||
.string()
|
||||
.min(1, '사업자 번호를 입력해주세요')
|
||||
.regex(/^\d{3}-\d{2}-\d{5}$/, '올바른 사업자 번호 형식이 아닙니다 (123-45-67890)'),
|
||||
businessType: z.string().min(1, '업종을 선택해주세요'),
|
||||
businessLocation: z.string().optional(),
|
||||
agreeTerms: z.boolean().refine((val) => val === true, {
|
||||
@ -64,20 +71,34 @@ type Step3Data = z.infer<typeof step3Schema>;
|
||||
|
||||
type RegisterData = Step1Data & Step2Data & Step3Data;
|
||||
|
||||
type Step = '계정정보' | '개인정보' | '사업장정보';
|
||||
|
||||
export default function RegisterPage() {
|
||||
function RegisterForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { showToast, setLoading } = useUIStore();
|
||||
const { login } = useAuthStore();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<Step>('계정정보');
|
||||
// URL 쿼리에서 step 파라미터 읽기 (기본값: 1)
|
||||
const stepParam = searchParams.get('step');
|
||||
const initialStep = stepParam ? parseInt(stepParam) : 1;
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<number>(initialStep);
|
||||
const [formData, setFormData] = useState<Partial<RegisterData>>({
|
||||
agreeMarketing: false,
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isVerifyingBusinessNumber, setIsVerifyingBusinessNumber] = useState(false);
|
||||
const [isBusinessNumberVerified, setIsBusinessNumberVerified] = useState(false);
|
||||
|
||||
// step 변경 시 URL 업데이트
|
||||
useEffect(() => {
|
||||
const newStep = Math.max(1, Math.min(3, currentStep));
|
||||
if (newStep !== currentStep) {
|
||||
setCurrentStep(newStep);
|
||||
}
|
||||
router.replace(`/register?step=${newStep}`, { scroll: false });
|
||||
}, [currentStep, router]);
|
||||
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
@ -86,19 +107,56 @@ export default function RegisterPage() {
|
||||
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
|
||||
};
|
||||
|
||||
const steps: Step[] = ['계정정보', '개인정보', '사업장정보'];
|
||||
const stepIndex = steps.indexOf(currentStep) + 1;
|
||||
const progress = (stepIndex / steps.length) * 100;
|
||||
const formatBusinessNumber = (value: string) => {
|
||||
const numbers = value.replace(/[^\d]/g, '');
|
||||
if (numbers.length <= 3) return numbers;
|
||||
if (numbers.length <= 5) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
|
||||
return `${numbers.slice(0, 3)}-${numbers.slice(3, 5)}-${numbers.slice(5, 10)}`;
|
||||
};
|
||||
|
||||
const validateStep = (step: Step) => {
|
||||
const handleVerifyBusinessNumber = async () => {
|
||||
if (!formData.businessNumber) {
|
||||
showToast('사업자 번호를 입력해주세요', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^\d{3}-\d{2}-\d{5}$/.test(formData.businessNumber)) {
|
||||
showToast('올바른 사업자 번호 형식이 아닙니다', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVerifyingBusinessNumber(true);
|
||||
|
||||
try {
|
||||
// TODO: API 연동 시 실제 사업자 번호 검증 API 호출
|
||||
// const response = await axios.post(`${BUSINESS_HOST}/api/v1/business/verify`, {
|
||||
// businessNumber: formData.businessNumber
|
||||
// });
|
||||
|
||||
// 임시 처리: 2초 후 성공
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
setIsBusinessNumberVerified(true);
|
||||
showToast('사업자 번호 인증이 완료되었습니다', 'success');
|
||||
} catch {
|
||||
setIsBusinessNumberVerified(false);
|
||||
showToast('사업자 번호 인증에 실패했습니다', 'error');
|
||||
} finally {
|
||||
setIsVerifyingBusinessNumber(false);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (currentStep / 3) * 100;
|
||||
|
||||
const validateStep = (step: number) => {
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
if (step === '계정정보') {
|
||||
if (step === 1) {
|
||||
step1Schema.parse(formData);
|
||||
} else if (step === '개인정보') {
|
||||
} else if (step === 2) {
|
||||
step2Schema.parse(formData);
|
||||
} else if (step === '사업장정보') {
|
||||
} else if (step === 3) {
|
||||
step3Schema.parse(formData);
|
||||
}
|
||||
return true;
|
||||
@ -111,6 +169,12 @@ export default function RegisterPage() {
|
||||
}
|
||||
});
|
||||
setErrors(newErrors);
|
||||
|
||||
// 첫 번째 에러 메시지를 Toast로 표시
|
||||
const firstError = error.errors[0];
|
||||
if (firstError) {
|
||||
showToast(firstError.message, 'error');
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@ -121,27 +185,23 @@ export default function RegisterPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === '계정정보') {
|
||||
setCurrentStep('개인정보');
|
||||
} else if (currentStep === '개인정보') {
|
||||
setCurrentStep('사업장정보');
|
||||
} else if (currentStep === '사업장정보') {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === '개인정보') {
|
||||
setCurrentStep('계정정보');
|
||||
} else if (currentStep === '사업장정보') {
|
||||
setCurrentStep('개인정보');
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validateStep('사업장정보')) {
|
||||
if (!validateStep(3)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -152,7 +212,7 @@ export default function RegisterPage() {
|
||||
// const response = await axios.post(`${USER_HOST}/api/v1/auth/register`, formData);
|
||||
|
||||
// 임시 처리
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
@ -179,40 +239,48 @@ export default function RegisterPage() {
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: 'background.default',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
px: 3,
|
||||
py: 8,
|
||||
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
|
||||
}}
|
||||
>
|
||||
<IconButton onClick={handleBack} edge="start">
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 440,
|
||||
p: { xs: 4, sm: 6 },
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
>
|
||||
{/* Header with back button */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 4 }}>
|
||||
<IconButton onClick={handleBack} edge="start" sx={{ mr: 2 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h6" sx={{ ml: 2, fontWeight: 600 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
회원가입
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<Box sx={{ px: 3, pt: 3, pb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress}
|
||||
sx={{ height: 6, borderRadius: 3 }}
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, textAlign: 'center' }}>
|
||||
{stepIndex}/3 단계
|
||||
{currentStep}/3 단계
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Funnel Content */}
|
||||
<Box sx={{ flex: 1, px: 3, py: 4 }}>
|
||||
{currentStep === '계정정보' && (
|
||||
<Paper elevation={0} sx={{ p: 4 }}>
|
||||
{/* Step 1: 계정정보 */}
|
||||
{currentStep === 1 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
계정 정보를 입력해주세요
|
||||
</Typography>
|
||||
@ -267,7 +335,10 @@ export default function RegisterPage() {
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowConfirmPassword(!showConfirmPassword)} edge="end">
|
||||
<IconButton
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
edge="end"
|
||||
>
|
||||
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
@ -280,16 +351,30 @@ export default function RegisterPage() {
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
sx={{ mt: 2, py: 1.5 }}
|
||||
sx={{ mt: 2, py: 1.5, fontSize: 16, fontWeight: 600 }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
이미 계정이 있으신가요?{' '}
|
||||
<Link
|
||||
href="/login"
|
||||
underline="hover"
|
||||
sx={{ color: 'primary.main', fontWeight: 600 }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{currentStep === '개인정보' && (
|
||||
<Paper elevation={0} sx={{ p: 4 }}>
|
||||
{/* Step 2: 개인정보 */}
|
||||
{currentStep === 2 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
개인 정보를 입력해주세요
|
||||
</Typography>
|
||||
@ -328,7 +413,7 @@ export default function RegisterPage() {
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={handleBack}
|
||||
sx={{ flex: 1, py: 1.5 }}
|
||||
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
@ -336,17 +421,18 @@ export default function RegisterPage() {
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
sx={{ flex: 1, py: 1.5 }}
|
||||
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{currentStep === '사업장정보' && (
|
||||
<Paper elevation={0} sx={{ p: 4 }}>
|
||||
{/* Step 3: 사업장정보 */}
|
||||
{currentStep === 3 && (
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
사업장 정보를 입력해주세요
|
||||
</Typography>
|
||||
@ -366,6 +452,51 @@ export default function RegisterPage() {
|
||||
required
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="사업자 번호"
|
||||
placeholder="123-45-67890"
|
||||
value={formData.businessNumber || ''}
|
||||
onChange={(e) => {
|
||||
const formatted = formatBusinessNumber(e.target.value);
|
||||
setFormData({ ...formData, businessNumber: formatted });
|
||||
setIsBusinessNumberVerified(false);
|
||||
}}
|
||||
error={!!errors.businessNumber}
|
||||
helperText={
|
||||
errors.businessNumber ||
|
||||
(isBusinessNumberVerified
|
||||
? '✓ 인증 완료'
|
||||
: '사업자 번호를 입력하고 인증해주세요')
|
||||
}
|
||||
required
|
||||
sx={{
|
||||
'& .MuiFormHelperText-root': {
|
||||
color: isBusinessNumberVerified ? 'success.main' : undefined,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={handleVerifyBusinessNumber}
|
||||
disabled={isVerifyingBusinessNumber || isBusinessNumberVerified}
|
||||
sx={{
|
||||
minWidth: '90px',
|
||||
py: 1.5,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isVerifyingBusinessNumber
|
||||
? '인증중'
|
||||
: isBusinessNumberVerified
|
||||
? '완료'
|
||||
: '인증'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<FormControl fullWidth required error={!!errors.businessType}>
|
||||
<InputLabel>업종</InputLabel>
|
||||
<Select
|
||||
@ -399,7 +530,7 @@ export default function RegisterPage() {
|
||||
helperText="선택 사항입니다"
|
||||
/>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<FormGroup>
|
||||
<FormControlLabel
|
||||
control={
|
||||
@ -422,7 +553,7 @@ export default function RegisterPage() {
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Box sx={{ ml: 3, mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ ml: 3, mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
@ -433,7 +564,10 @@ export default function RegisterPage() {
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" color={errors.agreeTerms ? 'error' : 'text.primary'}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={errors.agreeTerms ? 'error' : 'text.primary'}
|
||||
>
|
||||
[필수] 이용약관 동의
|
||||
</Typography>
|
||||
}
|
||||
@ -448,7 +582,10 @@ export default function RegisterPage() {
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2" color={errors.agreePrivacy ? 'error' : 'text.primary'}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color={errors.agreePrivacy ? 'error' : 'text.primary'}
|
||||
>
|
||||
[필수] 개인정보 처리방침 동의
|
||||
</Typography>
|
||||
}
|
||||
@ -462,16 +599,16 @@ export default function RegisterPage() {
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
[선택] 마케팅 정보 수신 동의
|
||||
</Typography>
|
||||
}
|
||||
label={<Typography variant="body2">[선택] 마케팅 정보 수신 동의</Typography>}
|
||||
/>
|
||||
</Box>
|
||||
</FormGroup>
|
||||
{(errors.agreeTerms || errors.agreePrivacy) && (
|
||||
<Typography variant="caption" color="error" sx={{ mt: 1, display: 'block' }}>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="error"
|
||||
sx={{ mt: 1, display: 'block', ml: 2 }}
|
||||
>
|
||||
필수 약관에 동의해주세요
|
||||
</Typography>
|
||||
)}
|
||||
@ -482,7 +619,7 @@ export default function RegisterPage() {
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={handleBack}
|
||||
sx={{ flex: 1, py: 1.5 }}
|
||||
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
@ -490,15 +627,36 @@ export default function RegisterPage() {
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={handleNext}
|
||||
sx={{ flex: 1, py: 1.5 }}
|
||||
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
|
||||
>
|
||||
가입완료
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography>Loading...</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<RegisterForm />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
100
src/app/(main)/events/create/page.tsx
Normal file
100
src/app/(main)/events/create/page.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
'use client';
|
||||
|
||||
import { useFunnel } from '@use-funnel/browser';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import ObjectiveStep from './steps/ObjectiveStep';
|
||||
import RecommendationStep from './steps/RecommendationStep';
|
||||
import ChannelStep from './steps/ChannelStep';
|
||||
import ApprovalStep from './steps/ApprovalStep';
|
||||
|
||||
// 이벤트 생성 데이터 타입
|
||||
export type EventObjective = 'new_customer' | 'revisit' | 'sales' | 'awareness';
|
||||
export type BudgetLevel = 'low' | 'medium' | 'high';
|
||||
export type EventMethod = 'online' | 'offline';
|
||||
|
||||
export interface EventData {
|
||||
objective?: EventObjective;
|
||||
recommendation?: {
|
||||
budget: BudgetLevel;
|
||||
method: EventMethod;
|
||||
title: string;
|
||||
prize: string;
|
||||
participationMethod: string;
|
||||
expectedParticipants: number;
|
||||
estimatedCost: number;
|
||||
roi: number;
|
||||
};
|
||||
contentPreview?: {
|
||||
imageStyle: string;
|
||||
};
|
||||
contentEdit?: {
|
||||
title: string;
|
||||
prize: string;
|
||||
guide: string;
|
||||
};
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
export default function EventCreatePage() {
|
||||
const router = useRouter();
|
||||
|
||||
const funnel = useFunnel<{
|
||||
objective: EventData;
|
||||
recommendation: EventData;
|
||||
channel: EventData;
|
||||
approval: EventData;
|
||||
}>({
|
||||
id: 'event-creation',
|
||||
initial: {
|
||||
step: 'objective',
|
||||
context: {},
|
||||
},
|
||||
});
|
||||
|
||||
const handleComplete = () => {
|
||||
// 이벤트 생성 완료 후 대시보드로 이동
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<funnel.Render
|
||||
objective={({ history }) => (
|
||||
<ObjectiveStep
|
||||
onNext={(objective) => {
|
||||
history.push('recommendation', { objective });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
recommendation={({ context, history }) => (
|
||||
<RecommendationStep
|
||||
objective={context.objective}
|
||||
onNext={(recommendation) => {
|
||||
history.push('channel', { ...context, recommendation });
|
||||
}}
|
||||
onBack={() => {
|
||||
history.go(-1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
channel={({ context, history }) => (
|
||||
<ChannelStep
|
||||
onNext={(channels) => {
|
||||
history.push('approval', { ...context, channels });
|
||||
}}
|
||||
onBack={() => {
|
||||
history.go(-1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
approval={({ context, history }) => (
|
||||
<ApprovalStep
|
||||
eventData={context}
|
||||
onApprove={handleComplete}
|
||||
onBack={() => {
|
||||
history.go(-1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
347
src/app/(main)/events/create/steps/ApprovalStep.tsx
Normal file
347
src/app/(main)/events/create/steps/ApprovalStep.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Chip,
|
||||
Grid,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Link,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save } from '@mui/icons-material';
|
||||
import { EventData } from '../page';
|
||||
|
||||
interface ApprovalStepProps {
|
||||
eventData: EventData;
|
||||
onApprove: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalStepProps) {
|
||||
const [agreeTerms, setAgreeTerms] = useState(false);
|
||||
const [termsDialogOpen, setTermsDialogOpen] = useState(false);
|
||||
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
|
||||
const handleApprove = () => {
|
||||
if (!agreeTerms) return;
|
||||
|
||||
setIsDeploying(true);
|
||||
|
||||
// 배포 시뮬레이션
|
||||
setTimeout(() => {
|
||||
setIsDeploying(false);
|
||||
setSuccessDialogOpen(true);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleSaveDraft = () => {
|
||||
// TODO: 임시저장 API 연동
|
||||
alert('임시저장되었습니다');
|
||||
};
|
||||
|
||||
const getChannelNames = (channels?: string[]) => {
|
||||
const channelMap: Record<string, string> = {
|
||||
uriTV: '우리동네TV',
|
||||
ringoBiz: '링고비즈',
|
||||
genieTV: '지니TV',
|
||||
sns: 'SNS',
|
||||
};
|
||||
|
||||
return channels?.map((ch) => channelMap[ch] || ch) || [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="md" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
최종 승인
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Title Section */}
|
||||
<Box sx={{ textAlign: 'center', mb: 5 }}>
|
||||
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
이벤트를 확인해주세요
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
모든 정보를 검토한 후 배포하세요
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Event Summary Card */}
|
||||
<Card elevation={0} sx={{ mb: 4, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{eventData.recommendation?.title || '이벤트 제목'}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
|
||||
<Chip label="배포 대기" color="warning" size="small" />
|
||||
<Chip label="AI 추천" color="info" size="small" />
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
이벤트 기간
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
2025.02.01 ~ 2025.02.28
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
목표 참여자
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{eventData.recommendation?.expectedParticipants || 0}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
예상 비용
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{eventData.recommendation?.estimatedCost.toLocaleString() || 0}원
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
예상 ROI
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main' }}>
|
||||
{eventData.recommendation?.roi || 0}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Event Details */}
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
|
||||
이벤트 상세
|
||||
</Typography>
|
||||
|
||||
<Card elevation={0} sx={{ mb: 2, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
이벤트 제목
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{eventData.recommendation?.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card elevation={0} sx={{ mb: 2, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
경품
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{eventData.recommendation?.prize}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
<Edit fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card elevation={0} sx={{ mb: 2, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
참여 방법
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||
{eventData.recommendation?.participationMethod}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Distribution Channels */}
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3, mt: 4 }}>
|
||||
배포 채널
|
||||
</Typography>
|
||||
|
||||
<Card elevation={0} sx={{ mb: 4, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, mb: 2 }}>
|
||||
{getChannelNames(eventData.channels).map((channel) => (
|
||||
<Chip key={channel} label={channel} color="primary" variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Edit />}
|
||||
sx={{ color: 'primary.main' }}
|
||||
>
|
||||
채널 수정하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terms Agreement */}
|
||||
<Card elevation={0} sx={{ mb: 5, borderRadius: 3, bgcolor: 'grey.50' }}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={agreeTerms}
|
||||
onChange={(e) => setAgreeTerms(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="body2">
|
||||
이벤트 약관 및 개인정보 처리방침에 동의합니다{' '}
|
||||
<span style={{ color: 'red' }}>(필수)</span>
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
component="button"
|
||||
variant="body2"
|
||||
onClick={() => setTermsDialogOpen(true)}
|
||||
sx={{ color: 'error.main', ml: 4, mt: 1 }}
|
||||
>
|
||||
약관 보기
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!agreeTerms || isDeploying}
|
||||
onClick={handleApprove}
|
||||
startIcon={isDeploying ? null : <RocketLaunch />}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
{isDeploying ? '배포 중...' : '배포하기'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={handleSaveDraft}
|
||||
startIcon={<Save />}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
임시저장
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Terms Dialog */}
|
||||
<Dialog
|
||||
open={termsDialogOpen}
|
||||
onClose={() => setTermsDialogOpen(false)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>이벤트 약관</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
제1조 (목적)
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3 }}>
|
||||
본 약관은 KT AI 이벤트 마케팅 서비스를 통해 진행되는 이벤트의 참여 및 개인정보 처리에 관한
|
||||
사항을 규정합니다.
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
제2조 (개인정보 수집 및 이용)
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
수집 항목: 이름, 전화번호, 이메일
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||
이용 목적: 이벤트 참여 확인 및 경품 제공
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mb: 3 }}>
|
||||
보유 기간: 이벤트 종료 후 6개월
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" sx={{ mb: 2 }}>
|
||||
제3조 (당첨자 발표)
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
당첨자는 이벤트 종료 후 7일 이내 개별 연락 드립니다.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setTermsDialogOpen(false)} variant="contained">
|
||||
확인
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Success Dialog */}
|
||||
<Dialog
|
||||
open={successDialogOpen}
|
||||
onClose={() => {
|
||||
setSuccessDialogOpen(false);
|
||||
onApprove();
|
||||
}}
|
||||
>
|
||||
<DialogContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
|
||||
<CheckCircle sx={{ fontSize: 80, color: 'success.main', mb: 2 }} />
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
배포 완료!
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 4 }}>
|
||||
이벤트가 성공적으로 배포되었습니다.
|
||||
<br />
|
||||
실시간으로 참여자를 확인할 수 있습니다.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setSuccessDialogOpen(false);
|
||||
onApprove();
|
||||
}}
|
||||
>
|
||||
대시보드로 이동
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
413
src/app/(main)/events/create/steps/ChannelStep.tsx
Normal file
413
src/app/(main)/events/create/steps/ChannelStep.tsx
Normal file
@ -0,0 +1,413 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack } from '@mui/icons-material';
|
||||
|
||||
interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
config?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
interface ChannelStepProps {
|
||||
onNext: (channels: string[]) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ChannelStep({ onNext, onBack }: ChannelStepProps) {
|
||||
const [channels, setChannels] = useState<Channel[]>([
|
||||
{ id: 'uriTV', name: '우리동네TV', selected: false, config: { radius: '500', time: 'evening' } },
|
||||
{ id: 'ringoBiz', name: '링고비즈', selected: false, config: { phone: '010-1234-5678' } },
|
||||
{ id: 'genieTV', name: '지니TV 광고', selected: false, config: { region: 'suwon', time: 'all', budget: '' } },
|
||||
{ id: 'sns', name: 'SNS', selected: false, config: { instagram: 'true', naver: 'true', kakao: 'false', schedule: 'now' } },
|
||||
]);
|
||||
|
||||
const handleChannelToggle = (channelId: string) => {
|
||||
setChannels((prev) =>
|
||||
prev.map((ch) => (ch.id === channelId ? { ...ch, selected: !ch.selected } : ch))
|
||||
);
|
||||
};
|
||||
|
||||
const handleConfigChange = (channelId: string, key: string, value: string) => {
|
||||
setChannels((prev) =>
|
||||
prev.map((ch) =>
|
||||
ch.id === channelId ? { ...ch, config: { ...ch.config, [key]: value } } : ch
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getChannelConfig = (channelId: string, key: string): string => {
|
||||
const channel = channels.find((ch) => ch.id === channelId);
|
||||
return (channel?.config?.[key] as string) || '';
|
||||
};
|
||||
|
||||
const calculateSummary = () => {
|
||||
let totalCost = 0;
|
||||
let totalExposure = 0;
|
||||
|
||||
channels.forEach((ch) => {
|
||||
if (!ch.selected) return;
|
||||
|
||||
if (ch.id === 'uriTV') {
|
||||
totalCost += 80000;
|
||||
totalExposure += 50000;
|
||||
} else if (ch.id === 'ringoBiz') {
|
||||
totalExposure += 30000;
|
||||
} else if (ch.id === 'genieTV') {
|
||||
const budget = parseInt(getChannelConfig('genieTV', 'budget')) || 0;
|
||||
totalCost += budget;
|
||||
totalExposure += Math.floor(budget / 100) * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
return { totalCost, totalExposure };
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
const selectedChannels = channels.filter((ch) => ch.selected).map((ch) => ch.id);
|
||||
if (selectedChannels.length > 0) {
|
||||
onNext(selectedChannels);
|
||||
}
|
||||
};
|
||||
|
||||
const { totalCost, totalExposure } = calculateSummary();
|
||||
const selectedCount = channels.filter((ch) => ch.selected).length;
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
배포 채널 선택
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 4, textAlign: 'center' }}>
|
||||
(최소 1개 이상)
|
||||
</Typography>
|
||||
|
||||
{/* 우리동네TV */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
opacity: channels[0].selected ? 1 : 0.6,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={channels[0].selected}
|
||||
onChange={() => handleChannelToggle('uriTV')}
|
||||
/>
|
||||
}
|
||||
label="우리동네TV"
|
||||
sx={{ mb: channels[0].selected ? 2 : 0 }}
|
||||
/>
|
||||
|
||||
{channels[0].selected && (
|
||||
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>반경</InputLabel>
|
||||
<Select
|
||||
value={getChannelConfig('uriTV', 'radius')}
|
||||
onChange={(e) => handleConfigChange('uriTV', 'radius', e.target.value)}
|
||||
label="반경"
|
||||
>
|
||||
<MenuItem value="500">500m</MenuItem>
|
||||
<MenuItem value="1000">1km</MenuItem>
|
||||
<MenuItem value="2000">2km</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>노출 시간대</InputLabel>
|
||||
<Select
|
||||
value={getChannelConfig('uriTV', 'time')}
|
||||
onChange={(e) => handleConfigChange('uriTV', 'time', e.target.value)}
|
||||
label="노출 시간대"
|
||||
>
|
||||
<MenuItem value="morning">아침 (7-12시)</MenuItem>
|
||||
<MenuItem value="afternoon">점심 (12-17시)</MenuItem>
|
||||
<MenuItem value="evening">저녁 (17-22시)</MenuItem>
|
||||
<MenuItem value="all">전체</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
예상 노출: <strong>5만명</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
비용: <strong>8만원</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 링고비즈 */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
opacity: channels[1].selected ? 1 : 0.6,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={channels[1].selected}
|
||||
onChange={() => handleChannelToggle('ringoBiz')}
|
||||
/>
|
||||
}
|
||||
label="링고비즈"
|
||||
sx={{ mb: channels[1].selected ? 2 : 0 }}
|
||||
/>
|
||||
|
||||
{channels[1].selected && (
|
||||
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="매장 전화번호"
|
||||
value={getChannelConfig('ringoBiz', 'phone')}
|
||||
InputProps={{ readOnly: true }}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 0.5 }}>
|
||||
연결음 자동 업데이트
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
예상 노출: <strong>3만명</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
비용: <strong>무료</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 지니TV 광고 */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 3,
|
||||
borderRadius: 3,
|
||||
opacity: channels[2].selected ? 1 : 0.6,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={channels[2].selected}
|
||||
onChange={() => handleChannelToggle('genieTV')}
|
||||
/>
|
||||
}
|
||||
label="지니TV 광고"
|
||||
sx={{ mb: channels[2].selected ? 2 : 0 }}
|
||||
/>
|
||||
|
||||
{channels[2].selected && (
|
||||
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>지역</InputLabel>
|
||||
<Select
|
||||
value={getChannelConfig('genieTV', 'region')}
|
||||
onChange={(e) => handleConfigChange('genieTV', 'region', e.target.value)}
|
||||
label="지역"
|
||||
>
|
||||
<MenuItem value="suwon">수원</MenuItem>
|
||||
<MenuItem value="seoul">서울</MenuItem>
|
||||
<MenuItem value="busan">부산</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>노출 시간대</InputLabel>
|
||||
<Select
|
||||
value={getChannelConfig('genieTV', 'time')}
|
||||
onChange={(e) => handleConfigChange('genieTV', 'time', e.target.value)}
|
||||
label="노출 시간대"
|
||||
>
|
||||
<MenuItem value="all">전체</MenuItem>
|
||||
<MenuItem value="prime">프라임 (19-23시)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="예산"
|
||||
placeholder="예산을 입력하세요"
|
||||
value={getChannelConfig('genieTV', 'budget')}
|
||||
onChange={(e) => handleConfigChange('genieTV', 'budget', e.target.value)}
|
||||
InputProps={{ inputProps: { min: 0, step: 10000 } }}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
예상 노출:{' '}
|
||||
<strong>
|
||||
{getChannelConfig('genieTV', 'budget')
|
||||
? `${(Math.floor(parseInt(getChannelConfig('genieTV', 'budget')) / 100) * 1000 / 10000).toFixed(1)}만명`
|
||||
: '계산중...'}
|
||||
</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SNS */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 5,
|
||||
borderRadius: 3,
|
||||
opacity: channels[3].selected ? 1 : 0.6,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={channels[3].selected}
|
||||
onChange={() => handleChannelToggle('sns')}
|
||||
/>
|
||||
}
|
||||
label="SNS"
|
||||
sx={{ mb: channels[3].selected ? 2 : 0 }}
|
||||
/>
|
||||
|
||||
{channels[3].selected && (
|
||||
<Box sx={{ pl: 4, pt: 2, borderTop: 1, borderColor: 'divider' }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 600 }}>
|
||||
플랫폼 선택
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={getChannelConfig('sns', 'instagram') === 'true'}
|
||||
onChange={(e) =>
|
||||
handleConfigChange('sns', 'instagram', e.target.checked.toString())
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Instagram"
|
||||
sx={{ display: 'block' }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={getChannelConfig('sns', 'naver') === 'true'}
|
||||
onChange={(e) =>
|
||||
handleConfigChange('sns', 'naver', e.target.checked.toString())
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Naver Blog"
|
||||
sx={{ display: 'block' }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={getChannelConfig('sns', 'kakao') === 'true'}
|
||||
onChange={(e) =>
|
||||
handleConfigChange('sns', 'kakao', e.target.checked.toString())
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Kakao Channel"
|
||||
sx={{ display: 'block', mb: 2 }}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>예약 게시</InputLabel>
|
||||
<Select
|
||||
value={getChannelConfig('sns', 'schedule')}
|
||||
onChange={(e) => handleConfigChange('sns', 'schedule', e.target.value)}
|
||||
label="예약 게시"
|
||||
>
|
||||
<MenuItem value="now">즉시</MenuItem>
|
||||
<MenuItem value="schedule">예약</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
예상 노출: <strong>-</strong>
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
비용: <strong>무료</strong>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card elevation={0} sx={{ mb: 5, borderRadius: 3, bgcolor: 'grey.50' }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="h6">총 예상 비용</Typography>
|
||||
<Typography variant="h6" color="error.main" sx={{ fontWeight: 700 }}>
|
||||
{totalCost.toLocaleString()}원
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6">총 예상 노출</Typography>
|
||||
<Typography variant="h6" color="primary.main" sx={{ fontWeight: 700 }}>
|
||||
{totalExposure > 0 ? `${totalExposure.toLocaleString()}명+` : '0명'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button fullWidth variant="outlined" size="large" onClick={onBack} sx={{ py: 1.5 }}>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={handleNext}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
159
src/app/(main)/events/create/steps/ContentEditStep.tsx
Normal file
159
src/app/(main)/events/create/steps/ContentEditStep.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
TextField,
|
||||
Grid,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Edit } from '@mui/icons-material';
|
||||
|
||||
interface ContentEditStepProps {
|
||||
initialTitle: string;
|
||||
initialPrize: string;
|
||||
onNext: (data: { title: string; prize: string; guide: string }) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ContentEditStep({
|
||||
initialTitle,
|
||||
initialPrize,
|
||||
onNext,
|
||||
onBack,
|
||||
}: ContentEditStepProps) {
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const [prize, setPrize] = useState(initialPrize);
|
||||
const [guide, setGuide] = useState('전화번호를 입력하고 참여하세요');
|
||||
|
||||
const handleSave = () => {
|
||||
// TODO: 임시저장 API 연동
|
||||
alert('편집 내용이 저장되었습니다');
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
onNext({ title, prize, guide });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
콘텐츠 편집
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={4}>
|
||||
{/* Preview Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
|
||||
미리보기
|
||||
</Typography>
|
||||
<Card elevation={0} sx={{ borderRadius: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
bgcolor: 'grey.100',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 48, marginBottom: 16 }}>
|
||||
celebration
|
||||
</span>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{title || '제목을 입력하세요'}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{prize || '경품을 입력하세요'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{guide || '참여 안내를 입력하세요'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Edit Section */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
|
||||
편집
|
||||
</Typography>
|
||||
|
||||
<Card elevation={0} sx={{ borderRadius: 3, mb: 3 }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Edit color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
텍스트 편집
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="제목"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
inputProps={{ maxLength: 50 }}
|
||||
helperText={`${title.length}/50자`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="경품"
|
||||
value={prize}
|
||||
onChange={(e) => setPrize(e.target.value)}
|
||||
inputProps={{ maxLength: 30 }}
|
||||
helperText={`${prize.length}/30자`}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="참여안내"
|
||||
value={guide}
|
||||
onChange={(e) => setGuide(e.target.value)}
|
||||
multiline
|
||||
rows={3}
|
||||
inputProps={{ maxLength: 100 }}
|
||||
helperText={`${guide.length}/100자`}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 5 }}>
|
||||
<Button fullWidth variant="outlined" size="large" onClick={handleSave} sx={{ py: 1.5 }}>
|
||||
저장
|
||||
</Button>
|
||||
<Button fullWidth variant="contained" size="large" onClick={handleNext} sx={{ py: 1.5 }}>
|
||||
다음 단계
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
368
src/app/(main)/events/create/steps/ContentPreviewStep.tsx
Normal file
368
src/app/(main)/events/create/steps/ContentPreviewStep.tsx
Normal file
@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Dialog,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material';
|
||||
|
||||
interface ImageStyle {
|
||||
id: string;
|
||||
name: string;
|
||||
gradient?: string;
|
||||
icon: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
const imageStyles: ImageStyle[] = [
|
||||
{
|
||||
id: 'simple',
|
||||
name: '스타일 1: 심플',
|
||||
icon: 'celebration',
|
||||
},
|
||||
{
|
||||
id: 'fancy',
|
||||
name: '스타일 2: 화려',
|
||||
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
icon: 'auto_awesome',
|
||||
textColor: 'white',
|
||||
},
|
||||
{
|
||||
id: 'trendy',
|
||||
name: '스타일 3: 트렌디',
|
||||
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
|
||||
icon: 'trending_up',
|
||||
textColor: 'white',
|
||||
},
|
||||
];
|
||||
|
||||
interface ContentPreviewStepProps {
|
||||
title: string;
|
||||
prize: string;
|
||||
onNext: (imageStyle: string) => void;
|
||||
onSkip: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function ContentPreviewStep({
|
||||
title,
|
||||
prize,
|
||||
onNext,
|
||||
onSkip,
|
||||
onBack,
|
||||
}: ContentPreviewStepProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
|
||||
const [fullscreenOpen, setFullscreenOpen] = useState(false);
|
||||
const [fullscreenStyle, setFullscreenStyle] = useState<ImageStyle | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// AI 이미지 생성 시뮬레이션
|
||||
const timer = setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleStyleSelect = (styleId: string) => {
|
||||
setSelectedStyle(styleId);
|
||||
};
|
||||
|
||||
const handlePreview = (style: ImageStyle, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setFullscreenStyle(style);
|
||||
setFullscreenOpen(true);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (selectedStyle) {
|
||||
onNext(selectedStyle);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="md" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
SNS 이미지 생성
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', mt: 10, mb: 10 }}>
|
||||
<Psychology
|
||||
sx={{
|
||||
fontSize: 64,
|
||||
color: 'info.main',
|
||||
mb: 2,
|
||||
animation: 'spin 2s linear infinite',
|
||||
'@keyframes spin': {
|
||||
'0%': { transform: 'rotate(0deg)' },
|
||||
'100%': { transform: 'rotate(360deg)' },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
AI 이미지 생성 중
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
|
||||
딥러닝 모델이 이벤트에 어울리는
|
||||
<br />
|
||||
이미지를 생성하고 있어요...
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
예상 시간: 5초
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="md" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
SNS 이미지 생성
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value)}>
|
||||
{imageStyles.map((style) => (
|
||||
<Box key={style.id} sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
{style.name}
|
||||
</Typography>
|
||||
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
border: selectedStyle === style.id ? 3 : 1,
|
||||
borderColor: selectedStyle === style.id ? 'error.main' : 'divider',
|
||||
transition: 'all 0.3s',
|
||||
position: 'relative',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleStyleSelect(style.id)}
|
||||
>
|
||||
{selectedStyle === style.id && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
bgcolor: 'error.main',
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<span className="material-icons" style={{ fontSize: 20 }}>
|
||||
check
|
||||
</span>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
aspectRatio: '1 / 1',
|
||||
background: style.gradient || '#f5f5f5',
|
||||
borderRadius: '12px 12px 0 0',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="material-icons"
|
||||
style={{
|
||||
fontSize: 48,
|
||||
marginBottom: 16,
|
||||
color: style.textColor || 'inherit',
|
||||
}}
|
||||
>
|
||||
{style.icon}
|
||||
</span>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
mb: 1,
|
||||
color: style.textColor || 'text.primary',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
sx={{
|
||||
color: style.textColor || 'text.secondary',
|
||||
opacity: style.textColor ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{prize}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ p: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<ZoomIn />}
|
||||
onClick={(e) => handlePreview(style, e)}
|
||||
>
|
||||
크게보기
|
||||
</Button>
|
||||
<FormControlLabel
|
||||
value={style.id}
|
||||
control={<Radio sx={{ display: 'none' }} />}
|
||||
label=""
|
||||
sx={{ display: 'none' }}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mt: 5 }}>
|
||||
<Button fullWidth variant="outlined" size="large" onClick={onSkip} sx={{ py: 1.5 }}>
|
||||
건너뛰기
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!selectedStyle}
|
||||
onClick={handleNext}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Fullscreen Dialog */}
|
||||
<Dialog
|
||||
open={fullscreenOpen}
|
||||
onClose={() => setFullscreenOpen(false)}
|
||||
maxWidth={false}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: 'rgba(0, 0, 0, 0.95)',
|
||||
boxShadow: 'none',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 4,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setFullscreenOpen(false)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 16,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.9)',
|
||||
'&:hover': { bgcolor: 'white' },
|
||||
}}
|
||||
>
|
||||
<span className="material-icons">close</span>
|
||||
</IconButton>
|
||||
|
||||
{fullscreenStyle && (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 600,
|
||||
aspectRatio: '1 / 1',
|
||||
background: fullscreenStyle.gradient || '#f5f5f5',
|
||||
borderRadius: 3,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
p: 6,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="material-icons"
|
||||
style={{
|
||||
fontSize: 80,
|
||||
marginBottom: 24,
|
||||
color: fullscreenStyle.textColor || 'inherit',
|
||||
}}
|
||||
>
|
||||
{fullscreenStyle.icon}
|
||||
</span>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 2,
|
||||
color: fullscreenStyle.textColor || 'text.primary',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h6"
|
||||
sx={{
|
||||
color: fullscreenStyle.textColor || 'text.secondary',
|
||||
opacity: fullscreenStyle.textColor ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{prize}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
157
src/app/(main)/events/create/steps/ObjectiveStep.tsx
Normal file
157
src/app/(main)/events/create/steps/ObjectiveStep.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Grid,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
} from '@mui/material';
|
||||
import { AutoAwesome, TrendingUp, Replay, Store, Campaign } from '@mui/icons-material';
|
||||
import { EventObjective } from '../page';
|
||||
|
||||
interface ObjectiveOption {
|
||||
id: EventObjective;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const objectives: ObjectiveOption[] = [
|
||||
{
|
||||
id: 'new_customer',
|
||||
icon: <Store sx={{ fontSize: 40 }} />,
|
||||
title: '신규 고객 유치',
|
||||
description: '새로운 고객을 확보하여 매장을 성장시키고 싶어요',
|
||||
},
|
||||
{
|
||||
id: 'revisit',
|
||||
icon: <Replay sx={{ fontSize: 40 }} />,
|
||||
title: '재방문 유도',
|
||||
description: '기존 고객의 재방문을 촉진하고 싶어요',
|
||||
},
|
||||
{
|
||||
id: 'sales',
|
||||
icon: <TrendingUp sx={{ fontSize: 40 }} />,
|
||||
title: '매출 증대',
|
||||
description: '단기간에 매출을 높이고 싶어요',
|
||||
},
|
||||
{
|
||||
id: 'awareness',
|
||||
icon: <Campaign sx={{ fontSize: 40 }} />,
|
||||
title: '인지도 향상',
|
||||
description: '브랜드나 매장 인지도를 높이고 싶어요',
|
||||
},
|
||||
];
|
||||
|
||||
interface ObjectiveStepProps {
|
||||
onNext: (objective: EventObjective) => void;
|
||||
}
|
||||
|
||||
export default function ObjectiveStep({ onNext }: ObjectiveStepProps) {
|
||||
const [selected, setSelected] = useState<EventObjective | null>(null);
|
||||
|
||||
const handleNext = () => {
|
||||
if (selected) {
|
||||
onNext(selected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="md" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Title Section */}
|
||||
<Box sx={{ mb: 5, textAlign: 'center' }}>
|
||||
<AutoAwesome sx={{ fontSize: 64, color: 'primary.main', mb: 2 }} />
|
||||
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
이벤트 목적을 선택해주세요
|
||||
</Typography>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
AI가 목적에 맞는 최적의 이벤트를 추천해드립니다
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Purpose Options */}
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value as EventObjective)}>
|
||||
<Grid container spacing={3} sx={{ mb: 5 }}>
|
||||
{objectives.map((objective) => (
|
||||
<Grid item xs={12} sm={6} key={objective.id}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
border: selected === objective.id ? 2 : 1,
|
||||
borderColor: selected === objective.id ? 'primary.main' : 'divider',
|
||||
bgcolor: selected === objective.id ? 'action.selected' : 'background.paper',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: selected === objective.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
}}
|
||||
onClick={() => setSelected(objective.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
|
||||
<Box sx={{ color: 'primary.main' }}>{objective.icon}</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
|
||||
{objective.title}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{objective.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
value={objective.id}
|
||||
control={<Radio />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Info Box */}
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
mb: 5,
|
||||
bgcolor: 'primary.light',
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ display: 'flex', gap: 2, p: 3 }}>
|
||||
<AutoAwesome sx={{ color: 'primary.main' }} />
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
선택하신 목적에 따라 AI가 업종, 지역, 계절 트렌드를 분석하여 가장 효과적인 이벤트를 추천합니다.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={!selected}
|
||||
onClick={handleNext}
|
||||
sx={{ py: 1.5 }}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
330
src/app/(main)/events/create/steps/RecommendationStep.tsx
Normal file
330
src/app/(main)/events/create/steps/RecommendationStep.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
TextField,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from '@mui/material';
|
||||
import { ArrowBack, Edit, Insights } from '@mui/icons-material';
|
||||
import { EventObjective, BudgetLevel, EventMethod } from '../page';
|
||||
|
||||
interface Recommendation {
|
||||
id: string;
|
||||
budget: BudgetLevel;
|
||||
method: EventMethod;
|
||||
title: string;
|
||||
prize: string;
|
||||
participationMethod: string;
|
||||
expectedParticipants: number;
|
||||
estimatedCost: number;
|
||||
roi: number;
|
||||
}
|
||||
|
||||
// Mock 추천 데이터
|
||||
const mockRecommendations: Recommendation[] = [
|
||||
// 저비용
|
||||
{
|
||||
id: 'low-online',
|
||||
budget: 'low',
|
||||
method: 'online',
|
||||
title: 'SNS 팔로우 이벤트',
|
||||
prize: '커피 쿠폰',
|
||||
participationMethod: 'SNS 팔로우',
|
||||
expectedParticipants: 180,
|
||||
estimatedCost: 250000,
|
||||
roi: 520,
|
||||
},
|
||||
{
|
||||
id: 'low-offline',
|
||||
budget: 'low',
|
||||
method: 'offline',
|
||||
title: '전화번호 등록 이벤트',
|
||||
prize: '커피 쿠폰',
|
||||
participationMethod: '방문 시 전화번호 등록',
|
||||
expectedParticipants: 120,
|
||||
estimatedCost: 300000,
|
||||
roi: 380,
|
||||
},
|
||||
// 중비용
|
||||
{
|
||||
id: 'medium-online',
|
||||
budget: 'medium',
|
||||
method: 'online',
|
||||
title: '리뷰 작성 이벤트',
|
||||
prize: '상품권 5만원',
|
||||
participationMethod: '네이버 리뷰 작성',
|
||||
expectedParticipants: 250,
|
||||
estimatedCost: 800000,
|
||||
roi: 450,
|
||||
},
|
||||
{
|
||||
id: 'medium-offline',
|
||||
budget: 'medium',
|
||||
method: 'offline',
|
||||
title: '스탬프 적립 이벤트',
|
||||
prize: '상품권 5만원',
|
||||
participationMethod: '3회 방문 시 스탬프',
|
||||
expectedParticipants: 200,
|
||||
estimatedCost: 1000000,
|
||||
roi: 380,
|
||||
},
|
||||
// 고비용
|
||||
{
|
||||
id: 'high-online',
|
||||
budget: 'high',
|
||||
method: 'online',
|
||||
title: '인플루언서 협업 이벤트',
|
||||
prize: '애플 에어팟',
|
||||
participationMethod: '게시물 공유 및 댓글',
|
||||
expectedParticipants: 500,
|
||||
estimatedCost: 2000000,
|
||||
roi: 380,
|
||||
},
|
||||
{
|
||||
id: 'high-offline',
|
||||
budget: 'high',
|
||||
method: 'offline',
|
||||
title: 'VIP 고객 초대 이벤트',
|
||||
prize: '애플 에어팟',
|
||||
participationMethod: '누적 10회 방문',
|
||||
expectedParticipants: 300,
|
||||
estimatedCost: 2500000,
|
||||
roi: 320,
|
||||
},
|
||||
];
|
||||
|
||||
interface RecommendationStepProps {
|
||||
objective?: EventObjective;
|
||||
onNext: (data: Recommendation) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) {
|
||||
const [selectedBudget, setSelectedBudget] = useState<BudgetLevel>('low');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({});
|
||||
|
||||
const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget);
|
||||
|
||||
const handleNext = () => {
|
||||
const selectedRec = mockRecommendations.find((r) => r.id === selected);
|
||||
if (selectedRec && selected) {
|
||||
const edited = editedData[selected];
|
||||
onNext({
|
||||
...selectedRec,
|
||||
title: edited?.title || selectedRec.title,
|
||||
prize: edited?.prize || selectedRec.prize,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTitle = (id: string, title: string) => {
|
||||
setEditedData((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], title },
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEditPrize = (id: string, prize: string) => {
|
||||
setEditedData((prev) => ({
|
||||
...prev,
|
||||
[id]: { ...prev[id], prize },
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 4 }}>
|
||||
<IconButton onClick={onBack}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700 }}>
|
||||
AI 이벤트 추천
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* Trends Analysis */}
|
||||
<Card elevation={0} sx={{ mb: 5, borderRadius: 3 }}>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||
<Insights color="primary" />
|
||||
<Typography variant="h6" sx={{ fontWeight: 700 }}>
|
||||
AI 트렌드 분석
|
||||
</Typography>
|
||||
</Box>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
📍 업종 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
음식점업 신년 프로모션 트렌드
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
🗺️ 지역 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
강남구 음식점 할인 이벤트 증가
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 1 }}>
|
||||
☀️ 시즌 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
설 연휴 특수 대비 고객 유치 전략
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Selection */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
예산별 추천 이벤트
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={selectedBudget}
|
||||
onChange={(_, value) => setSelectedBudget(value)}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 4 }}
|
||||
>
|
||||
<Tab label="💰 저비용" value="low" />
|
||||
<Tab label="💰💰 중비용" value="medium" />
|
||||
<Tab label="💰💰💰 고비용" value="high" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Recommendations */}
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<Grid container spacing={3} sx={{ mb: 5 }}>
|
||||
{budgetRecommendations.map((rec) => (
|
||||
<Grid item xs={12} md={6} key={rec.id}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
border: selected === rec.id ? 2 : 1,
|
||||
borderColor: selected === rec.id ? 'primary.main' : 'divider',
|
||||
bgcolor: selected === rec.id ? 'action.selected' : 'background.paper',
|
||||
transition: 'all 0.2s',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
}}
|
||||
onClick={() => setSelected(rec.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 2 }}>
|
||||
<Chip
|
||||
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
|
||||
color={rec.method === 'online' ? 'primary' : 'secondary'}
|
||||
size="small"
|
||||
/>
|
||||
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.title || rec.title}
|
||||
onChange={(e) => handleEditTitle(rec.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ mb: 2 }}
|
||||
InputProps={{
|
||||
endAdornment: <Edit fontSize="small" color="action" />,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5, display: 'block' }}>
|
||||
경품
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="small"
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.prize || rec.prize}
|
||||
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
InputProps={{
|
||||
endAdornment: <Edit fontSize="small" color="action" />,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mt: 2 }}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
참여 방법
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{rec.participationMethod}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
예상 참여
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{rec.expectedParticipants}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
예상 비용
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{(rec.estimatedCost / 10000).toFixed(0)}만원
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
투자대비수익률
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main' }}>
|
||||
{rec.roi}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</RadioGroup>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button fullWidth variant="outlined" size="large" onClick={onBack} sx={{ py: 1.5 }}>
|
||||
이전
|
||||
</Button>
|
||||
<Button fullWidth variant="contained" size="large" disabled={!selected} onClick={handleNext} sx={{ py: 1.5 }}>
|
||||
다음
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -149,7 +149,8 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onChangePassword = async (_data: PasswordData) => {
|
||||
const onChangePassword = async (data: PasswordData) => {
|
||||
console.log('Password change data:', data);
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
|
||||
457
src/app/page.tsx
457
src/app/page.tsx
@ -1,30 +1,455 @@
|
||||
import { Box, Container, Typography } from '@mui/material';
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Fab,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add,
|
||||
Celebration,
|
||||
Group,
|
||||
TrendingUp,
|
||||
Analytics,
|
||||
PersonAdd,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
} from '@mui/icons-material';
|
||||
|
||||
// Mock 사용자 데이터 (API 연동 전까지 임시 사용)
|
||||
const mockUser = {
|
||||
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 = [
|
||||
{ icon: PersonAdd, text: 'SNS 팔로우 이벤트에 새로운 참여자 12명', time: '5분 전' },
|
||||
{ icon: Edit, text: '설 맞이 할인 이벤트 내용을 수정했습니다', time: '1시간 전' },
|
||||
{ icon: CheckCircle, text: '고객 만족도 조사가 종료되었습니다', time: '3시간 전' },
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
// KPI 계산
|
||||
const activeEvents = mockEvents.filter((e) => e.status === '진행중');
|
||||
const totalParticipants = mockEvents.reduce((sum, e) => sum + e.participants, 0);
|
||||
const avgROI = mockEvents.length > 0
|
||||
? Math.round(mockEvents.reduce((sum, e) => sum + e.roi, 0) / mockEvents.length)
|
||||
: 0;
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
router.push('/events/create');
|
||||
};
|
||||
|
||||
const handleViewAnalytics = () => {
|
||||
router.push('/analytics');
|
||||
};
|
||||
|
||||
const handleEventClick = (eventId: string) => {
|
||||
router.push(`/events/${eventId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg">
|
||||
<Box
|
||||
sx={{
|
||||
pb: 10,
|
||||
bgcolor: 'background.default',
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h1" gutterBottom color="primary">
|
||||
KT AI 이벤트 마케팅
|
||||
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
|
||||
{/* Welcome Section */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography
|
||||
variant="h4"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
mb: 1,
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
안녕하세요, {mockUser.name}님!
|
||||
</Typography>
|
||||
<Typography variant="h4" color="text.secondary" gutterBottom>
|
||||
소상공인을 위한 스마트 마케팅
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ mt: 2, maxWidth: 600 }}>
|
||||
프로젝트 초기 설정이 완료되었습니다.
|
||||
<br />
|
||||
화면 개발은 사용자와 함께 진행됩니다.
|
||||
<Typography variant="body1" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
오늘도 성공적인 이벤트를 준비해보세요 ✨
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Grid container spacing={3} sx={{ mb: 5 }}>
|
||||
<Grid item xs={4}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 3, px: 2 }}>
|
||||
<Celebration sx={{ fontSize: 40, mb: 1.5, color: 'white' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mb: 0.5, color: 'white', fontWeight: 600 }}
|
||||
>
|
||||
진행 중
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{activeEvents.length}개
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'secondary.main',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 3, px: 2 }}>
|
||||
<Group sx={{ fontSize: 40, mb: 1.5, color: 'white' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mb: 0.5, color: 'white', fontWeight: 600 }}
|
||||
>
|
||||
총 참여자
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{totalParticipants.toLocaleString()}명
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
bgcolor: 'info.main',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 3, px: 2 }}>
|
||||
<TrendingUp sx={{ fontSize: 40, mb: 1.5, color: 'white' }} />
|
||||
<Typography
|
||||
variant="caption"
|
||||
display="block"
|
||||
sx={{ mb: 0.5, color: 'white', fontWeight: 600 }}
|
||||
>
|
||||
평균 ROI
|
||||
</Typography>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'white' }}>
|
||||
{avgROI}%
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3, color: 'text.primary' }}>
|
||||
빠른 시작
|
||||
</Typography>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={6}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
onClick={handleCreateEvent}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 32, color: 'white' }} />
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
새 이벤트
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
onClick={handleViewAnalytics}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '16px',
|
||||
background: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
margin: '0 auto',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<Analytics sx={{ fontSize: 32, color: 'white' }} />
|
||||
</Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary' }}>
|
||||
분석
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
{/* Active Events */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
진행 중인 이벤트
|
||||
</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
endIcon={<span className="material-icons">chevron_right</span>}
|
||||
onClick={() => router.push('/events')}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
fontWeight: 600,
|
||||
'&:hover': { background: 'rgba(102, 126, 234, 0.08)' },
|
||||
}}
|
||||
>
|
||||
전체보기
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{activeEvents.length === 0 ? (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ textAlign: 'center', py: 8 }}>
|
||||
<Box sx={{ color: 'text.disabled', mb: 2 }}>
|
||||
<span className="material-icons" style={{ fontSize: 64 }}>
|
||||
event_busy
|
||||
</span>
|
||||
</Box>
|
||||
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
|
||||
진행 중인 이벤트가 없습니다
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<Add />}
|
||||
onClick={handleCreateEvent}
|
||||
sx={{
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5568d3 0%, #65408b 100%)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
새 이벤트 만들기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{activeEvents.map((event) => (
|
||||
<Card
|
||||
key={event.id}
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => handleEventClick(event.id)}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{event.title}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.5,
|
||||
bgcolor: 'success.main',
|
||||
color: 'white',
|
||||
borderRadius: 2,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{event.status}
|
||||
</Box>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5, fontWeight: 500 }}>
|
||||
📅 {event.startDate} ~ {event.endDate}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 5 }}>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||
참여자
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'text.primary' }}>
|
||||
{event.participants.toLocaleString()}명
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'success.main' }}>
|
||||
{event.roi}%
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Box sx={{ mb: 5 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3, color: 'text.primary' }}>
|
||||
최근 활동
|
||||
</Typography>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
{mockActivities.map((activity, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 2.5,
|
||||
pt: index > 0 ? 3 : 0,
|
||||
mt: index > 0 ? 3 : 0,
|
||||
borderTop: index > 0 ? 1 : 0,
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'primary.main',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<activity.icon sx={{ fontSize: 20, color: 'white' }} />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'text.primary', mb: 0.5 }}>
|
||||
{activity.text}
|
||||
</Typography>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 500 }}>
|
||||
{activity.time}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
{/* Floating Action Button */}
|
||||
<Fab
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: 80,
|
||||
right: 16,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5568d3 0%, #65408b 100%)',
|
||||
},
|
||||
}}
|
||||
onClick={handleCreateEvent}
|
||||
>
|
||||
<Add sx={{ color: 'white' }} />
|
||||
</Fab>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user