인증 영역 개발 완료 (로그인, 회원가입, 프로필 관리)

- 로그인 페이지: 이메일 + 비밀번호 로그인, 소셜 로그인 버튼
- 회원가입 페이지: 3단계 funnel (계정정보, 개인정보, 사업장정보)
- 프로필 관리 페이지: 기본정보/매장정보 수정, 비밀번호 변경, 로그아웃
- MUI v6 + React Hook Form + Zod 검증
- Next.js 14 App Router, TypeScript 5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-24 11:27:15 +09:00
parent ca4dff559c
commit 4df7ba0697
23 changed files with 8921 additions and 0 deletions

11
.env.example Normal file
View File

@ -0,0 +1,11 @@
# API Hosts
NEXT_PUBLIC_USER_HOST=http://localhost:8081
NEXT_PUBLIC_EVENT_HOST=http://localhost:8080
NEXT_PUBLIC_CONTENT_HOST=http://localhost:8082
NEXT_PUBLIC_AI_HOST=http://localhost:8083
NEXT_PUBLIC_PARTICIPATION_HOST=http://localhost:8084
NEXT_PUBLIC_DISTRIBUTION_HOST=http://localhost:8085
NEXT_PUBLIC_ANALYTICS_HOST=http://localhost:8086
# API Version
NEXT_PUBLIC_API_VERSION=v1

12
.eslintrc.json Normal file
View File

@ -0,0 +1,12 @@
{
"extends": [
"next/core-web-vitals",
"next/typescript"
],
"rules": {
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

43
.gitignore vendored Normal file
View File

@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# playwright
.playwright-mcp/
# claude
claude/

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"
}

17
next.config.js Normal file
View File

@ -0,0 +1,17 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
compiler: {
emotion: true,
},
images: {
domains: ['localhost'],
formats: ['image/webp', 'image/avif'],
},
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
},
}
module.exports = nextConfig

6652
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "kt-event-marketing-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@emotion/react": "^11.13.3",
"@emotion/styled": "^11.13.0",
"@hookform/resolvers": "^5.2.2",
"@mui/icons-material": "^6.1.6",
"@mui/material": "^6.1.6",
"@mui/material-nextjs": "^7.3.3",
"@tanstack/react-query": "^5.59.16",
"@use-funnel/browser": "^0.0.12",
"@use-funnel/next": "^0.0.12",
"axios": "^1.7.7",
"chart.js": "^4.4.6",
"dayjs": "^1.11.13",
"next": "^14.2.15",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-hook-form": "^7.53.0",
"zod": "^3.23.8",
"zustand": "^4.5.5"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@types/node": "^22.7.5",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^8.8.1",
"@typescript-eslint/parser": "^8.8.1",
"eslint": "^8.57.1",
"eslint-config-next": "^14.2.15",
"prettier": "^3.3.3",
"typescript": "^5.6.3"
}
}

13
public/runtime-env.js Normal file
View File

@ -0,0 +1,13 @@
// 런타임 환경 설정 (배포 시 동적으로 주입 가능)
window.__runtime_config__ = {
API_GROUP: "/api/v1",
// 7개 마이크로서비스 호스트
USER_HOST: process.env.NEXT_PUBLIC_USER_HOST || "http://localhost:8081",
EVENT_HOST: process.env.NEXT_PUBLIC_EVENT_HOST || "http://localhost:8080",
CONTENT_HOST: process.env.NEXT_PUBLIC_CONTENT_HOST || "http://localhost:8082",
AI_HOST: process.env.NEXT_PUBLIC_AI_HOST || "http://localhost:8083",
PARTICIPATION_HOST: process.env.NEXT_PUBLIC_PARTICIPATION_HOST || "http://localhost:8084",
DISTRIBUTION_HOST: process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || "http://localhost:8085",
ANALYTICS_HOST: process.env.NEXT_PUBLIC_ANALYTICS_HOST || "http://localhost:8086",
};

View File

