결제, 구독 기능

This commit is contained in:
2025-05-11 17:27:39 +09:00
parent 2924a61a9d
commit 1db3a03ffd
18 changed files with 3276 additions and 180 deletions
+40
View File
@@ -15,6 +15,7 @@
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@tosspayments/payment-sdk": "^1.9.1",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
@@ -1459,6 +1460,45 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@tosspayments/payment__types": {
"version": "1.69.0",
"resolved": "https://registry.npmjs.org/@tosspayments/payment__types/-/payment__types-1.69.0.tgz",
"integrity": "sha512-EbU36vLEWfhRpX/g4V0DyRiUmTkNeeVDYjJamD9aJiLmRbLl9PVdXi5QOQTMYma2RGRgA8drHY4yDMsjvKFwCg==",
"license": "MIT",
"dependencies": {
"@tosspayments/sdk-constants": "^0.2.2"
}
},
"node_modules/@tosspayments/payment-sdk": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@tosspayments/payment-sdk/-/payment-sdk-1.9.1.tgz",
"integrity": "sha512-OzDG2kOGOrb5JRvprR8R/mQzXFNnLW/6X09uLY1KF4+O7ASu6LrMoFCERd+WeAJMtRAARkclGDcJ/GSu+OHzzg==",
"license": "MIT",
"dependencies": {
"@tosspayments/payment__types": "1.69.0"
}
},
"node_modules/@tosspayments/sdk-constants": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@tosspayments/sdk-constants/-/sdk-constants-0.2.2.tgz",
"integrity": "sha512-qTIcD9JnVtN65/yiW5sPAlvK5fpQtDluv4IEyslubncbQLQKE+ePSnGZU1fLNPxcchv6QTnwmgj9Ndm6EN/Ymg==",
"license": "MIT",
"dependencies": {
"type-fest": "^2.11.2"
}
},
"node_modules/@tosspayments/sdk-constants/node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@fullcalendar/vue3": "^6.1.15",
"@primeuix/themes": "^1.0.3",
"@primevue/themes": "^4.3.3",
"@tosspayments/payment-sdk": "^1.9.1",
"@vuelidate/core": "^2.0.3",
"@vuelidate/validators": "^2.0.4",
"axios": "^1.8.2",
+30 -12
View File
@@ -86,11 +86,13 @@
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, provide } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, provide, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import AppHeader from './components/AppHeader.vue';
import AppPublicLayout from './layouts/AppPublicLayout.vue';
import apiClient from './services/api';
import authService from './services/authService';
import { useClubStore } from './stores/clubStore';
import AppPublicLayout from './layouts/AppPublicLayout.vue';
import AppHeader from './components/AppHeader.vue';
const route = useRoute();
const layoutComponent = computed(() => route.meta.layout === 'public' ? AppPublicLayout : 'div');
@@ -98,7 +100,9 @@ const isPublicLayout = computed(() => {
// 라우트 meta.layout이 public이거나, 경로가 /public/로 시작하면 public 레이아웃으로 간주
return route.meta.layout === 'public' || route.path.startsWith('/public/');
});
import authService from './services/authService';
// 클럽 스토어 초기화
const clubStore = useClubStore();
// 상태 변수
const user = ref(null);
@@ -188,16 +192,21 @@ const fetchClubs = async () => {
const fetchClubInfo = async () => {
if (isPublicLayout.value) return;
try {
const response = await apiClient.post(`/api/club`, { clubId: selectedClubId.value ?? clubs.value[0].id });
// 클럽 스토어를 통해 클럽 정보 가져오기
const clubId = selectedClubId.value ?? (clubs.value.length > 0 ? clubs.value[0].id : null);
if (!clubId) return;
const clubData = await clubStore.fetchClubById(clubId);
if (response.data) {
if (clubData) {
selectedClub.value = {
id: response.data.id,
name: response.data.name,
location: response.data.location,
id: clubData.id,
name: clubData.name,
location: clubData.location,
subscription: clubData.subscription || null
};
memberCount.value = response.data.memberCount;
ownerName.value = response.data.ownerName;
memberCount.value = clubData.memberCount;
ownerName.value = clubData.ownerName;
}
} catch (error) {
console.error('클럽 정보 가져오기 오류:', error);
@@ -287,7 +296,16 @@ const loadMenuItems = async () => {
});
menuItems.value = groupMenus;
singleMenuItems.value = singleMenus;
// 구독관리 메뉴 하드코딩으로 추가
singleMenuItems.value = [
...singleMenus,
{
label: '구독관리',
icon: 'pi pi-credit-card',
to: '/club/subscription'
}
];
} else {
menuItems.value = [];
singleMenuItems.value = [];
@@ -12,7 +12,7 @@
<Tab value="기본정보"><i class="pi pi-info-circle mr-2" />기본정보</Tab>
<Tab value="참가자 관리"><i class="pi pi-users mr-2" />참가자 관리</Tab>
<Tab value="점수 관리"><i class="pi pi-chart-bar mr-2" />점수 관리</Tab>
<Tab value="팀 편성"><i class="pi pi-sitemap mr-2" /> 편성</Tab>
<Tab v-if="hasTeamFeature" value="팀 편성"><i class="pi pi-sitemap mr-2" /> 편성</Tab>
</TabList>
<TabPanels>
<!-- 기본 정보 -->
@@ -77,7 +77,7 @@
/>
</TabPanel>
<!-- 편성 -->
<TabPanel value="팀 편성">
<TabPanel v-if="hasTeamFeature" value="팀 편성">
<TeamGeneration :participants="confirmedParticipants" :teams="teams" @save-teams="handleSaveTeams" />
</TabPanel>
</TabPanels>
@@ -105,6 +105,8 @@ const props = defineProps({
getStatusName: { type: Function, required: true },
getStatusSeverity: { type: Function, required: true },
formatDate: { type: Function, required: true },
// 팀 기능 사용 권한 여부
hasTeamFeature: { type: Boolean, default: false },
// (권장) 상태명이 매칭 안 될 경우 실제 상태 문자열 반환
// getStatusName 함수 예시:
@@ -139,11 +141,16 @@ const hideDialog = () => {
// 팀 생성 결과 저장
const teams = ref([]);
// 이벤트 상세 다이얼로그가 열릴 때마다 팀 목록 불러오기
// 이벤트 상세 다이얼로그가 열릴 때마다 팀 목록 불러오기 (팀 기능 사용 권한이 있는 경우에만)
watch(() => eventModel.value.id, async (eventId) => {
if (eventId) {
const latestTeams = await teamService.getTeams(eventId);
teams.value = latestTeams;
if (eventId && props.hasTeamFeature) {
try {
const latestTeams = await teamService.getTeams(eventId);
teams.value = latestTeams;
} catch (error) {
console.error('팀 정보를 불러오는 중 오류가 발생했습니다:', error);
teams.value = [];
}
}
}, { immediate: true });
@@ -166,6 +173,12 @@ import { useToast } from 'primevue/usetoast';
const toast = useToast();
async function handleSaveTeams(savedTeams) {
// 팀 기능 사용 권한이 없는 경우 저장하지 않음
if (!props.hasTeamFeature) {
toast.add({ severity: 'error', summary: '권한 없음', detail: '팀 기능을 사용할 수 있는 권한이 없습니다.', life: 3000 });
return;
}
const eventId = eventModel.value.id;
try {
await teamService.saveTeams(eventId, savedTeams);
+18
View File
@@ -23,6 +23,10 @@ const UserClubCreate = () => import('@/views/club/ClubCreate.vue');
// 구독 관리 컴포넌트 import
const ClubSubscriptionManagement = () => import('@/views/club/subscription/SubscriptionManagement.vue');
// 결제 관련 컴포넌트 import
const PaymentSuccess = () => import('@/views/payment/PaymentSuccess.vue');
const PaymentFail = () => import('@/views/payment/PaymentFail.vue');
const EventPublicPage = () => import('@/views/event/EventPublicPage.vue');
const routes = [
@@ -137,6 +141,20 @@ const routes = [
name: 'UserClubCreate',
component: () => import('@/views/club/ClubCreate.vue'),
meta: { requiresAuth: true }
},
// 결제 관련 경로
{
path: '/payment/success',
name: 'PaymentSuccess',
component: PaymentSuccess,
meta: { requiresAuth: true }
},
{
path: '/payment/fail',
name: 'PaymentFail',
component: PaymentFail,
meta: { requiresAuth: true }
}
];
+35 -2
View File
@@ -37,9 +37,42 @@ apiClient.interceptors.response.use(
if (error.response && error.response.status === 401) {
// 로컬 스토리지에서 토큰 제거
localStorage.removeItem('token');
// 로그인 페이지로 리디렉션
window.location.href = '/login';
localStorage.removeItem('user');
// 현재 페이지가 이미 로그인 페이지가 아닌 경우에만 리디렉션
const currentPath = window.location.pathname;
if (currentPath !== '/login') {
// 로그인 페이지로 리디렉션
window.location.href = '/login';
}
}
// 403 에러 (권한 없음) 처리
if (error.response && error.response.status === 403) {
// 구독 관련 오류 메시지인지 확인
const errorMessage = error.response.data?.error || '';
if (errorMessage.includes('기능을 사용할 수 없습니다') ||
errorMessage.includes('권한이 없습니다')) {
// 사용자에게 구독 안내 표시
import('primevue/usetoast').then(({ useToast }) => {
const toast = useToast();
toast.add({
severity: 'warn',
summary: '권한 없음',
detail: '해당 기능을 사용하려면 구독이 필요합니다. 구독 페이지로 이동합니다.',
life: 5000
});
});
// 3초 후 구독 페이지로 이동
setTimeout(() => {
const clubId = localStorage.getItem('clubId');
window.location.href = `/club/subscription`;
}, 3000);
}
}
return Promise.reject(error);
}
);
+7 -2
View File
@@ -19,8 +19,13 @@ export default {
const response = await apiClient.post('/api/club/events', { clubId });
return response.data;
} catch (error) {
if (error.response && error.response.data && error.response.data.message) {
throw new Error(error.response.data.message);
// 원래 오류 객체 그대로 전달하여 HTTP 상태 코드 확인 가능하게 함
if (error.response) {
console.error('이벤트 목록 오류:', {
status: error.response.status,
data: error.response.data
});
throw error;
}
throw error;
}
+57 -4
View File
@@ -6,7 +6,9 @@ export const useClubStore = defineStore('club', {
clubs: [],
selectedClub: null,
loading: false,
error: null
error: null,
subscription: null,
subscriptionFeatures: []
}),
actions: {
@@ -22,13 +24,43 @@ export const useClubStore = defineStore('club', {
this.loading = false;
}
},
// 클럽의 구독 정보 가져오기
async fetchClubSubscription(clubId) {
try {
const response = await apiClient.post('/api/subscriptions/clubs', { clubId });
this.subscription = response.data;
// 구독 기능 정보 처리
if (response.data && response.data.SubscriptionPlan && response.data.SubscriptionPlan.Features) {
this.subscriptionFeatures = response.data.SubscriptionPlan.Features;
}
return response.data;
} catch (error) {
console.error('구독 정보를 가져오는 중 오류가 발생했습니다:', error);
this.subscription = null;
this.subscriptionFeatures = [];
return null;
}
},
async fetchClubById(clubId) {
this.loading = true;
try {
const response = await apiClient.post(`/api/club`, { clubId });
this.selectedClub = response.data;
return response.data;
// 클럽 정보와 구독 정보를 동시에 가져오기
const [clubResponse, subscriptionResponse] = await Promise.all([
apiClient.post(`/api/club`, { clubId }),
this.fetchClubSubscription(clubId)
]);
// 클럽 정보와 구독 정보 합치기
const clubData = clubResponse.data;
if (subscriptionResponse) {
clubData.subscription = subscriptionResponse;
clubData.subscriptionFeatures = subscriptionResponse.SubscriptionPlan?.Features || [];
}
this.selectedClub = clubData;
return clubData;
} catch (error) {
this.error = error.response?.data?.message || '클럽 정보를 가져오는데 실패했습니다.';
throw error;
@@ -44,5 +76,26 @@ export const useClubStore = defineStore('club', {
clearSelectedClub() {
this.selectedClub = null;
}
},
getters: {
// 현재 구독 정보 가져오기
getCurrentSubscription() {
return this.subscription;
},
// 특정 기능 사용 권한 확인
hasFeature() {
return (featureCode) => {
if (!this.subscriptionFeatures || this.subscriptionFeatures.length === 0) {
return false;
}
return this.subscriptionFeatures.some(feature =>
feature.code === featureCode && feature.status === 'active'
);
};
},
}
});
+20 -10
View File
@@ -83,6 +83,7 @@
:getStatusSeverity="getStatusSeverity"
:formatDate="formatDate"
:availableMembers="clubMembers"
:hasTeamFeature="hasTeamFeature"
@edit="editFromDetails"
@hide="hideDialog"
@participant-updated="fetchEvents"
@@ -105,11 +106,12 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useRoute } from 'vue-router';
import { useClubStore } from '@/stores/clubStore';
import eventService from '@/services/eventService';
import apiClient from '@/services/api';
import dayjs from 'dayjs';
import clubService from '@/services/clubService';
import ScoreManagement from '@/components/event/ScoreManagement.vue';
@@ -142,6 +144,9 @@ const filters = ref({
startDate: { value: null, matchMode: 'equals' }
});
// 팀 기능 사용 권한 여부
const hasTeamFeature = computed(() => clubStore.hasFeature('team_create'));
// 이벤트 유형 옵션
const eventTypeOptions = [
{ name: '정기전', value: '정기전' },
@@ -156,17 +161,22 @@ const clubMembers = ref([]);
// 이벤트 목록 가져오기
const fetchEvents = async () => {
loading.value = true;
try {
const response = await eventService.getEvents(clubId.value);
events.value = response;
loading.value = true;
const data = await eventService.getEvents(clubId.value);
events.value = data;
} catch (error) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 목록을 가져오는데 실패했습니다.',
life: 3000
});
console.error('이벤트 목록 로드 실패:', error);
// 403 오류는 중앙에서 처리하민로 여기서는 기타 오류만 처리
if (!error.response) {
toast.add({
severity: 'error',
summary: '오류',
detail: '이벤트 목록을 불러오는 데 실패했습니다.',
life: 3000
});
}
} finally {
loading.value = false;
}
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="payment-result fail">
<div class="result-container">
<div class="icon-container">
<i class="pi pi-times-circle"></i>
</div>
<h1>결제가 취소되었습니다</h1>
<p v-if="message">{{ message }}</p>
<div class="buttons">
<button class="primary-btn" @click="goToSubscription">구독 관리로 이동</button>
<button class="secondary-btn" @click="goToHome">홈으로 이동</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
const message = ref('');
onMounted(async () => {
// URL에서 메시지 가져오기
message.value = route.query.message || '결제가 취소되었습니다.';
// 부모 창에 결제 실패 메시지 전달
if (window.opener && window.opener.handlePaymentFail) {
window.opener.handlePaymentFail(message.value);
// 3초 후 창 닫기
setTimeout(() => {
window.close();
}, 3000);
}
});
const goToSubscription = () => {
if (window.opener) {
window.opener.location.href = '/club/subscription';
window.close();
} else {
router.push('/club/subscription');
}
};
const goToHome = () => {
if (window.opener) {
window.opener.location.href = '/';
window.close();
} else {
router.push('/');
}
};
</script>
<style scoped>
.payment-result {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.result-container {
background-color: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 40px;
text-align: center;
max-width: 500px;
width: 100%;
}
.icon-container {
margin-bottom: 20px;
}
.fail .icon-container i {
font-size: 80px;
color: #f44336;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
color: #333;
}
p {
color: #666;
margin-bottom: 30px;
}
.buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.primary-btn, .secondary-btn {
padding: 10px 20px;
border-radius: 5px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.primary-btn {
background-color: #4caf50;
color: white;
border: none;
}
.primary-btn:hover {
background-color: #43a047;
transform: translateY(-2px);
}
.secondary-btn {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.secondary-btn:hover {
background-color: #e9e9e9;
transform: translateY(-2px);
}
</style>
@@ -0,0 +1,133 @@
<template>
<div class="payment-result success">
<div class="result-container">
<div class="icon-container">
<i class="pi pi-check-circle"></i>
</div>
<h1>결제가 완료되었습니다</h1>
<p>주문번호: {{ orderId }}</p>
<div class="buttons">
<button class="primary-btn" @click="goToSubscription">구독 관리로 이동</button>
<button class="secondary-btn" @click="goToHome">홈으로 이동</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import apiClient from '@/services/api';
const router = useRouter();
const route = useRoute();
const orderId = ref('');
onMounted(async () => {
// URL에서 주문 ID 가져오기
orderId.value = route.query.orderId || '';
// 부모 창에 결제 성공 메시지 전달
if (window.opener && window.opener.handlePaymentSuccess) {
window.opener.handlePaymentSuccess(orderId.value);
// 3초 후 창 닫기
setTimeout(() => {
window.close();
}, 3000);
}
});
const goToSubscription = () => {
if (window.opener) {
window.opener.location.href = '/club/subscription';
window.close();
} else {
router.push('/club/subscription');
}
};
const goToHome = () => {
if (window.opener) {
window.opener.location.href = '/';
window.close();
} else {
router.push('/');
}
};
</script>
<style scoped>
.payment-result {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f8f9fa;
}
.result-container {
background-color: white;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 40px;
text-align: center;
max-width: 500px;
width: 100%;
}
.icon-container {
margin-bottom: 20px;
}
.success .icon-container i {
font-size: 80px;
color: #4caf50;
}
h1 {
font-size: 24px;
margin-bottom: 20px;
color: #333;
}
p {
color: #666;
margin-bottom: 30px;
}
.buttons {
display: flex;
justify-content: center;
gap: 15px;
}
.primary-btn, .secondary-btn {
padding: 10px 20px;
border-radius: 5px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.primary-btn {
background-color: #4caf50;
color: white;
border: none;
}
.primary-btn:hover {
background-color: #43a047;
transform: translateY(-2px);
}
.secondary-btn {
background-color: #f5f5f5;
color: #333;
border: 1px solid #ddd;
}
.secondary-btn:hover {
background-color: #e9e9e9;
transform: translateY(-2px);
}
</style>