feat: Complete admin panel implementation
- Add user management system with all CRUD operations - Add promotion statistics dashboard with export - Simplify Trading & Market settings UI - Fix promotion schema (dates now optional) - Add missing API endpoints and PATCH support - Add comprehensive documentation - Fix critical bugs (deletePromotion, duplicate endpoints) All features tested and production-ready.
This commit is contained in:
@@ -1,37 +1,47 @@
|
||||
<script setup>
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useWebSocketStore } from '@/stores/websocket'
|
||||
import { useMarketStore } from '@/stores/market'
|
||||
import NavBar from '@/components/NavBar.vue'
|
||||
import Footer from '@/components/Footer.vue'
|
||||
import { computed, onMounted, onUnmounted } from "vue";
|
||||
import { RouterView, useRoute } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useWebSocketStore } from "@/stores/websocket";
|
||||
import { useMarketStore } from "@/stores/market";
|
||||
import NavBar from "@/components/NavBar.vue";
|
||||
import Footer from "@/components/Footer.vue";
|
||||
import AnnouncementBanner from "@/components/AnnouncementBanner.vue";
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const wsStore = useWebSocketStore()
|
||||
const marketStore = useMarketStore()
|
||||
const authStore = useAuthStore();
|
||||
const wsStore = useWebSocketStore();
|
||||
const marketStore = useMarketStore();
|
||||
const route = useRoute();
|
||||
|
||||
// Hide layout components on maintenance and banned pages
|
||||
const showLayout = computed(
|
||||
() => route.name !== "Maintenance" && route.name !== "Banned"
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize authentication
|
||||
await authStore.initialize()
|
||||
await authStore.initialize();
|
||||
|
||||
// Connect WebSocket
|
||||
wsStore.connect()
|
||||
wsStore.connect();
|
||||
|
||||
// Setup market WebSocket listeners
|
||||
marketStore.setupWebSocketListeners()
|
||||
})
|
||||
marketStore.setupWebSocketListeners();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Disconnect WebSocket on app unmount
|
||||
wsStore.disconnect()
|
||||
})
|
||||
wsStore.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app" class="min-h-screen flex flex-col bg-mesh-gradient">
|
||||
<!-- Navigation Bar -->
|
||||
<NavBar />
|
||||
<NavBar v-if="showLayout" />
|
||||
|
||||
<!-- Announcements -->
|
||||
<AnnouncementBanner v-if="showLayout" />
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
@@ -43,16 +53,16 @@ onUnmounted(() => {
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<Footer />
|
||||
<Footer v-if="showLayout" />
|
||||
|
||||
<!-- Connection Status Indicator (bottom right) -->
|
||||
<div
|
||||
v-if="!wsStore.isConnected"
|
||||
v-if="!wsStore.isConnected && showLayout"
|
||||
class="fixed bottom-4 right-4 z-50 px-4 py-2 bg-accent-red/90 backdrop-blur-sm text-white rounded-lg shadow-lg flex items-center gap-2 animate-pulse"
|
||||
>
|
||||
<div class="w-2 h-2 rounded-full bg-white"></div>
|
||||
<span class="text-sm font-medium">
|
||||
{{ wsStore.isConnecting ? 'Connecting...' : 'Disconnected' }}
|
||||
{{ wsStore.isConnecting ? "Connecting..." : "Disconnected" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,7 +70,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
font-family: "Inter", system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
2004
frontend/src/components/AdminConfigPanel.vue
Normal file
2004
frontend/src/components/AdminConfigPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
1296
frontend/src/components/AdminDebugPanel.vue
Normal file
1296
frontend/src/components/AdminDebugPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
1350
frontend/src/components/AdminUsersPanel.vue
Normal file
1350
frontend/src/components/AdminUsersPanel.vue
Normal file
File diff suppressed because it is too large
Load Diff
258
frontend/src/components/AnnouncementBanner.vue
Normal file
258
frontend/src/components/AnnouncementBanner.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="activeAnnouncements.length > 0"
|
||||
class="announcements-wrapper"
|
||||
data-component="announcement-banner"
|
||||
>
|
||||
<div
|
||||
v-for="announcement in activeAnnouncements"
|
||||
:key="announcement.id"
|
||||
class="announcement-banner"
|
||||
>
|
||||
<div class="announcement-content">
|
||||
<p class="announcement-message">{{ announcement.message }}</p>
|
||||
<button
|
||||
v-if="announcement.dismissible"
|
||||
@click="dismissAnnouncement(announcement.id)"
|
||||
class="dismiss-btn"
|
||||
aria-label="Dismiss announcement"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { X } from "lucide-vue-next";
|
||||
import axios from "@/utils/axios";
|
||||
|
||||
const announcements = ref([]);
|
||||
const dismissedIds = ref([]);
|
||||
|
||||
// Load dismissed IDs from localStorage
|
||||
onMounted(async () => {
|
||||
console.log("🔵 AnnouncementBanner mounted - loading announcements...");
|
||||
await loadAnnouncements();
|
||||
const stored = localStorage.getItem("dismissedAnnouncements");
|
||||
if (stored) {
|
||||
try {
|
||||
dismissedIds.value = JSON.parse(stored);
|
||||
console.log("📋 Dismissed announcements:", dismissedIds.value);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse dismissed announcements", e);
|
||||
}
|
||||
}
|
||||
console.log("🔵 After mount - announcements:", announcements.value);
|
||||
console.log(
|
||||
"🔵 After mount - activeAnnouncements:",
|
||||
activeAnnouncements.value
|
||||
);
|
||||
});
|
||||
|
||||
const loadAnnouncements = async () => {
|
||||
try {
|
||||
console.log("📡 Fetching announcements from /api/config/announcements...");
|
||||
const response = await axios.get("/api/config/announcements");
|
||||
console.log("📡 Response:", response);
|
||||
if (response.data.success) {
|
||||
announcements.value = response.data.announcements;
|
||||
console.log("✅ Loaded announcements:", announcements.value);
|
||||
console.log(
|
||||
"✅ Active announcements (computed):",
|
||||
activeAnnouncements.value
|
||||
);
|
||||
console.log("✅ Will render:", activeAnnouncements.value.length > 0);
|
||||
} else {
|
||||
console.warn("⚠️ Announcements request succeeded but success=false");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to load announcements:", error);
|
||||
console.error("Error details:", error.response?.data || error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const activeAnnouncements = computed(() => {
|
||||
return announcements.value.filter((announcement) => {
|
||||
// Filter out dismissed announcements
|
||||
if (dismissedIds.value.includes(announcement.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// API already filters by enabled status, so we don't need to check it here
|
||||
// The backend only returns enabled announcements via getActiveAnnouncements()
|
||||
|
||||
// Check if announcement is within date range (if dates are provided)
|
||||
const now = new Date();
|
||||
|
||||
if (announcement.startDate) {
|
||||
const start = new Date(announcement.startDate);
|
||||
if (now < start) {
|
||||
return false; // Not started yet
|
||||
}
|
||||
}
|
||||
|
||||
if (announcement.endDate) {
|
||||
const end = new Date(announcement.endDate);
|
||||
if (now > end) {
|
||||
return false; // Already ended
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const dismissAnnouncement = (id) => {
|
||||
dismissedIds.value.push(id);
|
||||
localStorage.setItem(
|
||||
"dismissedAnnouncements",
|
||||
JSON.stringify(dismissedIds.value)
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcements-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.announcement-banner {
|
||||
width: 100%;
|
||||
min-height: 52px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(59, 130, 246, 0.15);
|
||||
animation: slideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0.875rem 2rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.announcement-message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
letter-spacing: 0.01em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.dismiss-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Hover effect on entire banner */
|
||||
.announcement-banner::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0);
|
||||
transition: background 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.announcement-banner:hover::before {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.announcement-content {
|
||||
padding: 0.75rem 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.announcement-message {
|
||||
font-size: 0.9375rem;
|
||||
padding-right: 2.5rem;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
right: 1rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens */
|
||||
@media (max-width: 480px) {
|
||||
.announcement-content {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.announcement-message {
|
||||
font-size: 0.875rem;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
right: 0.75rem;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Multiple announcements stacking */
|
||||
.announcement-banner + .announcement-banner {
|
||||
border-top: 1px solid rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
</style>
|
||||
348
frontend/src/components/ConfirmModal.vue
Normal file
348
frontend/src/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="onCancel">
|
||||
<div class="modal-container" :class="typeClass">
|
||||
<div class="modal-header">
|
||||
<div class="modal-icon">
|
||||
<component :is="iconComponent" :size="32" />
|
||||
</div>
|
||||
<h3 class="modal-title">{{ title }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<p class="modal-message">{{ message }}</p>
|
||||
<div v-if="details" class="modal-details">
|
||||
{{ details }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button
|
||||
@click="onCancel"
|
||||
class="btn btn-secondary"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
@click="onConfirm"
|
||||
class="btn btn-danger"
|
||||
:class="{ loading: loading }"
|
||||
:disabled="loading"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<span>{{ loading ? loadingText : confirmText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { AlertTriangle, Trash2, XCircle, Info, Loader2 } from 'lucide-vue-next';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Confirm Action',
|
||||
},
|
||||
message: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
details: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
confirmText: {
|
||||
type: String,
|
||||
default: 'Confirm',
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
default: 'Cancel',
|
||||
},
|
||||
loadingText: {
|
||||
type: String,
|
||||
default: 'Processing...',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'danger', // danger, warning, info
|
||||
validator: (value) => ['danger', 'warning', 'info'].includes(value),
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
|
||||
|
||||
const typeClass = computed(() => `modal-${props.type}`);
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
switch (props.type) {
|
||||
case 'danger':
|
||||
return Trash2;
|
||||
case 'warning':
|
||||
return AlertTriangle;
|
||||
case 'info':
|
||||
return Info;
|
||||
default:
|
||||
return XCircle;
|
||||
}
|
||||
});
|
||||
|
||||
const onConfirm = () => {
|
||||
if (!props.loading) {
|
||||
emit('confirm');
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
if (!props.loading) {
|
||||
emit('update:modelValue', false);
|
||||
emit('cancel');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1rem auto;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.modal-danger .modal-icon {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
border: 2px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.modal-warning .modal-icon {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #f59e0b;
|
||||
border: 2px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.modal-info .modal-icon {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #3b82f6;
|
||||
border: 2px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem 2rem 1.5rem 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
font-size: 1rem;
|
||||
color: #d1d5db;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-details {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #e5e7eb;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
border: 1px solid rgba(239, 68, 68, 0.5);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-danger:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.btn-danger.loading {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%);
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Transition animations */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-container,
|
||||
.modal-leave-active .modal-container {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-container,
|
||||
.modal-leave-to .modal-container {
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 640px) {
|
||||
.modal-container {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem 1.5rem 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0.75rem 1.5rem 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
814
frontend/src/components/PromotionStatsModal.vue
Normal file
814
frontend/src/components/PromotionStatsModal.vue
Normal file
@@ -0,0 +1,814 @@
|
||||
<template>
|
||||
<div v-if="show" class="modal-overlay" @click.self="$emit('close')">
|
||||
<div class="modal-content modal-large">
|
||||
<div class="modal-header">
|
||||
<h2>Promotion Statistics</h2>
|
||||
<button class="close-btn" @click="$emit('close')" aria-label="Close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div v-if="loading" class="loading">
|
||||
<Loader2 :size="32" class="animate-spin" />
|
||||
<p>Loading statistics...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<AlertCircle :size="48" />
|
||||
<p>{{ error }}</p>
|
||||
<button class="btn btn-primary" @click="loadStats">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="stats" class="stats-container">
|
||||
<!-- Promotion Info -->
|
||||
<div class="promo-info-card">
|
||||
<h3>{{ promotion.name }}</h3>
|
||||
<p class="promo-description">{{ promotion.description }}</p>
|
||||
<div class="promo-meta">
|
||||
<span class="type-badge" :class="`type-${promotion.type}`">
|
||||
{{ formatPromoType(promotion.type) }}
|
||||
</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="promotion.enabled ? 'status-active' : 'status-inactive'"
|
||||
>
|
||||
{{ promotion.enabled ? "Active" : "Inactive" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<Users :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Total Uses</p>
|
||||
<p class="stat-value">{{ stats.totalUses || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<UserCheck :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Unique Users</p>
|
||||
<p class="stat-value">{{ stats.uniqueUsers || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<DollarSign :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Total Bonus Given</p>
|
||||
<p class="stat-value">${{ formatNumber(stats.totalBonusGiven || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<TrendingUp :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Avg Bonus Per Use</p>
|
||||
<p class="stat-value">${{ formatNumber(stats.averageBonusPerUse || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<Percent :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Total Discount Given</p>
|
||||
<p class="stat-value">${{ formatNumber(stats.totalDiscountGiven || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">
|
||||
<Target :size="24" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<p class="stat-label">Usage Rate</p>
|
||||
<p class="stat-value">
|
||||
{{ calculateUsageRate() }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Promotion Details -->
|
||||
<div class="details-section">
|
||||
<h4>Promotion Details</h4>
|
||||
<div class="details-grid">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Start Date:</span>
|
||||
<span class="detail-value">{{ formatDate(promotion.startDate) }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">End Date:</span>
|
||||
<span class="detail-value">{{ formatDate(promotion.endDate) }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.bonusPercentage">
|
||||
<span class="detail-label">Bonus Percentage:</span>
|
||||
<span class="detail-value">{{ promotion.bonusPercentage }}%</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.bonusAmount">
|
||||
<span class="detail-label">Bonus Amount:</span>
|
||||
<span class="detail-value">${{ promotion.bonusAmount }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.minDeposit">
|
||||
<span class="detail-label">Min Deposit:</span>
|
||||
<span class="detail-value">${{ promotion.minDeposit }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.maxBonus">
|
||||
<span class="detail-label">Max Bonus:</span>
|
||||
<span class="detail-value">${{ promotion.maxBonus }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.discountPercentage">
|
||||
<span class="detail-label">Discount:</span>
|
||||
<span class="detail-value">{{ promotion.discountPercentage }}%</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Max Uses Per User:</span>
|
||||
<span class="detail-value">{{ promotion.maxUsesPerUser || 'Unlimited' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Max Total Uses:</span>
|
||||
<span class="detail-value">{{ promotion.maxTotalUses || 'Unlimited' }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">New Users Only:</span>
|
||||
<span class="detail-value">{{ promotion.newUsersOnly ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="detail-row" v-if="promotion.code">
|
||||
<span class="detail-label">Promo Code:</span>
|
||||
<span class="detail-value promo-code">{{ promotion.code }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Created By:</span>
|
||||
<span class="detail-value">{{ promotion.createdBy }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Usage -->
|
||||
<div class="usage-section">
|
||||
<div class="section-header">
|
||||
<h4>Recent Usage</h4>
|
||||
<button class="btn btn-sm btn-secondary" @click="refreshUsage">
|
||||
<RefreshCw :size="16" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingUsage" class="loading-small">
|
||||
<Loader2 :size="24" class="animate-spin" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="usageList && usageList.length > 0" class="usage-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Bonus Amount</th>
|
||||
<th>Discount Amount</th>
|
||||
<th>Deposit Amount</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="usage in usageList" :key="usage._id">
|
||||
<td>
|
||||
<div class="user-cell">
|
||||
<User :size="16" />
|
||||
<span>{{ usage.userId?.username || 'Unknown' }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="amount-cell">${{ formatNumber(usage.bonusAmount || 0) }}</td>
|
||||
<td class="amount-cell">${{ formatNumber(usage.discountAmount || 0) }}</td>
|
||||
<td class="amount-cell">${{ formatNumber(usage.depositAmount || 0) }}</td>
|
||||
<td>{{ formatDateTime(usage.usedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div v-if="pagination.hasMore" class="load-more">
|
||||
<button class="btn btn-secondary" @click="loadMoreUsage">
|
||||
Load More
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="no-data">
|
||||
<TrendingDown :size="48" />
|
||||
<p>No usage data yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-secondary" @click="exportStats">
|
||||
<Download :size="16" />
|
||||
Export Data
|
||||
</button>
|
||||
<button class="btn btn-primary" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from "vue";
|
||||
import axios from "../utils/axios";
|
||||
import { useToast } from "vue-toastification";
|
||||
import {
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Users,
|
||||
UserCheck,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Percent,
|
||||
Target,
|
||||
User,
|
||||
RefreshCw,
|
||||
Download,
|
||||
TrendingDown,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
promotion: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
const toast = useToast();
|
||||
|
||||
const loading = ref(false);
|
||||
const loadingUsage = ref(false);
|
||||
const error = ref(null);
|
||||
const stats = ref(null);
|
||||
const usageList = ref([]);
|
||||
const pagination = ref({
|
||||
limit: 20,
|
||||
skip: 0,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
// Load stats when modal is shown
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
loadStats();
|
||||
loadUsage();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const loadStats = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await axios.get(`/api/admin/promotions/${props.promotion.id}/stats`);
|
||||
if (response.data.success) {
|
||||
stats.value = response.data.stats;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load promotion stats:", err);
|
||||
error.value = err.response?.data?.message || "Failed to load statistics";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadUsage = async (append = false) => {
|
||||
loadingUsage.value = true;
|
||||
try {
|
||||
const response = await axios.get(`/api/admin/promotions/${props.promotion.id}/usage`, {
|
||||
params: {
|
||||
limit: pagination.value.limit,
|
||||
skip: append ? pagination.value.skip : 0,
|
||||
},
|
||||
});
|
||||
if (response.data.success) {
|
||||
if (append) {
|
||||
usageList.value.push(...response.data.usages);
|
||||
} else {
|
||||
usageList.value = response.data.usages;
|
||||
}
|
||||
pagination.value = response.data.pagination;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load usage data:", err);
|
||||
toast.error("Failed to load usage data");
|
||||
} finally {
|
||||
loadingUsage.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshUsage = () => {
|
||||
pagination.value.skip = 0;
|
||||
loadUsage(false);
|
||||
};
|
||||
|
||||
const loadMoreUsage = () => {
|
||||
pagination.value.skip += pagination.value.limit;
|
||||
loadUsage(true);
|
||||
};
|
||||
|
||||
const calculateUsageRate = () => {
|
||||
if (!props.promotion.maxTotalUses) return 100;
|
||||
const rate = ((stats.value?.totalUses || 0) / props.promotion.maxTotalUses) * 100;
|
||||
return rate.toFixed(1);
|
||||
};
|
||||
|
||||
const formatPromoType = (type) => {
|
||||
const types = {
|
||||
deposit_bonus: "Deposit Bonus",
|
||||
discount: "Discount",
|
||||
free_item: "Free Item",
|
||||
custom: "Custom",
|
||||
};
|
||||
return types[type] || type;
|
||||
};
|
||||
|
||||
const formatNumber = (num) => {
|
||||
return Number(num).toFixed(2);
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return "N/A";
|
||||
return new Date(date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (date) => {
|
||||
if (!date) return "N/A";
|
||||
return new Date(date).toLocaleString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const exportStats = () => {
|
||||
try {
|
||||
const data = {
|
||||
promotion: {
|
||||
name: props.promotion.name,
|
||||
description: props.promotion.description,
|
||||
type: props.promotion.type,
|
||||
enabled: props.promotion.enabled,
|
||||
startDate: props.promotion.startDate,
|
||||
endDate: props.promotion.endDate,
|
||||
},
|
||||
statistics: stats.value,
|
||||
usageData: usageList.value,
|
||||
exportedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `promotion-${props.promotion.id}-stats-${Date.now()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success("Statistics exported successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to export stats:", err);
|
||||
toast.error("Failed to export statistics");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1d29;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #8b92a7;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
gap: 16px;
|
||||
color: #8b92a7;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.loading-small {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
color: #8b92a7;
|
||||
}
|
||||
|
||||
.stats-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.promo-info-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.promo-info-card h3 {
|
||||
margin: 0 0 8px 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.promo-description {
|
||||
color: #8b92a7;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.promo-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.type-deposit_bonus {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.type-discount {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.type-free_item {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
.type-custom {
|
||||
background: rgba(251, 191, 36, 0.2);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-inactive {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6366f1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin: 0;
|
||||
color: #8b92a7;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin: 4px 0 0 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.details-section h4 {
|
||||
margin: 0 0 16px 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #8b92a7;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.promo-code {
|
||||
font-family: monospace;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.usage-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header h4 {
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.usage-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.usage-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.usage-table th {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
color: #8b92a7;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.usage-table td {
|
||||
padding: 12px;
|
||||
color: #ffffff;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.amount-cell {
|
||||
font-family: monospace;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 20px;
|
||||
color: #8b92a7;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
padding: 24px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.813rem;
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.usage-table {
|
||||
font-size: 0.813rem;
|
||||
}
|
||||
|
||||
.usage-table th,
|
||||
.usage-table td {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
frontend/src/components/ToggleSwitch.vue
Normal file
167
frontend/src/components/ToggleSwitch.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<label class="toggle-switch" :class="{ disabled: disabled }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
:disabled="disabled"
|
||||
class="toggle-input"
|
||||
/>
|
||||
<span class="toggle-track">
|
||||
<span class="toggle-thumb"></span>
|
||||
<span class="toggle-labels">
|
||||
<span class="label-on">ON</span>
|
||||
<span class="label-off">OFF</span>
|
||||
</span>
|
||||
</span>
|
||||
<span v-if="label" class="toggle-label-text">{{ label }}</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toggle-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.toggle-switch.disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
position: relative;
|
||||
width: 60px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
border-radius: 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
}
|
||||
|
||||
.toggle-input:focus-visible + .toggle-track {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
left: 3px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track .toggle-thumb {
|
||||
transform: translateX(32px);
|
||||
}
|
||||
|
||||
.toggle-labels {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.label-on,
|
||||
.label-off {
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.label-on {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.label-off {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track .label-on {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track .label-off {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.toggle-label-text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.toggle-switch:hover:not(.disabled) .toggle-track {
|
||||
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2), 0 0 0 4px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.toggle-switch.disabled .toggle-track {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Animation for hover state */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-switch:active:not(.disabled) .toggle-thumb {
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.toggle-input:checked + .toggle-track .toggle-thumb:active {
|
||||
transform: translateX(28px);
|
||||
}
|
||||
</style>
|
||||
1380
frontend/src/components/UserManagementTab.vue
Normal file
1380
frontend/src/components/UserManagementTab.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,18 @@ const routes = [
|
||||
component: () => import("@/views/PrivacyPage.vue"),
|
||||
meta: { title: "Privacy Policy" },
|
||||
},
|
||||
{
|
||||
path: "/maintenance",
|
||||
name: "Maintenance",
|
||||
component: () => import("@/views/MaintenancePage.vue"),
|
||||
meta: { title: "Maintenance Mode" },
|
||||
},
|
||||
{
|
||||
path: "/banned",
|
||||
name: "Banned",
|
||||
component: () => import("@/views/BannedPage.vue"),
|
||||
meta: { title: "Account Suspended" },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
name: "NotFound",
|
||||
@@ -137,6 +149,48 @@ router.beforeEach(async (to, from, next) => {
|
||||
await authStore.initialize();
|
||||
}
|
||||
|
||||
// If on maintenance page and user is admin, redirect to home
|
||||
if (to.name === "Maintenance" && authStore.isAdmin) {
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is banned (except on banned page itself)
|
||||
if (authStore.isBanned && to.name !== "Banned") {
|
||||
next({ name: "Banned" });
|
||||
return;
|
||||
}
|
||||
|
||||
// If on banned page but user is not banned, redirect to home
|
||||
if (to.name === "Banned" && !authStore.isBanned) {
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check maintenance mode (skip for maintenance page itself)
|
||||
if (to.name !== "Maintenance") {
|
||||
try {
|
||||
const axios = (await import("@/utils/axios")).default;
|
||||
const response = await axios.get("/api/config/status");
|
||||
|
||||
if (response.data.maintenance) {
|
||||
// Allow admins to bypass maintenance
|
||||
if (!authStore.isAdmin) {
|
||||
next({ name: "Maintenance" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If we get a 503 maintenance error, redirect
|
||||
if (error.response && error.response.status === 503) {
|
||||
if (!authStore.isAdmin) {
|
||||
next({ name: "Maintenance" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check authentication requirement
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
next({ name: "Home", query: { redirect: to.fullPath } });
|
||||
@@ -149,11 +203,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user is banned
|
||||
if (authStore.isBanned && to.name !== "Home") {
|
||||
next({ name: "Home" });
|
||||
return;
|
||||
}
|
||||
// Banned check already handled above, no need to redirect to home
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import axios from "axios";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useToast } from "vue-toastification";
|
||||
|
||||
// Create axios instance
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || '/api',
|
||||
baseURL: import.meta.env.VITE_API_URL || "/api",
|
||||
timeout: 15000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
axiosInstance.interceptors.request.use(
|
||||
@@ -20,83 +20,97 @@ axiosInstance.interceptors.request.use(
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`
|
||||
// }
|
||||
return config
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const toast = useToast()
|
||||
const authStore = useAuthStore()
|
||||
const toast = useToast();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
const { status, data } = error.response;
|
||||
|
||||
switch (status) {
|
||||
case 401:
|
||||
// Unauthorized - token expired or invalid
|
||||
if (data.code === 'TokenExpired') {
|
||||
if (data.code === "TokenExpired") {
|
||||
// Try to refresh token
|
||||
try {
|
||||
const refreshed = await authStore.refreshToken()
|
||||
const refreshed = await authStore.refreshToken();
|
||||
if (refreshed) {
|
||||
// Retry the original request
|
||||
return axiosInstance.request(error.config)
|
||||
return axiosInstance.request(error.config);
|
||||
}
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, logout user
|
||||
authStore.clearUser()
|
||||
window.location.href = '/'
|
||||
authStore.clearUser();
|
||||
window.location.href = "/";
|
||||
}
|
||||
} else {
|
||||
authStore.clearUser()
|
||||
toast.error('Please login to continue')
|
||||
authStore.clearUser();
|
||||
toast.error("Please login to continue");
|
||||
}
|
||||
break
|
||||
break;
|
||||
|
||||
case 403:
|
||||
// Forbidden
|
||||
toast.error(data.message || 'Access denied')
|
||||
break
|
||||
toast.error(data.message || "Access denied");
|
||||
break;
|
||||
|
||||
case 404:
|
||||
// Not found
|
||||
toast.error(data.message || 'Resource not found')
|
||||
break
|
||||
toast.error(data.message || "Resource not found");
|
||||
break;
|
||||
|
||||
case 429:
|
||||
// Too many requests
|
||||
toast.error('Too many requests. Please slow down.')
|
||||
break
|
||||
toast.error("Too many requests. Please slow down.");
|
||||
break;
|
||||
|
||||
case 500:
|
||||
// Server error
|
||||
toast.error('Server error. Please try again later.')
|
||||
break
|
||||
toast.error("Server error. Please try again later.");
|
||||
break;
|
||||
|
||||
case 503:
|
||||
// Service unavailable - maintenance mode
|
||||
if (data.maintenance) {
|
||||
// Only redirect if user is not admin
|
||||
if (!authStore.isAdmin) {
|
||||
window.location.href = "/maintenance";
|
||||
}
|
||||
// Don't show toast for maintenance, page will handle it
|
||||
return Promise.reject(error);
|
||||
} else {
|
||||
toast.error("Service temporarily unavailable");
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Other errors
|
||||
if (data.message) {
|
||||
toast.error(data.message)
|
||||
toast.error(data.message);
|
||||
}
|
||||
}
|
||||
} else if (error.request) {
|
||||
// Request made but no response
|
||||
toast.error('Network error. Please check your connection.')
|
||||
toast.error("Network error. Please check your connection.");
|
||||
} else {
|
||||
// Something else happened
|
||||
toast.error('An unexpected error occurred')
|
||||
toast.error("An unexpected error occurred");
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default axiosInstance
|
||||
export default axiosInstance;
|
||||
|
||||
@@ -37,6 +37,21 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div v-if="activeTab === 'users'">
|
||||
<AdminUsersPanel />
|
||||
</div>
|
||||
|
||||
<!-- Config Tab -->
|
||||
<div v-if="activeTab === 'config'">
|
||||
<AdminConfigPanel />
|
||||
</div>
|
||||
|
||||
<!-- Debug Tab -->
|
||||
<div v-if="activeTab === 'debug'">
|
||||
<AdminDebugPanel />
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div v-if="activeTab === 'dashboard'" class="space-y-6">
|
||||
<!-- Quick Stats -->
|
||||
@@ -735,6 +750,9 @@ import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { useToast } from "vue-toastification";
|
||||
import axios from "../utils/axios";
|
||||
import AdminUsersPanel from "../components/AdminUsersPanel.vue";
|
||||
import AdminConfigPanel from "../components/AdminConfigPanel.vue";
|
||||
import AdminDebugPanel from "../components/AdminDebugPanel.vue";
|
||||
import {
|
||||
Shield,
|
||||
RefreshCw,
|
||||
@@ -819,9 +837,12 @@ const isSavingPrice = ref(false);
|
||||
// Tabs
|
||||
const tabs = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ id: "users", label: "Users", icon: Users },
|
||||
{ id: "config", label: "Config", icon: Shield },
|
||||
{ id: "financial", label: "Financial", icon: DollarSign },
|
||||
{ id: "transactions", label: "Transactions", icon: Activity },
|
||||
{ id: "items", label: "Items", icon: Box },
|
||||
{ id: "debug", label: "Debug", icon: Shield },
|
||||
];
|
||||
|
||||
// Games
|
||||
|
||||
251
frontend/src/views/AdminPanelTest.vue
Normal file
251
frontend/src/views/AdminPanelTest.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="admin-test-page">
|
||||
<div class="test-container">
|
||||
<h1>Admin Panel Button Test</h1>
|
||||
<p>This is a simple test page to verify button functionality</p>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Trading Settings</h2>
|
||||
<div class="form-group">
|
||||
<label>Enable Trading</label>
|
||||
<input type="checkbox" v-model="testTrading.enabled" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Min Deposit</label>
|
||||
<input type="number" v-model.number="testTrading.minDeposit" />
|
||||
</div>
|
||||
<button @click="testSaveTrading" class="test-btn">
|
||||
Save Trading (Console Log)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test Market Settings</h2>
|
||||
<div class="form-group">
|
||||
<label>Enable Market</label>
|
||||
<input type="checkbox" v-model="testMarket.enabled" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Commission</label>
|
||||
<input type="number" v-model.number="testMarket.commission" step="0.01" />
|
||||
</div>
|
||||
<button @click="testSaveMarket" class="test-btn">
|
||||
Save Market (Console Log)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>Test API Call</h2>
|
||||
<button @click="testApiCall" class="test-btn" :disabled="loading">
|
||||
{{ loading ? 'Loading...' : 'Test API Call' }}
|
||||
</button>
|
||||
<div v-if="apiResponse" class="response">
|
||||
<pre>{{ JSON.stringify(apiResponse, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="console-output">
|
||||
<h3>Console Output:</h3>
|
||||
<div class="output-box">
|
||||
<p v-for="(log, index) in logs" :key="index">{{ log }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import axios from '../utils/axios';
|
||||
|
||||
const testTrading = ref({
|
||||
enabled: true,
|
||||
minDeposit: 0.1,
|
||||
});
|
||||
|
||||
const testMarket = ref({
|
||||
enabled: true,
|
||||
commission: 0.1,
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const apiResponse = ref(null);
|
||||
const logs = ref([]);
|
||||
|
||||
function addLog(message) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
logs.value.push(`[${timestamp}] ${message}`);
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function testSaveTrading() {
|
||||
addLog('🔧 Test Save Trading clicked!');
|
||||
addLog(`Trading data: ${JSON.stringify(testTrading.value)}`);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
addLog('✅ Trading save would be called with this data');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function testSaveMarket() {
|
||||
addLog('🔧 Test Save Market clicked!');
|
||||
addLog(`Market data: ${JSON.stringify(testMarket.value)}`);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
addLog('✅ Market save would be called with this data');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function testApiCall() {
|
||||
addLog('🌐 Testing API call to /admin/config...');
|
||||
loading.value = true;
|
||||
apiResponse.value = null;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/admin/config');
|
||||
addLog('✅ API call successful!');
|
||||
apiResponse.value = response.data;
|
||||
} catch (error) {
|
||||
addLog(`❌ API call failed: ${error.message}`);
|
||||
apiResponse.value = { error: error.message, details: error.response?.data };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-test-page {
|
||||
min-height: 100vh;
|
||||
background: #0f1419;
|
||||
color: #ffffff;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.test-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #6366f1;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #8b92a7;
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #ffffff;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #8b92a7;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input[type="number"],
|
||||
.form-group input[type="text"] {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
padding: 12px 24px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.test-btn:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
}
|
||||
|
||||
.test-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.console-output {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
background: #000000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.813rem;
|
||||
}
|
||||
|
||||
.output-box p {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.response {
|
||||
margin-top: 16px;
|
||||
background: #000000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.response pre {
|
||||
margin: 0;
|
||||
color: #22c55e;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.813rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
531
frontend/src/views/BannedPage.vue
Normal file
531
frontend/src/views/BannedPage.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<template>
|
||||
<div class="banned-page">
|
||||
<div class="banned-container">
|
||||
<!-- Icon -->
|
||||
<div class="icon-wrapper">
|
||||
<ShieldAlert class="banned-icon" />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="banned-title">Account Suspended</h1>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="banned-message">
|
||||
Your account has been suspended due to a violation of our Terms of
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<!-- Ban Details -->
|
||||
<div v-if="banInfo" class="ban-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Reason:</span>
|
||||
<span class="detail-value">{{
|
||||
banInfo.reason || "Violation of Terms of Service"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="banInfo.bannedAt" class="detail-item">
|
||||
<span class="detail-label">Banned on:</span>
|
||||
<span class="detail-value">{{ formatDate(banInfo.bannedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="banInfo.bannedUntil" class="detail-item">
|
||||
<span class="detail-label">Ban expires:</span>
|
||||
<span class="detail-value">{{
|
||||
formatDate(banInfo.bannedUntil)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-item permanent-ban">
|
||||
<AlertCircle :size="18" />
|
||||
<span>This is a permanent ban</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="info-box">
|
||||
<Info :size="20" class="info-icon" />
|
||||
<div class="info-content">
|
||||
<p class="info-title">What does this mean?</p>
|
||||
<p class="info-text">
|
||||
You will not be able to access your account, make trades, or use the
|
||||
marketplace while your account is suspended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appeal Section -->
|
||||
<div class="appeal-section">
|
||||
<p class="appeal-text">
|
||||
If you believe this ban was made in error, you can submit an appeal.
|
||||
</p>
|
||||
<a href="/support" class="appeal-btn">
|
||||
<Mail :size="20" />
|
||||
<span>Contact Support</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button @click="handleLogout" class="logout-btn">
|
||||
<LogOut :size="20" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="banned-footer">
|
||||
<p>For more information, please review our</p>
|
||||
<div class="footer-links">
|
||||
<a href="/terms" class="footer-link">Terms of Service</a>
|
||||
<span class="separator">•</span>
|
||||
<a href="/privacy" class="footer-link">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="social-links">
|
||||
<a
|
||||
:href="socialLinks.twitter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
:href="socialLinks.discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { ShieldAlert, AlertCircle, Info, Mail, LogOut } from "lucide-vue-next";
|
||||
import axios from "@/utils/axios";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const socialLinks = ref({
|
||||
twitter: "https://x.com",
|
||||
discord: "https://discord.gg",
|
||||
});
|
||||
|
||||
const banInfo = computed(() => {
|
||||
if (!authStore.user) return null;
|
||||
|
||||
return {
|
||||
reason: authStore.user.ban?.reason,
|
||||
bannedAt: authStore.user.ban?.bannedAt,
|
||||
bannedUntil: authStore.user.ban?.bannedUntil,
|
||||
isPermanent:
|
||||
authStore.user.ban?.permanent || !authStore.user.ban?.bannedUntil,
|
||||
};
|
||||
});
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return "";
|
||||
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout();
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const fetchSocialLinks = async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/config/public");
|
||||
if (response.data.success && response.data.config.social) {
|
||||
const social = response.data.config.social;
|
||||
if (social.twitter) {
|
||||
socialLinks.value.twitter = social.twitter;
|
||||
}
|
||||
if (social.discord) {
|
||||
socialLinks.value.discord = social.discord;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch social links:", error);
|
||||
// Keep default values if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// If user is not banned, redirect to home
|
||||
if (!authStore.isBanned) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
// Fetch social links
|
||||
fetchSocialLinks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.banned-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banned-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(
|
||||
circle at 20% 50%,
|
||||
rgba(239, 68, 68, 0.1) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 80%,
|
||||
rgba(220, 38, 38, 0.1) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.banned-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background: rgba(30, 30, 46, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 1.5rem;
|
||||
padding: 3rem 2rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.banned-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #ef4444;
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
.banned-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #ef4444;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.banned-message {
|
||||
font-size: 1.125rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ban-details {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.9375rem;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.permanent-ban {
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
flex-shrink: 0;
|
||||
color: #3b82f6;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 0.875rem;
|
||||
color: #d1d5db;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.appeal-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.appeal-text {
|
||||
font-size: 0.9375rem;
|
||||
color: #d1d5db;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.appeal-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
border: 1px solid rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.appeal-btn:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(59, 130, 246, 0.5);
|
||||
}
|
||||
|
||||
.appeal-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #d1d5db;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.banned-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.banned-footer p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: #60a5fa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.banned-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.banned-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.banned-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.appeal-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
510
frontend/src/views/MaintenancePage.vue
Normal file
510
frontend/src/views/MaintenancePage.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<template>
|
||||
<div class="maintenance-page">
|
||||
<div class="maintenance-container">
|
||||
<!-- Icon -->
|
||||
<div class="icon-wrapper">
|
||||
<Settings class="maintenance-icon" />
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h1 class="maintenance-title">We'll Be Right Back!</h1>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="maintenance-message">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- Countdown Timer (if scheduled end time exists) -->
|
||||
<div v-if="scheduledEnd && timeRemaining" class="countdown-section">
|
||||
<p class="countdown-label">Estimated completion time:</p>
|
||||
<div class="countdown-timer">
|
||||
<div class="time-unit">
|
||||
<span class="time-value">{{ timeRemaining.hours }}</span>
|
||||
<span class="time-label">Hours</span>
|
||||
</div>
|
||||
<span class="time-separator">:</span>
|
||||
<div class="time-unit">
|
||||
<span class="time-value">{{ timeRemaining.minutes }}</span>
|
||||
<span class="time-label">Minutes</span>
|
||||
</div>
|
||||
<span class="time-separator">:</span>
|
||||
<div class="time-unit">
|
||||
<span class="time-value">{{ timeRemaining.seconds }}</span>
|
||||
<span class="time-label">Seconds</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="countdown-end">{{ scheduledEndFormatted }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading Animation -->
|
||||
<div class="loading-dots">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Info -->
|
||||
<div class="maintenance-footer">
|
||||
<p>We apologize for any inconvenience.</p>
|
||||
<p>Please check back soon!</p>
|
||||
</div>
|
||||
|
||||
<!-- Steam Login Button -->
|
||||
<div class="login-section">
|
||||
<p class="login-prompt">Admin? Login to access the site</p>
|
||||
<a :href="steamLoginUrl" class="steam-login-btn">
|
||||
<svg class="steam-icon" viewBox="0 0 256 256" fill="currentColor">
|
||||
<path
|
||||
d="M127.999 0C57.421 0 0 57.421 0 127.999c0 63.646 46.546 116.392 107.404 126.284l35.542-51.937c-2.771.413-5.623.631-8.525.631-29.099 0-52.709-23.611-52.709-52.709 0-29.099 23.61-52.709 52.709-52.709 29.098 0 52.708 23.61 52.708 52.709 0 2.902-.218 5.754-.631 8.525l51.937 35.542C248.423 173.536 256 151.997 256 127.999 256 57.421 198.579 0 127.999 0zm-1.369 96.108c-20.175 0-36.559 16.383-36.559 36.559 0 .367.006.732.018 1.096l24.357 10.07c4.023-2.503 8.772-3.951 13.844-3.951 14.576 0 26.418 11.842 26.418 26.418s-11.842 26.418-26.418 26.418c-14.048 0-25.538-10.997-26.343-24.853l-23.554-9.742c.04.832.061 1.669.061 2.51 0 20.175 16.383 36.559 36.559 36.559 20.175 0 36.558-16.384 36.558-36.559 0-20.176-16.383-36.559-36.558-36.559z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Login with Steam</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="social-links">
|
||||
<a
|
||||
:href="socialLinks.twitter"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
aria-label="X (Twitter)"
|
||||
>
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
:href="socialLinks.discord"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="social-link"
|
||||
aria-label="Discord"
|
||||
>
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { Settings } from "lucide-vue-next";
|
||||
import axios from "@/utils/axios";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const steamLoginUrl = computed(() => {
|
||||
// Use the backend auth endpoint for Steam OAuth
|
||||
return `${import.meta.env.VITE_API_URL || "/api"}/auth/steam`;
|
||||
});
|
||||
const timeRemaining = ref(null);
|
||||
const message = ref("Our site is currently undergoing scheduled maintenance.");
|
||||
const scheduledEnd = ref(null);
|
||||
const socialLinks = ref({
|
||||
twitter: "https://x.com",
|
||||
discord: "https://discord.gg",
|
||||
});
|
||||
let countdownInterval = null;
|
||||
|
||||
const scheduledEndFormatted = computed(() => {
|
||||
if (!scheduledEnd.value) return "";
|
||||
|
||||
const date = new Date(scheduledEnd.value);
|
||||
return date.toLocaleString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const fetchMaintenanceInfo = async () => {
|
||||
try {
|
||||
const response = await axios.get("/api/config/public");
|
||||
if (response.data.success) {
|
||||
const config = response.data.config;
|
||||
|
||||
// Update maintenance info
|
||||
if (config.maintenance) {
|
||||
message.value = config.maintenance.message || message.value;
|
||||
scheduledEnd.value = config.maintenance.scheduledEnd;
|
||||
}
|
||||
|
||||
// Update social links
|
||||
if (config.social) {
|
||||
if (config.social.twitter) {
|
||||
socialLinks.value.twitter = config.social.twitter;
|
||||
}
|
||||
if (config.social.discord) {
|
||||
socialLinks.value.discord = config.social.discord;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch maintenance info:", error);
|
||||
// Keep default values if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
const updateCountdown = () => {
|
||||
if (!scheduledEnd.value) return;
|
||||
|
||||
const now = new Date().getTime();
|
||||
const end = new Date(scheduledEnd.value).getTime();
|
||||
const distance = end - now;
|
||||
|
||||
if (distance < 0) {
|
||||
timeRemaining.value = null;
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
// Optionally reload the page when maintenance ends
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = Math.floor(
|
||||
(distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)
|
||||
);
|
||||
const minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((distance % (1000 * 60)) / 1000);
|
||||
|
||||
timeRemaining.value = {
|
||||
hours: String(hours).padStart(2, "0"),
|
||||
minutes: String(minutes).padStart(2, "0"),
|
||||
seconds: String(seconds).padStart(2, "0"),
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchMaintenanceInfo();
|
||||
|
||||
if (scheduledEnd.value) {
|
||||
updateCountdown();
|
||||
countdownInterval = setInterval(updateCountdown, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.maintenance-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maintenance-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: radial-gradient(
|
||||
circle at 20% 50%,
|
||||
rgba(59, 130, 246, 0.1) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 80%,
|
||||
rgba(139, 92, 246, 0.1) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
background: rgba(30, 30, 46, 0.9);
|
||||
backdrop-filter: blur(20px);
|
||||
border-radius: 1.5rem;
|
||||
padding: 3rem 2rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.maintenance-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #3b82f6;
|
||||
animation: rotate 3s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.maintenance-message {
|
||||
font-size: 1.125rem;
|
||||
color: #d1d5db;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.countdown-section {
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.countdown-timer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.time-unit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 2rem;
|
||||
color: #3b82f6;
|
||||
font-weight: 700;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.countdown-end {
|
||||
font-size: 0.875rem;
|
||||
color: #9ca3af;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
40% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-footer {
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.maintenance-footer p {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.login-section {
|
||||
margin-top: 2rem;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.steam-login-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #171a21 0%, #1b2838 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.steam-login-btn:hover {
|
||||
background: linear-gradient(135deg, #1b2838 0%, #2a475e 100%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.steam-login-btn:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.steam-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.social-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #9ca3af;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #3b82f6;
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.maintenance-container {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.maintenance-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.maintenance-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.countdown-timer {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user