@ -0,0 +1,346 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Box,
TextField,
Button,
Checkbox,
FormControlLabel,
Typography,
Link,
Divider,
Paper,
InputAdornment,
IconButton,
} from '@mui/material';
import { Visibility, VisibilityOff, Email, Lock, ChatBubble } from '@mui/icons-material';
import { useAuthStore } from '@/stores/authStore';
import { useUIStore } from '@/stores/uiStore';
// 유효성 검사 스키마
const loginSchema = z.object({
email: z
.string()
.min(1, '이메일을 입력해주세요')
.email('올바른 이메일 형식이 아닙니다'),
password: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'),
rememberMe: z.boolean().optional(),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const { login } = useAuthStore();
const { showToast, setLoading } = useUIStore();
const [showPassword, setShowPassword] = useState(false);
const {
control,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
rememberMe: false,
},
});
const onSubmit = async (data: LoginFormData) => {
try {
setLoading(true);
// TODO: API 연동 시 실제 로그인 처리
// const response = await axios.post(`${USER_HOST}/api/v1/auth/login`, {
// email: data.email,
// password: data.password,
// });
// 임시 로그인 처리 (API 연동 전)
await new Promise(resolve => setTimeout(resolve, 1000));
const mockUser = {
id: '1',
name: '홍길동',
phone: '010-1234-5678',
email: data.email,
businessName: '홍길동 고깃집',
businessType: 'restaurant',
};
login(mockUser, 'mock-jwt-token');
showToast('로그인되었습니다', 'success');
router.push('/');
} catch {
showToast('로그인에 실패했습니다. 이메일과 비밀번호를 확인해주세요.', 'error');
} finally {
setLoading(false);
}
};
const handleSocialLogin = (provider: 'kakao' | 'naver') => {
// TODO: 소셜 로그인 구현
showToast(`${provider === 'kakao' ? '카카오톡' : '네이버'} 로그인은 준비 중입니다`, 'info');
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
px: 3,
py: 8,
background: 'linear-gradient(135deg, #FFF 0%, #F5F5F5 100%)',
}}
>
<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)',
}}
>
{/* 로고 및 타이틀 */}
<Box sx={{ textAlign: 'center', mb: 6 }}>
<Box
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 64,
height: 64,
borderRadius: '50%',
bgcolor: 'primary.main',
mb: 3,
}}
>
<Typography sx={{ fontSize: 32 }}>🎉</Typography>
</Box>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
KT AI
</Typography>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
{/* 로그인 폼 */}
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ mb: 3 }}>
<Controller
name="email"
control={control}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="이메일"
type="email"
placeholder="example@email.com"
error={!!errors.email}
helperText={errors.email?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email color="action" />
</InputAdornment>
),
}}
/>
)}
/>
</Box>
<Box sx={{ mb: 2 }}>
<Controller
name="password"
control={control}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showPassword ? 'text' : 'password'}
label="비밀번호"
placeholder="8자 이상 입력하세요"
error={!!errors.password}
helperText={errors.password?.message}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
size="small"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
</Box>
<Box sx={{ mb: 3 }}>
<Controller
name="rememberMe"
control={control}
render={({ field }) => (
<FormControlLabel
control={<Checkbox {...field} checked={field.value} />}
label={
<Typography variant="body2" color="text.secondary">
</Typography>
}
/>
)}
/>
</Box>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
sx={{
mb: 2,
py: 1.5,
fontSize: 16,
fontWeight: 600,
}}
>
</Button>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 2, mb: 4 }}>
<Link
href="/forgot-password"
variant="body2"
color="text.secondary"
underline="hover"
sx={{ cursor: 'pointer' }}
>
</Link>
<Typography variant="body2" color="text.secondary">
|
</Typography>
<Link
href="/register"
variant="body2"
color="primary"
underline="hover"
sx={{ cursor: 'pointer', fontWeight: 600 }}
>
</Link>
</Box>
</form>
{/* 소셜 로그인 */}
<Divider sx={{ mb: 3 }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Divider>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mb: 3 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={() => handleSocialLogin('kakao')}
sx={{
py: 1.5,
borderColor: '#FEE500',
bgcolor: '#FEE500',
color: '#000000',
fontWeight: 600,
'&:hover': {
bgcolor: '#FDD835',
borderColor: '#FDD835',
},
}}
startIcon={<ChatBubble />}
>
</Button>
<Button
fullWidth
variant="outlined"
size="large"
onClick={() => handleSocialLogin('naver')}
sx={{
py: 1.5,
borderColor: '#03C75A',
bgcolor: '#03C75A',
color: '#FFFFFF',
fontWeight: 600,
'&:hover': {
bgcolor: '#02B350',
borderColor: '#02B350',
},
}}
startIcon={
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
bgcolor: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
color: '#03C75A',
fontSize: 14,
}}
>
N
</Box>
}
>
</Button>
</Box>
{/* 약관 동의 안내 */}
<Typography variant="caption" color="text.secondary" sx={{ textAlign: 'center', display: 'block' }}>
{' '}
<Link href="/terms" underline="hover" sx={{ color: 'text.secondary' }}>
</Link>{' '}
{' '}
<Link href="/privacy" underline="hover" sx={{ color: 'text.secondary' }}>
</Link>
.
</Typography>
</Paper>
</Box>
);
}

