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, ",");
|
||||
};
|
||||