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:
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
Reference in New Issue
Block a user