This commit is contained in:
ondal 2025-02-13 15:47:22 +09:00
commit bb7ce2d988
53 changed files with 19663 additions and 0 deletions

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

71
README.md Normal file
View File

@ -0,0 +1,71 @@
# 마이구독 - 생활 구독 관리 서비스
## 프로젝트 소개
마이구독은 여러 구독 서비스를 한눈에 관리할 수 있는 서비스입니다.
총 구독료 확인, 구독 서비스 관리, 맞춤형 구독 추천 등의 기능을 제공합니다.
## 시작하기
### 환경 설정
1. 환경변수 설정
```bash
# .env 파일을 생성하고 아래 환경변수를 설정합니다
REACT_APP_MEMBER_URL=http://localhost:8081
REACT_APP_MYSUB_URL=http://localhost:8082
REACT_APP_RECOMMEND_URL=http://localhost:8083
```
2. 이미지 파일 준비
public/images 폴더에 필요한 이미지 파일들을 위치시킵니다
이미지 파일명은 서비스ID.png 형식이어야 합니다 (예: netflix.png)
이미지는 PNG 포맷, 투명 배경, 200x200px ~ 400x400px 크기여야 합니다
### 설치 및 실행
```
# 의존성 설치
npm install
# 개발 서버 실행
npm start
```
### 테스트 계정
```
ID: user01
Password: Passw0rd
```
## 주요 기능
로그인/로그아웃
총 구독료 조회
구독 등급별 이미지 표시
나의 구독 서비스 목록
지출 패턴 기반 구독 추천
카테고리별 구독 서비스 조회
구독 서비스 상세 정보
구독 신청/취소
## 기술 스택
React 18
React Router DOM 6
Material UI 5
Axios
Context API
## 폴더 구조
src/
├── components/ # 재사용 가능한 컴포넌트
│ ├── auth/ # 인증 관련
│ ├── common/ # 공통 컴포넌트
│ ├── main/ # 메인 화면
│ └── subscriptions/ # 구독 서비스 관련
├── contexts/ # Context API
├── pages/ # 페이지 컴포넌트
├── services/ # API 통신
└── utils/ # 유틸리티 함수
## 환경 요구사항
Node.js 18 이상
npm 9 이상

18342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "lifesub-web",
"version": "0.1.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.15.10",
"@mui/material": "^5.15.10",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.6.7",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/images/addict.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

BIN
public/images/baemin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
public/images/catch.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

BIN
public/images/chicor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
public/images/class101.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
public/images/collector.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

BIN
public/images/coupang.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/images/disney.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
public/images/karrot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/images/kurly.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

BIN
public/images/liker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

BIN
public/images/lohbs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/images/melon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
public/images/millie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
public/images/netflix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
public/images/spotify.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
public/images/taling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
public/images/tving.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

18
public/index.html Normal file
View File

@ -0,0 +1,18 @@
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="생활 구독 관리 서비스" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>마이구독</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

27
public/manifest.json Normal file
View File

@ -0,0 +1,27 @@
// public/manifest.json
{
"short_name": "마이구독",
"name": "마이구독 - 생활 구독 관리",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff",
"orientation": "portrait"
}

8
src/App.css Normal file
View File

@ -0,0 +1,8 @@
/* src/App.css */
.App {
width: 100%;
max-width: 500px;
margin: 0 auto;
min-height: 100vh;
background-color: white;
}

32
src/App.js Normal file
View File

@ -0,0 +1,32 @@
// src/App.js
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import Layout from './components/common/Layout';
import MainPage from './pages/MainPage';
import LoginPage from './pages/LoginPage';
import SubscriptionListPage from './pages/SubscriptionListPage';
import SubscriptionDetailPage from './pages/SubscriptionDetailPage';
import './App.css';
function App() {
return (
<AuthProvider>
<div className="App">
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<Layout />}>
<Route index element={<MainPage />} />
<Route path="subscriptions">
<Route index element={<SubscriptionListPage />} />
<Route path=":id" element={<SubscriptionDetailPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</div>
</AuthProvider>
);
}
export default App;

