결제, 구독 기능
This commit is contained in:
+30
-12
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user