release
22
.gitignore
vendored
Normal 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
@ -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
44
package.json
Normal 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
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/images/addict.png
Normal file
|
After Width: | Height: | Size: 425 KiB |
BIN
public/images/baemin.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/images/catch.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
public/images/chicor.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
public/images/class101.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
BIN
public/images/collector.png
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
public/images/coupang-eats.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
public/images/coupang.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/images/disney.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
public/images/karrot.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
public/images/kurly.png
Normal file
|
After Width: | Height: | Size: 518 KiB |
BIN
public/images/liker.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
public/images/lohbs.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/images/melon.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/images/millie.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/images/netflix.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/oliveyoung.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
public/images/spotify.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
public/images/taling.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/tving.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/youtube-music.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
18
public/index.html
Normal 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
|
After Width: | Height: | Size: 20 KiB |
27
public/manifest.json
Normal 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
@ -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
@ -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;
|
||||||
109
src/components/auth/LoginForm.js
Normal 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;
|
||||||
18
src/components/common/ErrorMessage.js
Normal 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;
|
||||||
53
src/components/common/Header.js
Normal 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;
|
||||||
18
src/components/common/Layout.js
Normal 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;
|
||||||
23
src/components/common/LoadingSpinner.js
Normal 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;
|
||||||
58
src/components/common/SubscriptionCard.js
Normal 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;
|
||||||
32
src/components/main/MySubscriptions.js
Normal 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;
|
||||||
51
src/components/main/RecommendCategory.js
Normal 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;
|
||||||
44
src/components/main/TotalFee.js
Normal 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;
|
||||||
43
src/components/subscriptions/CategoryList.js
Normal 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;
|
||||||
106
src/components/subscriptions/SubscriptionDetail.js
Normal 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;
|
||||||
31
src/components/subscriptions/SubscriptionList.js
Normal 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;
|
||||||
60
src/contexts/AuthContext.js
Normal 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
@ -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
@ -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
@ -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
@ -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;
|
||||||
87
src/pages/SubscriptionDetailPage.js
Normal 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;
|
||||||
94
src/pages/SubscriptionListPage.js
Normal 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
@ -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
4
src/utils/formatters.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
// src/utils/formatters.js
|
||||||
|
export const formatNumber = (number) => {
|
||||||
|
return number?.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
};
|
||||||