View File

@ -0,0 +1,109 @@
// src/components/auth/LoginForm.js
import React, { useState } from 'react';
import {
Box,
TextField,
Button,
Typography,
InputAdornment,
IconButton
} from '@mui/material';
import { Visibility, VisibilityOff } from '@mui/icons-material';
const LoginForm = ({ onSubmit, error }) => {
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(userId, password);
};
return (
<Box
component="form"
onSubmit={handleSubmit}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
width: '100%',
maxWidth: 400,
margin: '0 auto'
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
mb: 3
}}
>
<img
src="/logo192.png"
alt="마이구독 로고"
style={{
width: '100px',
height: '100px',
objectFit: 'contain'
}}
/>
</Box>
<TextField
label="아이디"
variant="outlined"
fullWidth
value={userId}
onChange={(e) => setUserId(e.target.value)}
error={!!error}
autoComplete="username"
required
/>
<TextField
label="비밀번호"
type={showPassword ? 'text' : 'password'}
variant="outlined"
fullWidth
value={password}
onChange={(e) => setPassword(e.target.value)}
error={!!error}
autoComplete="current-password"
required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{error && (
<Typography color="error" variant="body2">
{error}
</Typography>
)}
<Button
type="submit"
variant="contained"
color="primary"
size="large"
fullWidth
sx={{ mt: 2 }}
>
로그인
</Button>
</Box>
);
};
export default LoginForm;

View File

@ -0,0 +1,18 @@
// src/components/common/ErrorMessage.js
/*
에러 메시지를 일관된 형태로 표시
*/
import React from 'react';
import { Alert, Box } from '@mui/material';
const ErrorMessage = ({ message }) => {
return (
<Box sx={{ mt: 2, mb: 2 }}>
<Alert severity="error">
{message || '오류가 발생했습니다. 다시 시도해주세요.'}
</Alert>
</Box>
);
};
export default ErrorMessage;

View File

@ -0,0 +1,53 @@
// src/components/common/Header.js 수정 - 로그아웃 기능 추가
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { AppBar, Toolbar, IconButton, Typography, Button } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useAuth } from '../../contexts/AuthContext';
const Header = () => {
const navigate = useNavigate();
const location = useLocation();
const { currentUser, logout } = useAuth();
const getTitle = () => {
switch (location.pathname) {
case '/':
return '마이구독';
case '/subscriptions':
return '구독 서비스';
default:
if (location.pathname.startsWith('/subscriptions/')) {
return '구독 상세';
}
return '마이구독';
}
};
const handleLogout = async () => {
await logout();
navigate('/login');
};
return (
<AppBar position="sticky" sx={{ backgroundColor: 'white', color: 'black' }}>
<Toolbar>
{location.pathname !== '/' && (
<IconButton edge="start" onClick={() => navigate(-1)} sx={{ mr: 2 }}>
<ArrowBackIcon />
</IconButton>
)}
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
{getTitle()}
</Typography>
{currentUser && (
<Button color="inherit" onClick={handleLogout}>
로그아웃
</Button>
)}
</Toolbar>
</AppBar>
);
};
export default Header;

View File

@ -0,0 +1,18 @@
// src/components/common/Layout.js
import React from 'react';
import { Outlet } from 'react-router-dom';
import { Container } from '@mui/material';
import Header from './Header';
const Layout = () => {
return (
<>
<Header />
<Container maxWidth="sm" sx={{ pt: 2, pb: 4 }}>
<Outlet />
</Container>
</>
);
};
export default Layout;

View File

@ -0,0 +1,23 @@
// src/components/common/LoadingSpinner.js
/*
데이터 로딩 표시할 로딩 인디케이터
*/
import React from 'react';
import { CircularProgress, Box } from '@mui/material';
const LoadingSpinner = () => {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '200px'
}}
>
<CircularProgress />
</Box>
);
};
export default LoadingSpinner;

