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

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