View File

@ -0,0 +1,504 @@
'use client';
import { useRouter } from 'next/navigation';
import { z } from 'zod';
import {
Box,
TextField,
Button,
Typography,
Paper,
LinearProgress,
IconButton,
InputAdornment,
Select,
MenuItem,
FormControl,
InputLabel,
Checkbox,
FormControlLabel,
FormGroup,
} from '@mui/material';
import { ArrowBack, Visibility, VisibilityOff } from '@mui/icons-material';
import { useState } 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 step2Schema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
phone: z
.string()
.min(1, '휴대폰 번호를 입력해주세요')
.regex(/^010-\d{4}-\d{4}$/, '올바른 휴대폰 번호 형식이 아닙니다 (010-1234-5678)'),
});
const step3Schema = z.object({
businessName: z.string().min(2, '상호명은 2자 이상이어야 합니다'),
businessType: z.string().min(1, '업종을 선택해주세요'),
businessLocation: z.string().optional(),
agreeTerms: z.boolean().refine((val) => val === true, {
message: '이용약관에 동의해주세요',
}),
agreePrivacy: z.boolean().refine((val) => val === true, {
message: '개인정보 처리방침에 동의해주세요',
}),
agreeMarketing: z.boolean().optional(),
});
type Step1Data = z.infer<typeof step1Schema>;
type Step2Data = z.infer<typeof step2Schema>;
type Step3Data = z.infer<typeof step3Schema>;
type RegisterData = Step1Data & Step2Data & Step3Data;
type Step = '계정정보' | '개인정보' | '사업장정보';
export default function RegisterPage() {
const router = useRouter();
const { showToast, setLoading } = useUIStore();
const { login } = useAuthStore();
const [currentStep, setCurrentStep] = useState<Step>('계정정보');
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 formatPhoneNumber = (value: string) => {
const numbers = value.replace(/[^\d]/g, '');
if (numbers.length <= 3) return numbers;
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
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 validateStep = (step: Step) => {
setErrors({});
try {
if (step === '계정정보') {
step1Schema.parse(formData);
} else if (step === '개인정보') {
step2Schema.parse(formData);
} else if (step === '사업장정보') {
step3Schema.parse(formData);
}
return true;
} catch (error) {
if (error instanceof z.ZodError) {
const newErrors: Record<string, string> = {};
error.errors.forEach((err) => {
if (err.path[0]) {
newErrors[err.path[0].toString()] = err.message;
}
});
setErrors(newErrors);
}
return false;
}
};
const handleNext = () => {
if (!validateStep(currentStep)) {
return;
}
if (currentStep === '계정정보') {
setCurrentStep('개인정보');
} else if (currentStep === '개인정보') {
setCurrentStep('사업장정보');
} else if (currentStep === '사업장정보') {
handleSubmit();
}
};
const handleBack = () => {
if (currentStep === '개인정보') {
setCurrentStep('계정정보');
} else if (currentStep === '사업장정보') {
setCurrentStep('개인정보');
} else {
router.back();
}
};
const handleSubmit = async () => {
if (!validateStep('사업장정보')) {
return;
}
try {
setLoading(true);
// TODO: API 연동 시 실제 회원가입 처리
// const response = await axios.post(`${USER_HOST}/api/v1/auth/register`, formData);
// 임시 처리
await new Promise(resolve => setTimeout(resolve, 1000));
const mockUser = {
id: '1',
name: formData.name!,
phone: formData.phone!,
email: formData.email!,
businessName: formData.businessName!,
businessType: formData.businessType!,
};
login(mockUser, 'mock-jwt-token');
showToast('회원가입이 완료되었습니다!', 'success');
router.push('/');
} catch {
showToast('회원가입에 실패했습니다. 다시 시도해주세요.', 'error');
} finally {
setLoading(false);
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
bgcolor: 'background.default',
}}
>
{/* Header */}
<Box
sx={{
p: 2,
display: 'flex',
alignItems: 'center',
borderBottom: 1,
borderColor: 'divider',
bgcolor: 'background.paper',
}}
>
<IconButton onClick={handleBack} edge="start">
<ArrowBack />
</IconButton>
<Typography variant="h6" sx={{ ml: 2, 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>
{/* 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>
<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={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 }}
>
</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>
<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 }}
>
</Button>
<Button
variant="contained"
size="large"
onClick={handleNext}
sx={{ flex: 1, py: 1.5 }}
>
</Button>
</Box>
</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>
<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
/>
<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>
<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: 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 }}
>
</Button>
<Button
variant="contained"
size="large"
onClick={handleNext}
sx={{ flex: 1, py: 1.5 }}
>
</Button>
</Box>
</Box>
</Paper>
)}
</Box>
</Box>
);
}

View File

@ -0,0 +1,505 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Box,
TextField,
Button,
Typography,
Paper,
Avatar,
Select,
MenuItem,
FormControl,
InputLabel,
InputAdornment,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import { Person, Visibility, VisibilityOff, CheckCircle } from '@mui/icons-material';
import { useAuthStore } from '@/stores/authStore';
import { useUIStore } from '@/stores/uiStore';
// 기본 정보 스키마
const basicInfoSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
phone: z
.string()
.min(1, '휴대폰 번호를 입력해주세요')
.regex(/^010-\d{4}-\d{4}$/, '올바른 휴대폰 번호 형식이 아닙니다'),
email: z.string().email('올바른 이메일 형식이 아닙니다'),
});
// 사업장 정보 스키마
const businessInfoSchema = z.object({
businessName: z.string().min(2, '매장명은 2자 이상이어야 합니다'),
businessType: z.string().min(1, '업종을 선택해주세요'),
businessLocation: z.string().optional(),
businessHours: z.string().optional(),
});
// 비밀번호 변경 스키마
const passwordSchema = z
.object({
currentPassword: z.string().min(1, '현재 비밀번호를 입력해주세요'),
newPassword: z
.string()
.min(8, '비밀번호는 8자 이상이어야 합니다')
.regex(/^(?=.*[A-Za-z])(?=.*\d)/, '영문과 숫자를 포함해야 합니다'),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
message: '새 비밀번호가 일치하지 않습니다',
path: ['confirmPassword'],
});
type BasicInfoData = z.infer<typeof basicInfoSchema>;
type BusinessInfoData = z.infer<typeof businessInfoSchema>;
type PasswordData = z.infer<typeof passwordSchema>;
export default function ProfilePage() {
const router = useRouter();
const { user, logout, setUser } = useAuthStore();
const { showToast, setLoading } = useUIStore();
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false);
// 기본 정보 폼
const {
control: basicControl,
handleSubmit: handleBasicSubmit,
formState: { errors: basicErrors },
} = useForm<BasicInfoData>({
resolver: zodResolver(basicInfoSchema),
defaultValues: {
name: user?.name || '',
phone: user?.phone || '',
email: user?.email || '',
},
});
// 사업장 정보 폼
const {
control: businessControl,
handleSubmit: handleBusinessSubmit,
formState: { errors: businessErrors },
} = useForm<BusinessInfoData>({
resolver: zodResolver(businessInfoSchema),
defaultValues: {
businessName: user?.businessName || '',
businessType: user?.businessType || '',
businessLocation: '',
businessHours: '',
},
});
// 비밀번호 변경 폼
const {
control: passwordControl,
handleSubmit: handlePasswordSubmit,
formState: { errors: passwordErrors },
reset: resetPassword,
} = useForm<PasswordData>({
resolver: zodResolver(passwordSchema),
defaultValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
});
const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/[^\d]/g, '');
if (numbers.length <= 3) return numbers;
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
};
const onSaveProfile = async (data: BasicInfoData & BusinessInfoData) => {
try {
setLoading(true);
// TODO: API 연동 시 실제 프로필 업데이트
// await axios.put(`${USER_HOST}/api/v1/users/profile`, data);
await new Promise(resolve => setTimeout(resolve, 1000));
if (user) {
setUser({
...user,
...data,
});
}
setSuccessDialogOpen(true);
} catch {
showToast('프로필 저장에 실패했습니다', 'error');
} finally {
setLoading(false);
}
};
const onChangePassword = async (_data: PasswordData) => {
try {
setLoading(true);
// TODO: API 연동 시 실제 비밀번호 변경
// await axios.put(`${USER_HOST}/api/v1/users/password`, {
// currentPassword: _data.currentPassword,
// newPassword: _data.newPassword,
// });
await new Promise(resolve => setTimeout(resolve, 1000));
showToast('비밀번호가 변경되었습니다', 'success');
resetPassword();
} catch {
showToast('비밀번호 변경에 실패했습니다', 'error');
} finally {
setLoading(false);
}
};
const handleSave = () => {
handleBasicSubmit((basicData) => {
handleBusinessSubmit((businessData) => {
onSaveProfile({ ...basicData, ...businessData });
})();
})();
};
const handleLogout = () => {
logout();
router.push('/login');
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Box sx={{ maxWidth: 600, mx: 'auto', px: 3, py: 4 }}>
{/* 사용자 정보 섹션 */}
<Paper elevation={0} sx={{ p: 4, mb: 3, textAlign: 'center' }}>
<Avatar
sx={{
width: 80,
height: 80,
mx: 'auto',
mb: 2,
bgcolor: 'grey.100',
color: 'grey.400',
}}
>
<Person sx={{ fontSize: 48 }} />
</Avatar>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 0.5 }}>
{user?.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{user?.email}
</Typography>
</Paper>
{/* 기본 정보 */}
<Paper elevation={0} sx={{ p: 4, mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Controller
name="name"
control={basicControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="이름"
error={!!basicErrors.name}
helperText={basicErrors.name?.message}
/>
)}
/>
<Controller
name="phone"
control={basicControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="전화번호"
onChange={(e) => {
const formatted = formatPhoneNumber(e.target.value);
field.onChange(formatted);
}}
error={!!basicErrors.phone}
helperText={basicErrors.phone?.message}
/>
)}
/>
<Controller
name="email"
control={basicControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="이메일"
type="email"
error={!!basicErrors.email}
helperText={basicErrors.email?.message}
/>
)}
/>
</Box>
</Paper>
{/* 매장 정보 */}
<Paper elevation={0} sx={{ p: 4, mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Controller
name="businessName"
control={businessControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="매장명"
error={!!businessErrors.businessName}
helperText={businessErrors.businessName?.message}
/>
)}
/>
<Controller
name="businessType"
control={businessControl}
render={({ field }) => (
<FormControl fullWidth error={!!businessErrors.businessType}>
<InputLabel></InputLabel>
<Select {...field} label="업종">
<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>
{businessErrors.businessType && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 2 }}>
{businessErrors.businessType.message}
</Typography>
)}
</FormControl>
)}
/>
<Controller
name="businessLocation"
control={businessControl}
render={({ field }) => (
<TextField {...field} fullWidth label="주소" />
)}
/>
<Controller
name="businessHours"
control={businessControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
label="영업시간"
placeholder="예: 10:00 ~ 22:00"
/>
)}
/>
</Box>
</Paper>
{/* 비밀번호 변경 */}
<Paper elevation={0} sx={{ p: 4, mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<Controller
name="currentPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showCurrentPassword ? 'text' : 'password'}
label="현재 비밀번호"
placeholder="현재 비밀번호를 입력하세요"
error={!!passwordErrors.currentPassword}
helperText={passwordErrors.currentPassword?.message}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
edge="end"
>
{showCurrentPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="newPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showNewPassword ? 'text' : 'password'}
label="새 비밀번호"
placeholder="새 비밀번호를 입력하세요"
error={!!passwordErrors.newPassword}
helperText={passwordErrors.newPassword?.message || '8자 이상, 영문과 숫자를 포함해주세요'}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowNewPassword(!showNewPassword)}
edge="end"
>
{showNewPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Controller
name="confirmPassword"
control={passwordControl}
render={({ field }) => (
<TextField
{...field}
fullWidth
type={showConfirmPassword ? 'text' : 'password'}
label="비밀번호 확인"
placeholder="비밀번호를 다시 입력하세요"
error={!!passwordErrors.confirmPassword}
helperText={passwordErrors.confirmPassword?.message}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
)}
/>
<Button
fullWidth
variant="outlined"
size="large"
onClick={handlePasswordSubmit(onChangePassword)}
sx={{ mt: 1 }}
>
</Button>
</Box>
</Paper>
{/* 액션 버튼 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleSave}
sx={{ py: 1.5 }}
>
</Button>
<Button
fullWidth
variant="text"
size="large"
color="error"
onClick={() => setLogoutDialogOpen(true)}
sx={{ py: 1.5 }}
>
</Button>
</Box>
</Box>
{/* 저장 완료 다이얼로그 */}
<Dialog open={successDialogOpen} onClose={() => setSuccessDialogOpen(false)}>
<DialogContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<CheckCircle sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
</Typography>
<Typography variant="body2" color="text.secondary">
.
</Typography>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', pb: 3 }}>
<Button
variant="contained"
onClick={() => {
setSuccessDialogOpen(false);
window.location.reload();
}}
sx={{ minWidth: 120 }}
>
</Button>
</DialogActions>
</Dialog>
{/* 로그아웃 확인 다이얼로그 */}
<Dialog open={logoutDialogOpen} onClose={() => setLogoutDialogOpen(false)}>
<DialogTitle></DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ textAlign: 'center', py: 2 }}>
?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setLogoutDialogOpen(false)}></Button>
<Button variant="contained" onClick={handleLogout}>
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