View File

@ -0,0 +1,58 @@
// src/components/common/SubscriptionCard.js
/*
구독 서비스 정보를 카드 형태로 표시
서비스 로고, 이름, 설명, 가격 표시
클릭 이벤트 처리 가능
*/
import React from 'react';
import { Card, CardContent, CardMedia, Typography, Box } from '@mui/material';
import { formatNumber } from '../../utils/formatters';
const SubscriptionCard = ({
logoUrl,
serviceName,
description,
price,
onClick,
showPrice = true
}) => {
return (
<Card
sx={{
display: 'flex',
mb: 2,
cursor: 'pointer',
'&:hover': {
boxShadow: 3
}
}}
onClick={onClick}
>
<CardMedia
component="img"
sx={{ width: 100, p: 2, objectFit: 'contain' }}
image={`${logoUrl}`}
alt={serviceName}
/>
<Box sx={{ display: 'flex', flexDirection: 'column', flexGrow: 1 }}>
<CardContent>
<Typography variant="h6" component="div">
{serviceName}
</Typography>
{description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{description}
</Typography>
)}
{showPrice && price && (
<Typography variant="subtitle1" color="primary">
{formatNumber(price)}
</Typography>
)}
</CardContent>
</Box>
</Card>
);
};
export default SubscriptionCard;

View File

@ -0,0 +1,32 @@
// src/components/main/MySubscriptions.js
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Box } from '@mui/material';
import SubscriptionCard from '../common/SubscriptionCard';
const MySubscriptions = ({ subscriptions }) => {
const navigate = useNavigate();
return (
<Box sx={{ mb: 4 }}>
<Typography variant="h6" gutterBottom>
나의 구독 서비스
</Typography>
{subscriptions.length === 0 ? (
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
아직 구독한 서비스가 없어요
</Typography>
) : (
subscriptions.map((subscription) => (
<SubscriptionCard
key={subscription.id}
{...subscription}
onClick={() => navigate(`/subscriptions/${subscription.id}`)}
/>
))
)}
</Box>
);
};
export default MySubscriptions;

View File

