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:
2026-01-10 21:57:55 +00:00
parent b90cdd59df
commit 63c578b0ae
52 changed files with 21810 additions and 61 deletions

View File

@@ -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,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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

View 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>

View 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>

View 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>