44
src/app/layout.tsx Normal file
View File

@ -0,0 +1,44 @@
import type { Metadata, Viewport } from 'next';
import { MUIThemeProvider } from '@/lib/theme-provider';
import { ReactQueryProvider } from '@/lib/react-query-provider';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: 'KT AI 이벤트 마케팅',
description: '소상공인을 위한 AI 기반 이벤트 자동 생성 서비스',
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
themeColor: '#E31E24',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/icon?family=Material+Icons"
/>
</head>
<body>
<MUIThemeProvider>
<ReactQueryProvider>
{children}
</ReactQueryProvider>
</MUIThemeProvider>
</body>
</html>
);
}

30
src/app/page.tsx Normal file
View File

@ -0,0 +1,30 @@
import { Box, Container, Typography } from '@mui/material';
export default function HomePage() {
return (
<Container maxWidth="lg">
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
}}
>
<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>
);
}

View File

@ -0,0 +1,28 @@
import { Box, CircularProgress, Typography } from '@mui/material';
interface LoadingProps {
message?: string;
size?: number;
}
export function Loading({ message = '로딩 중...', size = 40 }: LoadingProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
gap: 2,
}}
>
<CircularProgress size={size} />
{message && (
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
)}
</Box>
);
}

View File

@ -0,0 +1,27 @@
'use client';
import { Snackbar, Alert } from '@mui/material';
import { useUIStore } from '@/stores/uiStore';
export function Toast() {
const { toast, hideToast } = useUIStore();
return (
<Snackbar
open={toast.open}
autoHideDuration={3000}
onClose={hideToast}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
sx={{ bottom: { xs: 80, sm: 24 } }}
>
<Alert
onClose={hideToast}
severity={toast.severity}
variant="filled"
sx={{ width: '100%' }}
>
{toast.message}
</Alert>
</Snackbar>
);
}