@ -0,0 +1,51 @@
// src/components/main/RecommendCategory.js
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Paper, Typography, Box, Button } from '@mui/material';
const RecommendCategory = ({ categoryName, spendingCategory, totalSpending, baseDate }) => {
const navigate = useNavigate();
return (
<Paper
elevation={3}
sx={{
p: 3,
mb: 3,
borderRadius: 2
}}
>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
{baseDate} 기준
</Typography>
<Typography variant="h6" gutterBottom>
이런 구독은 어떠세요?
</Typography>
<Typography variant="body1" gutterBottom>
지난 <strong>{spendingCategory}</strong> 카테고리에서<br />
가장 많은 지출이 있었네요!
</Typography>
<Box
sx={{
mt: 2,
mb: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Button
variant="contained"
color="primary"
fullWidth
onClick={() => navigate(`/subscriptions?category=${categoryName}`)}
sx={{ mt: 2 }}
>
추천 구독 서비스 보기
</Button>
</Box>
</Paper>
);
};
export default RecommendCategory;

View File

@ -0,0 +1,44 @@
// src/components/main/TotalFee.js
import React from 'react';
import { Paper, Typography, Box } from '@mui/material';
import { formatNumber } from '../../utils/formatters';
const feeLevelImages = {
'liker': '/images/liker.png',
'collector': '/images/collector.png',
'addict': '/images/addict.png'
};
const TotalFee = ({ totalFee, feeLevel }) => {
return (
<Paper
elevation={3}
sx={{
p: 3,
mb: 3,
textAlign: 'center',
borderRadius: 2
}}
>
<Box
component="img"
src={feeLevelImages[feeLevel]}
alt={feeLevel}
sx={{
width: 120,
height: 120,
mb: 2,
objectFit: 'contain'
}}
/>
<Typography variant="h6" gutterBottom>
이번 구독료
</Typography>
<Typography variant="h4" color="primary" fontWeight="bold">
{formatNumber(totalFee)}
</Typography>
</Paper>
);
};
export default TotalFee;

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Box, Grid, Button, styled } from '@mui/material';
const CategoryButton = styled(Button)(({ theme }) => ({
width: '100%',
padding: theme.spacing(2),
fontSize: '1.1rem',
borderRadius: theme.spacing(1),
textTransform: 'none',
border: '1px solid',
borderColor: theme.palette.grey[300],
'&.selected': {
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
boxShadow: theme.shadows[3],
borderColor: 'transparent',
'&:hover': {
backgroundColor: theme.palette.primary.dark,
}
}
}));
const CategoryList = ({ categories, selectedCategory, onCategoryChange }) => {
return (
<Box sx={{ width: '100%', mb: 4, px: 2 }}>
<Grid container spacing={2}>
{categories.map((category) => (
<Grid item xs={4} key={category.categoryId}>
<CategoryButton
variant={selectedCategory === category.categoryId ? "contained" : "outlined"}
className={selectedCategory === category.categoryId ? 'selected' : ''}
onClick={() => onCategoryChange(category.categoryId)}
>
{category.categoryName}
</CategoryButton>
</Grid>
))}
</Grid>
</Box>
);
};
export default CategoryList;

View File

@ -0,0 +1,106 @@
// src/components/subscriptions/SubscriptionDetail.js
import React from 'react';
import {
Box,
Typography,
Button,
Card,
CardMedia,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText
} from '@mui/material';
import PriceCheckIcon from '@mui/icons-material/PriceCheck';
import GroupIcon from '@mui/icons-material/Group';
import CategoryIcon from '@mui/icons-material/Category';
import { formatNumber } from '../../utils/formatters';
const SubscriptionDetail = ({
serviceName,
logoUrl,
category,
description,
price,
maxSharedUsers,
isSubscribed,
onSubscribe,
onCancel
}) => {
return (
<Card sx={{ p: 3 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', mb: 3 }}>
<CardMedia
component="img"
sx={{ width: 200, height: 200, objectFit: 'contain', mb: 2 }}
image={`${logoUrl}`}
alt={serviceName}
/>
<Typography variant="h5" component="div" gutterBottom>
{serviceName}
</Typography>
</Box>
<Divider sx={{ mb: 3 }} />
<List>
<ListItem>
<ListItemIcon>
<CategoryIcon />
</ListItemIcon>
<ListItemText
primary="카테고리"
secondary={category}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<PriceCheckIcon />
</ListItemIcon>
<ListItemText
primary="월 구독료"
secondary={`${formatNumber(price)}`}
/>
</ListItem>
<ListItem>
<ListItemIcon>
<GroupIcon />
</ListItemIcon>
<ListItemText
primary="최대 공유 인원"
secondary={`${maxSharedUsers}`}
/>
</ListItem>
</List>
<Typography variant="body1" sx={{ mt: 2, mb: 3 }}>
{description}
</Typography>
<Box sx={{ mt: 3 }}>
{isSubscribed ? (
<Button
variant="outlined"
color="error"
fullWidth
onClick={onCancel}
>
구독 취소하기
</Button>
) : (
<Button
variant="contained"
color="primary"
fullWidth
onClick={onSubscribe}
>
구독하기
</Button>
)}
</Box>
</Card>
);
};
export default SubscriptionDetail;

View File

@ -0,0 +1,31 @@
// src/components/subscriptions/SubscriptionList.js
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Typography } from '@mui/material';
import SubscriptionCard from '../common/SubscriptionCard';
const SubscriptionList = ({ services }) => {
const navigate = useNavigate();
if (!services.length) {
return (
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
해당 카테고리에 구독 서비스가 없습니다.
</Typography>
);
}
return (
<Box>
{services.map((service) => (
<SubscriptionCard
key={service.serviceId}
{...service}
onClick={() => navigate(`/subscriptions/${service.serviceId}`)}
/>
))}
</Box>
);
};
export default SubscriptionList;

View File

@ -0,0 +1,60 @@
// src/contexts/AuthContext.js
import React, { createContext, useContext, useState, useEffect } from 'react';
import { authApi, setAuthToken, removeAuthToken } from '../services/api';
const AuthContext = createContext(null);
export const AuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [authInitialized, setAuthInitialized] = useState(false); // 변수명 변경
useEffect(() => {
const savedUser = localStorage.getItem('user');
if (savedUser) {
setCurrentUser(JSON.parse(savedUser));
}
setAuthInitialized(true); // 변수명 변경
}, []);
const login = async (userId, password) => {
try {
const response = await authApi.login({ userId, password });
const { accessToken } = response.data.data;
setAuthToken(accessToken);
const user = { userId };
setCurrentUser(user);
setAuthInitialized(true);
localStorage.setItem('user', JSON.stringify(user));
return true;
} catch (error) {
console.error('Login failed:', error);
return false;
}
};
const logout = async () => {
try {
if (currentUser) {
await authApi.logout({ userId: currentUser.userId });
}
} finally {
removeAuthToken();
setCurrentUser(null);
localStorage.removeItem('user');
}
};
return (
<AuthContext.Provider value={{ currentUser, login, logout, authInitialized }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};

16
src/index.css Normal file
View File

@ -0,0 +1,16 @@
/* src/index.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}

15
src/index.js Normal file
View File

@ -0,0 +1,15 @@
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

73
src/pages/LoginPage.js Normal file
View File

@ -0,0 +1,73 @@
// src/pages/LoginPage.js
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Typography, Paper } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import LoginForm from '../components/auth/LoginForm';
const LoginPage = () => {
const navigate = useNavigate();
const { currentUser, login } = useAuth();
const [error, setError] = useState('');
useEffect(() => {
if (currentUser) {
navigate('/');
}
}, [currentUser, navigate]);
const handleLogin = async (userId, password) => {
try {
setError('');
const success = await login(userId, password);
if (success) {
navigate('/');
} else {
setError('아이디 또는 비밀번호가 올바르지 않습니다.');
}
} catch (err) {
setError('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
}
};
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 2
}}
>
<Paper
elevation={3}
sx={{
width: '100%',
maxWidth: 400,
p: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}
>
<Typography
component="h1"
variant="h4"
gutterBottom
sx={{ mb: 4 }}
>
마이구독
</Typography>
<LoginForm
onSubmit={handleLogin}
error={error}
/>
</Paper>
</Box>
);
};
export default LoginPage;

73
src/pages/MainPage.js Normal file
View File

@ -0,0 +1,73 @@
// src/pages/MainPage.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box } from '@mui/material';
import { useAuth } from '../contexts/AuthContext';
import TotalFee from '../components/main/TotalFee';
import MySubscriptions from '../components/main/MySubscriptions';
import RecommendCategory from '../components/main/RecommendCategory';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ErrorMessage from '../components/common/ErrorMessage';
import { mySubscriptionApi, recommendApi, handleApiResponse } from '../services/api';
const MainPage = () => {
const navigate = useNavigate();
const { currentUser, authInitialized } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [totalFee, setTotalFee] = useState(null);
const [subscriptions, setSubscriptions] = useState([]);
const [recommendedCategory, setRecommendedCategory] = useState(null);
useEffect(() => {
// 로그인 체크
if (authInitialized && !currentUser) { // loading 상태 체크 추가
navigate('/login');
return;
}
if(authInitialized) {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const [totalFeeResponse, subscriptionsResponse, recommendResponse] = await Promise.all([
mySubscriptionApi.getTotalFee(currentUser.userId),
mySubscriptionApi.getMySubscriptions(currentUser.userId),
recommendApi.getRecommendedCategory(currentUser.userId)
]);
setTotalFee(handleApiResponse(totalFeeResponse));
setSubscriptions(handleApiResponse(subscriptionsResponse));
setRecommendedCategory(handleApiResponse(recommendResponse));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}
}, [currentUser, navigate, authInitialized]);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
return (
<Box>
<TotalFee
totalFee={totalFee?.totalFee}
feeLevel={totalFee?.feeLevel}
/>
<MySubscriptions subscriptions={subscriptions} />
{recommendedCategory && (
<RecommendCategory {...recommendedCategory} />
)}
</Box>
);
};
export default MainPage;

View File

@ -0,0 +1,87 @@
// src/pages/SubscriptionDetailPage.js
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import SubscriptionDetail from '../components/subscriptions/SubscriptionDetail';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ErrorMessage from '../components/common/ErrorMessage';
import { mySubscriptionApi, handleApiResponse } from '../services/api';
const SubscriptionDetailPage = () => {
const { id } = useParams();
const navigate = useNavigate();
const { currentUser, authInitialized } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [subscription, setSubscription] = useState(null);
const [isSubscribed, setIsSubscribed] = useState(false);
useEffect(() => {
if (authInitialized && !currentUser) { // login여부가 완료되고 현재 유저 정보가 없을때 로그인 페이지로 보냄
navigate('/login');
return;
}
if(authInitialized) {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
// 구독 상세 정보와 사용자의 구독 목록을 동시에 조회
const [detailResponse, mySubsResponse] = await Promise.all([
mySubscriptionApi.getSubscriptionDetail(id),
mySubscriptionApi.getMySubscriptions(currentUser.userId)
]);
const detailData = handleApiResponse(detailResponse);
const mySubsData = handleApiResponse(mySubsResponse);
setSubscription(detailData);
// 현재 서비스가 사용자의 구독 목록에 있는지 확인
setIsSubscribed(mySubsData.some(sub => sub.id === parseInt(id)));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}
}, [currentUser, navigate, id, authInitialized]);
const handleSubscribe = async () => {
try {
await mySubscriptionApi.subscribe(id, currentUser.userId);
navigate('/');
} catch (err) {
setError(err.message);
}
};
const handleCancel = async () => {
try {
await mySubscriptionApi.cancelSubscription(id);
navigate('/');
} catch (err) {
setError(err.message);
}
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
if (!subscription) return <ErrorMessage message="구독 서비스를 찾을 수 없습니다." />;
return (
<SubscriptionDetail
{...subscription}
isSubscribed={isSubscribed}
onSubscribe={handleSubscribe}
onCancel={handleCancel}
/>
);
};
export default SubscriptionDetailPage;

View File

@ -0,0 +1,94 @@
// src/pages/SubscriptionListPage.js
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
import { Box } from '@mui/material';
import CategoryList from '../components/subscriptions/CategoryList';
import SubscriptionList from '../components/subscriptions/SubscriptionList';
import LoadingSpinner from '../components/common/LoadingSpinner';
import ErrorMessage from '../components/common/ErrorMessage';
import { mySubscriptionApi, handleApiResponse } from '../services/api';
const SubscriptionListPage = () => {
const navigate = useNavigate();
const { currentUser, authInitialized } = useAuth();
const [searchParams, setSearchParams] = useSearchParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [categories, setCategories] = useState([]);
const [services, setServices] = useState([]);
const selectedCategory = searchParams.get('category') || '';
useEffect(() => {
if (authInitialized && !currentUser) { // login여부가 완료되고 현재 유저 정보가 없을때 로그인 페이지로 보냄
navigate('/login');
return;
}
if(authInitialized) {
const fetchCategories = async () => {
try {
setLoading(true);
setError(null);
const response = await mySubscriptionApi.getCategories();
const categoryList = handleApiResponse(response);
setCategories(categoryList);
// 선택된 카테고리가 없으면 첫 번째 카테고리 선택
if (!selectedCategory && categoryList.length > 0) {
setSearchParams({ category: categoryList[0].categoryId });
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchCategories();
}
}, [currentUser, navigate, selectedCategory, setSearchParams, authInitialized]);
useEffect(() => {
if(authInitialized) {
const fetchServices = async () => {
if (!selectedCategory) return;
try {
setLoading(true);
setError(null);
const response = await mySubscriptionApi.getServicesByCategory(selectedCategory);
setServices(handleApiResponse(response));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchServices();
}
}, [selectedCategory, authInitialized]);
const handleCategoryChange = (categoryId) => {
setSearchParams({ category: categoryId });
};
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error} />;
return (
<Box>
<CategoryList
categories={categories}
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<SubscriptionList services={services} />
</Box>
);
};
export default SubscriptionListPage;

91
src/services/api.js Normal file
View File

@ -0,0 +1,91 @@
// src/services/api.js
import axios from 'axios';
// 서비스별 base URL
const MEMBER_URL = process.env.REACT_APP_MEMBER_URL || 'http://localhost:8081';
const MYSUB_URL = process.env.REACT_APP_MYSUB_URL || 'http://localhost:8082';
const RECOMMEND_URL = process.env.REACT_APP_RECOMMEND_URL || 'http://localhost:8083';
// 서비스별 axios 인스턴스 생성
const createAxiosInstance = (baseURL) => {
const instance = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// 토큰 인터셉터 추가
instance.interceptors.request.use(
(config) => {
// login API는 토큰이 필요없음
if (config.url === '/api/auth/login') {
return config;
}
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
return instance;
};
const memberApi = createAxiosInstance(MEMBER_URL);
const mysubApi = createAxiosInstance(MYSUB_URL);
const recommendationApi = createAxiosInstance(RECOMMEND_URL);
// 인증 관련 API
export const authApi = {
login: (loginRequest) => memberApi.post('/api/auth/login', loginRequest),
logout: (logoutRequest) => memberApi.post('/api/auth/logout', logoutRequest)
};
// 마이구독 관련 API
export const mySubscriptionApi = {
getTotalFee: (userId) => mysubApi.get(`/api/mysub/total-fee?userId=${userId}`),
getMySubscriptions: (userId) => mysubApi.get(`/api/mysub/list?userId=${userId}`),
getSubscriptionDetail: (id) => mysubApi.get(`/api/mysub/services/${id}`),
subscribe: (id, userId) => mysubApi.post(`/api/mysub/services/${id}/subscribe?userId=${userId}`),
cancelSubscription: (id) => mysubApi.delete(`/api/mysub/services/${id}`),
getCategories: () => mysubApi.get('/api/mysub/categories'),
getServicesByCategory: (categoryId) => mysubApi.get(`/api/mysub/services?categoryId=${categoryId}`)
};
// 추천 관련 API
export const recommendApi = { // 이름을 recommendationApi로 변경
getRecommendedCategory: (userId) => recommendationApi.get(`/api/recommend/categories?userId=${userId}`)
};
// API 응답 처리 헬퍼 함수
export const handleApiResponse = (response) => {
const { status, message, data } = response.data;
if (status === 200) {
return data;
} else {
throw new Error(message);
}
};
// 인증 토큰 관리 헬퍼 함수
export const setAuthToken = (token) => {
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
};
export const getAuthToken = () => {
return localStorage.getItem('token');
};
export const removeAuthToken = () => {
localStorage.removeItem('token');
};

0
src/services/auth.js Normal file
View File

4
src/utils/formatters.js Normal file
View File

@ -0,0 +1,4 @@
// src/utils/formatters.js
export const formatNumber = (number) => {
return number?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
};