event 기능 추가

This commit is contained in:
cherry2250 2025-10-24 15:08:50 +09:00
parent 4df7ba0697
commit 01d91e194a
10 changed files with 2792 additions and 334 deletions

View File

@ -1,6 +1,6 @@
'use client';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { z } from 'zod';
import {
Box,
@ -18,24 +18,27 @@ 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({
email: z.string().email('올바른 이메일 형식이 아닙니다'),
password: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: '비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
});
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, {
message: '비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
});
const step2Schema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
@ -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,326 +239,424 @@ export default function RegisterPage() {
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
justifyContent: 'center',
alignItems: 'center',
px: 3,
py: 8,
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
}}
>
{/* Header */}
<Box
<Paper
elevation={0}
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
width: '100%',
maxWidth: 440,
p: { xs: 4, sm: 6 },
borderRadius: 3,
boxShadow: '0 8px 32px rgba(0,0,0,0.08)',
}}
>
<IconButton onClick={handleBack} edge="start">
<ArrowBack />
</IconButton>
<Typography variant="h6" sx={{ ml: 2, fontWeight: 600 }}>
</Typography>
</Box>
{/* 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={{ fontWeight: 600 }}>
</Typography>
</Box>
{/* Progress Bar */}
<Box sx={{ px: 3, pt: 3, pb: 2 }}>
<LinearProgress variant="determinate" value={progress} sx={{ height: 6, borderRadius: 3 }} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, textAlign: 'center' }}>
{stepIndex}/3
</Typography>
</Box>
{/* Progress Bar */}
<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' }}>
{currentStep}/3
</Typography>
</Box>
{/* Funnel Content */}
<Box sx={{ flex: 1, px: 3, py: 4 }}>
{currentStep === '계정정보' && (
<Paper elevation={0} sx={{ p: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
{/* Step 1: 계정정보 */}
{currentStep === 1 && (
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
fullWidth
label="이메일"
type="email"
placeholder="example@email.com"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
error={!!errors.email}
helperText={errors.email || '이메일 주소로 로그인합니다'}
required
/>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
fullWidth
label="이메일"
type="email"
placeholder="example@email.com"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
error={!!errors.email}
helperText={errors.email || '이메일 주소로 로그인합니다'}
required
/>
<TextField
fullWidth
label="비밀번호"
type={showPassword ? 'text' : 'password'}
placeholder="8자 이상, 영문+숫자 조합"
value={formData.password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
error={!!errors.password}
helperText={errors.password}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="비밀번호"
type={showPassword ? 'text' : 'password'}
placeholder="8자 이상, 영문+숫자 조합"
value={formData.password || ''}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
error={!!errors.password}
helperText={errors.password}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="비밀번호 확인"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="비밀번호를 다시 입력하세요"
value={formData.confirmPassword || ''}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowConfirmPassword(!showConfirmPassword)} edge="end">
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
label="비밀번호 확인"
type={showConfirmPassword ? 'text' : 'password'}
placeholder="비밀번호를 다시 입력하세요"
value={formData.confirmPassword || ''}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleNext}
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>
)}
{/* Step 2: 개인정보 */}
{currentStep === 2 && (
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
fullWidth
label="이름"
placeholder="홍길동"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
helperText={errors.name}
required
/>
<TextField
fullWidth
label="휴대폰 번호"
placeholder="010-1234-5678"
value={formData.phone || ''}
onChange={(e) => {
const formatted = formatPhoneNumber(e.target.value);
setFormData({ ...formData, phone: formatted });
}}
error={!!errors.phone}
helperText={errors.phone}
required
/>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
size="large"
onClick={handleBack}
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleNext}
sx={{ mt: 2, py: 1.5 }}
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
>
</Button>
</Box>
</Paper>
</Box>
</Box>
)}
{currentStep === '개인정보' && (
<Paper elevation={0} sx={{ p: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
{/* Step 3: 사업장정보 */}
{currentStep === 3 && (
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
fullWidth
label="상호명"
placeholder="홍길동 고깃집"
value={formData.businessName || ''}
onChange={(e) => setFormData({ ...formData, businessName: e.target.value })}
error={!!errors.businessName}
helperText={errors.businessName}
required
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
fullWidth
label="이름"
placeholder="홍길동"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
error={!!errors.name}
helperText={errors.name}
required
/>
<TextField
fullWidth
label="휴대폰 번호"
placeholder="010-1234-5678"
value={formData.phone || ''}
label="사업자 번호"
placeholder="123-45-67890"
value={formData.businessNumber || ''}
onChange={(e) => {
const formatted = formatPhoneNumber(e.target.value);
setFormData({ ...formData, phone: formatted });
const formatted = formatBusinessNumber(e.target.value);
setFormData({ ...formData, businessNumber: formatted });
setIsBusinessNumberVerified(false);
}}
error={!!errors.phone}
helperText={errors.phone}
error={!!errors.businessNumber}
helperText={
errors.businessNumber ||
(isBusinessNumberVerified
? '✓ 인증 완료'
: '사업자 번호를 입력하고 인증해주세요')
}
required
sx={{
'& .MuiFormHelperText-root': {
color: isBusinessNumberVerified ? 'success.main' : undefined,
},
}}
/>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
size="large"
onClick={handleBack}
sx={{ flex: 1, py: 1.5 }}
>
</Button>
<Button
variant="contained"
size="large"
onClick={handleNext}
sx={{ flex: 1, py: 1.5 }}
>
</Button>
</Box>
<Button
variant="outlined"
size="large"
onClick={handleVerifyBusinessNumber}
disabled={isVerifyingBusinessNumber || isBusinessNumberVerified}
sx={{
minWidth: '90px',
py: 1.5,
fontSize: 14,
fontWeight: 600,
}}
>
{isVerifyingBusinessNumber
? '인증중'
: isBusinessNumberVerified
? '완료'
: '인증'}
</Button>
</Box>
</Paper>
)}
{currentStep === '사업장정보' && (
<Paper elevation={0} sx={{ p: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
</Typography>
<FormControl fullWidth required error={!!errors.businessType}>
<InputLabel></InputLabel>
<Select
value={formData.businessType || ''}
onChange={(e) => setFormData({ ...formData, businessType: e.target.value })}
label="업종"
>
<MenuItem value=""> </MenuItem>
<MenuItem value="restaurant"></MenuItem>
<MenuItem value="cafe">/</MenuItem>
<MenuItem value="retail">/</MenuItem>
<MenuItem value="beauty">/</MenuItem>
<MenuItem value="fitness">/</MenuItem>
<MenuItem value="education">/</MenuItem>
<MenuItem value="service"></MenuItem>
<MenuItem value="other"></MenuItem>
</Select>
{errors.businessType && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 2 }}>
{errors.businessType}
</Typography>
)}
</FormControl>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
fullWidth
label="상호명"
placeholder="홍길동 고깃집"
value={formData.businessName || ''}
onChange={(e) => setFormData({ ...formData, businessName: e.target.value })}
error={!!errors.businessName}
helperText={errors.businessName}
required
/>
<TextField
fullWidth
label="주요 지역"
placeholder="예: 강남구"
value={formData.businessLocation || ''}
onChange={(e) => setFormData({ ...formData, businessLocation: e.target.value })}
helperText="선택 사항입니다"
/>
<FormControl fullWidth required error={!!errors.businessType}>
<InputLabel></InputLabel>
<Select
value={formData.businessType || ''}
onChange={(e) => setFormData({ ...formData, businessType: e.target.value })}
label="업종"
>
<MenuItem value=""> </MenuItem>
<MenuItem value="restaurant"></MenuItem>
<MenuItem value="cafe">/</MenuItem>
<MenuItem value="retail">/</MenuItem>
<MenuItem value="beauty">/</MenuItem>
<MenuItem value="fitness">/</MenuItem>
<MenuItem value="education">/</MenuItem>
<MenuItem value="service"></MenuItem>
<MenuItem value="other"></MenuItem>
</Select>
{errors.businessType && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 2 }}>
{errors.businessType}
</Typography>
)}
</FormControl>
<TextField
fullWidth
label="주요 지역"
placeholder="예: 강남구"
value={formData.businessLocation || ''}
onChange={(e) => setFormData({ ...formData, businessLocation: e.target.value })}
helperText="선택 사항입니다"
/>
<Box sx={{ mt: 2 }}>
<FormGroup>
<Box sx={{ mt: 1 }}>
<FormGroup>
<FormControlLabel
control={
<Checkbox
checked={formData.agreeTerms === true && formData.agreePrivacy === true}
onChange={(e) => {
const checked = e.target.checked;
setFormData({
...formData,
agreeTerms: checked,
agreePrivacy: checked,
agreeMarketing: checked,
});
}}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: 600 }}>
</Typography>
}
/>
<Box sx={{ ml: 3, mt: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<FormControlLabel
control={
<Checkbox
checked={formData.agreeTerms === true && formData.agreePrivacy === true}
onChange={(e) => {
const checked = e.target.checked;
setFormData({
...formData,
agreeTerms: checked,
agreePrivacy: checked,
agreeMarketing: checked,
});
}}
checked={formData.agreeTerms || false}
onChange={(e) =>
setFormData({ ...formData, agreeTerms: e.target.checked })
}
/>
}
label={
<Typography variant="body2" sx={{ fontWeight: 600 }}>
<Typography
variant="body2"
color={errors.agreeTerms ? 'error' : 'text.primary'}
>
[]
</Typography>
}
/>
<Box sx={{ ml: 3, mt: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
<FormControlLabel
control={
<Checkbox
checked={formData.agreeTerms || false}
onChange={(e) =>
setFormData({ ...formData, agreeTerms: e.target.checked })
}
/>
}
label={
<Typography variant="body2" color={errors.agreeTerms ? 'error' : 'text.primary'}>
[]
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={formData.agreePrivacy || false}
onChange={(e) =>
setFormData({ ...formData, agreePrivacy: e.target.checked })
}
/>
}
label={
<Typography variant="body2" color={errors.agreePrivacy ? 'error' : 'text.primary'}>
[]
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={formData.agreeMarketing || false}
onChange={(e) =>
setFormData({ ...formData, agreeMarketing: e.target.checked })
}
/>
}
label={
<Typography variant="body2">
[]
</Typography>
}
/>
</Box>
</FormGroup>
{(errors.agreeTerms || errors.agreePrivacy) && (
<Typography variant="caption" color="error" sx={{ mt: 1, display: 'block' }}>
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
size="large"
onClick={handleBack}
sx={{ flex: 1, py: 1.5 }}
<FormControlLabel
control={
<Checkbox
checked={formData.agreePrivacy || false}
onChange={(e) =>
setFormData({ ...formData, agreePrivacy: e.target.checked })
}
/>
}
label={
<Typography
variant="body2"
color={errors.agreePrivacy ? 'error' : 'text.primary'}
>
[]
</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={formData.agreeMarketing || false}
onChange={(e) =>
setFormData({ ...formData, agreeMarketing: e.target.checked })
}
/>
}
label={<Typography variant="body2">[] </Typography>}
/>
</Box>
</FormGroup>
{(errors.agreeTerms || errors.agreePrivacy) && (
<Typography
variant="caption"
color="error"
sx={{ mt: 1, display: 'block', ml: 2 }}
>
</Button>
<Button
variant="contained"
size="large"
onClick={handleNext}
sx={{ flex: 1, py: 1.5 }}
>
</Button>
</Box>
</Typography>
)}
</Box>
</Paper>
<Box sx={{ display: 'flex', gap: 2, mt: 2 }}>
<Button
variant="outlined"
size="large"
onClick={handleBack}
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
>
</Button>
<Button
variant="contained"
size="large"
onClick={handleNext}
sx={{ flex: 1, py: 1.5, fontSize: 16, fontWeight: 600 }}
>
</Button>
</Box>
</Box>
</Box>
)}
</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>
);
}

View 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);
}}
/>
)}
/>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -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);

View File

@ -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
<Box
sx={{
pb: 10,
bgcolor: 'background.default',
minHeight: '100vh',
}}
>
<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="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={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
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}
>
<Typography variant="h1" gutterBottom color="primary">
KT AI
</Typography>
<Typography variant="h4" color="text.secondary" gutterBottom>
</Typography>
<Typography variant="body1" sx={{ mt: 2, maxWidth: 600 }}>
.
<br />
.
</Typography>
</Box>
</Container>
<Add sx={{ color: 'white' }} />
</Fab>
</Box>
);
}