View File

@ -0,0 +1,54 @@
'use client';
import { usePathname, useRouter } from 'next/navigation';
import { BottomNavigation, BottomNavigationAction, Paper } from '@mui/material';
import HomeIcon from '@mui/icons-material/Home';
import EventIcon from '@mui/icons-material/Event';
import BarChartIcon from '@mui/icons-material/BarChart';
import PersonIcon from '@mui/icons-material/Person';
export function BottomNav() {
const pathname = usePathname();
const router = useRouter();
const navItems = [
{ label: '홈', icon: <HomeIcon />, value: '/' },
{ label: '이벤트', icon: <EventIcon />, value: '/events' },
{ label: '분석', icon: <BarChartIcon />, value: '/analytics' },
{ label: '프로필', icon: <PersonIcon />, value: '/profile' },
];
const currentValue = navItems.find((item) => item.value === pathname)?.value || '/';
return (
<Paper
sx={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 1100,
display: { xs: 'block', md: 'none' },
}}
elevation={3}
>
<BottomNavigation
value={currentValue}
onChange={(_, newValue) => {
router.push(newValue);
}}
showLabels
sx={{ height: 60 }}
>
{navItems.map((item) => (
<BottomNavigationAction
key={item.value}
label={item.label}
icon={item.icon}
value={item.value}
/>
))}
</BottomNavigation>
</Paper>
);
}

