cherry2250 47ed0b5a7c 그래디언트 배경 카드 텍스트 가독성 개선 및 입체감 추가
- 배경색이 있는 모든 카드의 흰색 글자를 검은색으로 변경하여 가독성 향상
- 아이콘과 텍스트에 그림자 효과를 추가하여 입체감 부여
- Profile 페이지 디자인 통일성 완료

변경 파일:
- src/app/(main)/page.tsx: 대시보드 KPI 카드 (3개)
- src/app/(main)/events/page.tsx: 이벤트 통계 카드 (4개)
- src/app/(main)/events/create/steps/ApprovalStep.tsx: 승인 단계 요약 카드 (4개)
- src/app/(main)/profile/page.tsx: 프로필 페이지 전체 리디자인

적용된 효과:
- 아이콘: drop-shadow(0px 2px 4px rgba(0,0,0,0.2))
- 큰 텍스트: text-shadow 0px 2px 4px rgba(0,0,0,0.15)
- 작은 텍스트: text-shadow 0px 1px 2px rgba(0,0,0,0.1)
- 아이콘 배경: rgba(0,0,0,0.05) (대시보드)
- 글자 색상: colors.gray[900] (제목), colors.gray[700] (라벨)
2025-10-27 16:15:43 +09:00

604 lines
20 KiB
TypeScript

'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,
Container,
TextField,
Button,
Typography,
Card,
CardContent,
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';
import Header from '@/shared/ui/Header';
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
// 기본 정보 스키마
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) => {
console.log('Password change data:', data);
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 (
<>
<Header title="프로필" showBack={true} showMenu={false} showProfile={false} />
<Box
sx={{
pt: { xs: 7, sm: 8 },
pb: 10,
bgcolor: colors.gray[50],
minHeight: '100vh',
}}
>
<Container maxWidth="lg" sx={{ pt: 8, pb: 6, px: { xs: 6, sm: 8, md: 10 } }}>
{/* 사용자 정보 섹션 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10, textAlign: 'center' }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Avatar
sx={{
width: 100,
height: 100,
mx: 'auto',
mb: 3,
bgcolor: colors.purple,
color: 'white',
}}
>
<Person sx={{ fontSize: 56 }} />
</Avatar>
<Typography variant="h5" sx={{ ...responsiveText.h3, mb: 1 }}>
{user?.name}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ...responsiveText.body1 }}>
{user?.email}
</Typography>
</CardContent>
</Card>
{/* 기본 정보 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
</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>
</CardContent>
</Card>
{/* 매장 정보 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
</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>
</CardContent>
</Card>
{/* 비밀번호 변경 */}
<Card elevation={0} sx={{ ...cardStyles.default, mb: 10 }}>
<CardContent sx={{ p: { xs: 6, sm: 8 } }}>
<Typography variant="h6" sx={{ ...responsiveText.h4, fontWeight: 700, mb: 6 }}>
</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,
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
</Box>
</CardContent>
</Card>
{/* 액션 버튼 */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleSave}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
<Button
fullWidth
variant="text"
size="large"
color="error"
onClick={() => setLogoutDialogOpen(true)}
sx={{
py: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
</Box>
</Container>
</Box>
{/* 저장 완료 다이얼로그 */}
<Dialog
open={successDialogOpen}
onClose={() => setSuccessDialogOpen(false)}
PaperProps={{
sx: {
borderRadius: 4,
minWidth: 300,
},
}}
>
<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, fontSize: '1.25rem' }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
.
</Typography>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', pb: 3 }}>
<Button
variant="contained"
onClick={() => {
setSuccessDialogOpen(false);
window.location.reload();
}}
sx={{
minWidth: 120,
py: 2,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
</DialogActions>
</Dialog>
{/* 로그아웃 확인 다이얼로그 */}
<Dialog
open={logoutDialogOpen}
onClose={() => setLogoutDialogOpen(false)}
PaperProps={{
sx: {
borderRadius: 4,
minWidth: 300,
},
}}
>
<DialogTitle sx={{ fontSize: '1.25rem', fontWeight: 700 }}></DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ textAlign: 'center', py: 2, fontSize: '1rem' }}>
?
</Typography>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', gap: 2, pb: 3 }}>
<Button
onClick={() => setLogoutDialogOpen(false)}
sx={{
fontWeight: 600,
fontSize: '1rem',
py: 2,
px: 4,
borderRadius: 2,
}}
>
</Button>
<Button
variant="contained"
onClick={handleLogout}
color="error"
sx={{
fontWeight: 700,
fontSize: '1rem',
py: 2,
px: 4,
borderRadius: 2,
}}
>
</Button>
</DialogActions>
</Dialog>
</>
);
}