mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 05:36:23 +00:00
인증 영역 개발 완료 (로그인, 회원가입, 프로필 관리)
- 로그인 페이지: 이메일 + 비밀번호 로그인, 소셜 로그인 버튼 - 회원가입 페이지: 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:
parent
ca4dff559c
commit
4df7ba0697
11
.env.example
Normal file
11
.env.example
Normal 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
12
.eslintrc.json
Normal 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
43
.gitignore
vendored
Normal 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
10
.prettierrc
Normal 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
17
next.config.js
Normal 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
6652
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal 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
13
public/runtime-env.js
Normal 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",
|
||||
};
|
||||
346
src/app/(auth)/login/page.tsx
Normal file
346
src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
504
src/app/(auth)/register/page.tsx
Normal file
504
src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
505
src/app/(main)/profile/page.tsx
Normal file
505
src/app/(main)/profile/page.tsx
Normal 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
44
src/app/layout.tsx
Normal 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
30
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/components/common/Loading.tsx
Normal file
28
src/components/common/Loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/common/Toast.tsx
Normal file
27
src/components/common/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/layout/BottomNav.tsx
Normal file
54
src/components/layout/BottomNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/lib/react-query-provider.tsx
Normal file
24
src/lib/react-query-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/lib/theme-provider.tsx
Normal file
18
src/lib/theme-provider.tsx
Normal 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
40
src/stores/authStore.ts
Normal 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
31
src/stores/uiStore.ts
Normal 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
187
src/styles/globals.css
Normal 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
253
src/styles/theme.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user