View File

@ -0,0 +1,24 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode, useState } from 'react';
export function ReactQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 30 * 60 * 1000, // 30 minutes (formerly cacheTime)
refetchOnWindowFocus: true,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

View File

@ -0,0 +1,18 @@
'use client';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
import { theme } from '@/styles/theme';
import { ReactNode } from 'react';
export function MUIThemeProvider({ children }: { children: ReactNode }) {
return (
<AppRouterCacheProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
</AppRouterCacheProvider>
);
}

40
src/stores/authStore.ts Normal file
View File

@ -0,0 +1,40 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
name: string;
email: string;
phone: string;
businessName?: string;
businessType?: string;
}
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setUser: (user: User | null) => void;
setToken: (token: string | null) => void;
login: (user: User, token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setToken: (token) => set({ token }),
login: (user, token) =>
set({ user, token, isAuthenticated: true }),
logout: () =>
set({ user: null, token: null, isAuthenticated: false }),
}),
{
name: 'auth-storage',
}
)
);

31
src/stores/uiStore.ts Normal file
View File

@ -0,0 +1,31 @@
import { create } from 'zustand';
interface UIState {
isLoading: boolean;
isSidebarOpen: boolean;
toast: {
open: boolean;
message: string;
severity: 'success' | 'error' | 'warning' | 'info';
};
setLoading: (isLoading: boolean) => void;
toggleSidebar: () => void;
showToast: (message: string, severity?: 'success' | 'error' | 'warning' | 'info') => void;
hideToast: () => void;
}
export const useUIStore = create<UIState>((set) => ({
isLoading: false,
isSidebarOpen: false,
toast: {
open: false,
message: '',
severity: 'info',
},
setLoading: (isLoading) => set({ isLoading }),
toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
showToast: (message, severity = 'info') =>
set({ toast: { open: true, message, severity } }),
hideToast: () =>
set((state) => ({ toast: { ...state.toast, open: false } })),
}));

