mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:56:24 +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