187
src/styles/globals.css Normal file
View File

@ -0,0 +1,187 @@
/* CSS Variables */
:root {
/* Primary Colors */
--color-primary-main: #E31E24;
--color-primary-light: #FF4D52;
--color-primary-dark: #C71820;
/* Secondary Colors */
--color-secondary-main: #0066FF;
--color-secondary-light: #4D94FF;
--color-secondary-dark: #004DBF;
/* Grayscale */
--color-gray-900: #1A1A1A;
--color-gray-700: #4A4A4A;
--color-gray-500: #9E9E9E;
--color-gray-300: #D9D9D9;
--color-gray-100: #F5F5F5;
--color-gray-50: #FFFFFF;
/* Semantic Colors */
--color-success: #00C853;
--color-warning: #FFA000;
--color-error: #D32F2F;
--color-info: #0288D1;
/* Spacing (4px Grid) */
--spacing-xs: 4px;
--spacing-s: 8px;
--spacing-m: 16px;
--spacing-l: 24px;
--spacing-xl: 32px;
--spacing-2xl: 48px;
/* Border Radius */
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--border-radius-xl: 16px;
--border-radius-full: 9999px;
/* Shadows */
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.08);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.12);
--shadow-xl: 0 8px 24px rgba(0, 0, 0, 0.15);
/* Z-Index */
--z-index-modal: 1300;
--z-index-drawer: 1200;
--z-index-app-bar: 1100;
--z-index-fab: 1050;
--z-index-sticky: 1020;
}
/* CSS Reset & Base Styles */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI",
"Roboto", "Helvetica Neue", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--color-gray-900);
background-color: var(--color-gray-50);
min-height: 100%;
overflow-x: hidden;
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 700;
}
p {
margin: 0;
}
a {
color: var(--color-primary-main);
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--color-primary-light);
}
/* Remove default button styles */
button {
font-family: inherit;
cursor: pointer;
}
/* Accessibility: Focus Indicators */
*:focus-visible {
outline: 2px solid var(--color-secondary-main);
outline-offset: 2px;
border-radius: var(--border-radius-sm);
}
/* Selection */
::selection {
background-color: var(--color-primary-light);
color: white;
}
/* Scrollbar (Webkit) */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-gray-100);
}
::-webkit-scrollbar-thumb {
background: var(--color-gray-300);
border-radius: var(--border-radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-gray-500);
}
/* Utility Classes */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
@media (min-width: 768px) {
.container {
padding: 0 40px;
}
}
@media (min-width: 1024px) {
.container {
padding: 0 80px;
}
}
/* Mobile First - Hide on Mobile */
.hide-mobile {
display: none;
}
@media (min-width: 768px) {
.hide-mobile {
display: block;
}
}
/* Hide on Desktop */
.hide-desktop {
display: block;
}
@media (min-width: 768px) {
.hide-desktop {
display: none;
}
}

253
src/styles/theme.ts Normal file
View File

@ -0,0 +1,253 @@
import { createTheme } from '@mui/material/styles';
// KT 브랜드 컬러 시스템
const colors = {
// Primary: KT Red
primary: {
main: '#E31E24',
light: '#FF4D52',
dark: '#C71820',
contrastText: '#FFFFFF',
},
// Secondary: AI Blue
secondary: {
main: '#0066FF',
light: '#4D94FF',
dark: '#004DBF',
contrastText: '#FFFFFF',
},
// Grayscale
gray: {
900: '#1A1A1A', // Black
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
50: '#FFFFFF', // White
},
// Semantic Colors
success: {
main: '#00C853',
},
warning: {
main: '#FFA000',
},
error: {
main: '#D32F2F',
},
info: {
main: '#0288D1',
},
};
// Breakpoints (Mobile First)
const breakpoints = {
values: {
xs: 320,
sm: 768,
md: 1024,
lg: 1280,
xl: 1920,
},
};
// Typography (Pretendard)
const typography = {
fontFamily: '"Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Helvetica Neue", system-ui, sans-serif',
// Display
h1: {
fontSize: '28px',
fontWeight: 700,
lineHeight: 1.3,
letterSpacing: '-0.5px',
'@media (min-width:768px)': {
fontSize: '32px',
},
'@media (min-width:1024px)': {
fontSize: '36px',
},
},
// H1
h2: {
fontSize: '24px',
fontWeight: 700,
lineHeight: 1.3,
letterSpacing: '-0.3px',
'@media (min-width:768px)': {
fontSize: '28px',
},
'@media (min-width:1024px)': {
fontSize: '32px',
},
},
// H2
h3: {
fontSize: '20px',
fontWeight: 700,
lineHeight: 1.4,
letterSpacing: '-0.2px',
'@media (min-width:768px)': {
fontSize: '22px',
},
'@media (min-width:1024px)': {
fontSize: '24px',
},
},
// H3
h4: {
fontSize: '18px',
fontWeight: 600,
lineHeight: 1.4,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '20px',
},
},
// Body Large
body1: {
fontSize: '16px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '18px',
},
},
// Body Medium
body2: {
fontSize: '14px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '16px',
},
},
// Body Small
caption: {
fontSize: '12px',
fontWeight: 400,
lineHeight: 1.5,
letterSpacing: '0px',
'@media (min-width:768px)': {
fontSize: '14px',
},
},
// Button
button: {
fontSize: '16px',
fontWeight: 600,
lineHeight: 1.5,
letterSpacing: '0px',
textTransform: 'none' as const,
},
};
// Spacing (4px Grid System)
const spacing = 4;
// Component Overrides
const components = {
MuiButton: {
styleOverrides: {
root: {
borderRadius: '8px',
padding: '12px 20px',
boxShadow: 'none',
'&:hover': {
boxShadow: 'none',
},
},
sizeLarge: {
height: '48px',
padding: '16px 24px',
},
sizeMedium: {
height: '44px',
padding: '12px 20px',
},
sizeSmall: {
height: '36px',
padding: '8px 16px',
},
contained: {
boxShadow: '0 2px 4px rgba(227, 30, 36, 0.2)',
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
border: '1px solid #E0E0E0',
},
},
},
MuiTextField: {
styleOverrides: {
root: {
'& .MuiOutlinedInput-root': {
borderRadius: '8px',
'& fieldset': {
borderColor: '#D9D9D9',
},
'&:hover fieldset': {
borderColor: '#E31E24',
},
'&.Mui-focused fieldset': {
borderColor: '#0066FF',
borderWidth: '2px',
},
},
},
},
},
MuiCheckbox: {
styleOverrides: {
root: {
'&.Mui-checked': {
color: '#E31E24',
},
},
},
},
MuiRadio: {
styleOverrides: {
root: {
'&.Mui-checked': {
color: '#E31E24',
},
},
},
},
};
// Create MUI Theme
export const theme = createTheme({
palette: {
primary: colors.primary,
secondary: colors.secondary,
success: colors.success,
warning: colors.warning,
error: colors.error,
info: colors.info,
grey: colors.gray,
background: {
default: '#FFFFFF',
paper: '#FFFFFF',
},
text: {
primary: colors.gray[900],
secondary: colors.gray[700],
disabled: colors.gray[500],
},
},
breakpoints,
typography,
spacing,
components,
shape: {
borderRadius: 8,
},
});

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}