Files
TurboTrades/frontend/src/components/AdminConfigPanel.vue
iDefineHD 63c578b0ae 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.
2026-01-10 21:57:55 +00:00

2005 lines
49 KiB
Vue

<template>
<div class="admin-config-panel">
<!-- Confirm Delete Modal -->
<ConfirmModal
v-model="showDeleteModal"
title="Delete Announcement"
:message="`Are you sure you want to delete this announcement?`"
:details="announcementToDelete?.message"
confirm-text="Delete"
cancel-text="Cancel"
type="danger"
:loading="deleting"
@confirm="confirmDeleteAnnouncement"
/>
<!-- Promotion Stats Modal -->
<PromotionStatsModal
:show="showPromotionStatsModal"
:promotion="selectedPromotionForStats"
@close="closePromotionStatsModal"
v-if="selectedPromotionForStats"
/>
<!-- Tabs -->
<div class="tabs">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="['tab', { active: activeTab === tab.id }]"
>
<component :is="tab.icon" :size="18" />
{{ tab.label }}
</button>
</div>
<!-- Maintenance Tab -->
<div v-if="activeTab === 'maintenance'" class="tab-content">
<div class="section-header">
<h2>Maintenance Mode</h2>
<p>Control site access during maintenance periods</p>
</div>
<div class="maintenance-status">
<div
class="status-indicator"
:class="{ active: config.maintenance?.enabled }"
>
<div class="status-dot"></div>
<span>{{
config.maintenance?.enabled
? "MAINTENANCE ACTIVE"
: "SITE OPERATIONAL"
}}</span>
</div>
</div>
<div class="form-section">
<div class="form-group">
<ToggleSwitch
v-model="maintenanceForm.enabled"
label="Enable Maintenance Mode"
/>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.5rem">
When enabled, only admins and whitelisted Steam IDs can access the
site
</p>
</div>
<div class="form-group">
<label>Maintenance Message</label>
<textarea
v-model="maintenanceForm.message"
class="form-textarea"
rows="3"
placeholder="e.g., We're performing scheduled maintenance. We'll be back soon!"
></textarea>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem">
This message will be shown to users when they try to access the site
</p>
</div>
<div class="form-group">
<label
class="toggle-label"
style="cursor: pointer; user-select: none"
>
<input
type="checkbox"
v-model="showScheduling"
style="margin-right: 0.5rem"
/>
<span>Schedule Maintenance (Optional)</span>
</label>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem">
Leave unchecked to manually control maintenance mode
</p>
</div>
<div v-if="showScheduling" class="form-row">
<div class="form-group">
<label>Scheduled Start</label>
<input
v-model="maintenanceForm.scheduledStart"
type="datetime-local"
class="form-input"
/>
</div>
<div class="form-group">
<label>Scheduled End</label>
<input
v-model="maintenanceForm.scheduledEnd"
type="datetime-local"
class="form-input"
/>
</div>
</div>
<div class="form-group">
<label>Allowed Steam IDs (Optional)</label>
<p style="font-size: 0.875rem; color: #9ca3af; margin-bottom: 0.5rem">
Add Steam IDs of users who can access during maintenance (admins can
always access)
</p>
<div class="steam-id-list">
<div
v-for="(steamId, index) in maintenanceForm.allowedSteamIds"
:key="index"
class="steam-id-item"
>
<input
v-model="maintenanceForm.allowedSteamIds[index]"
type="text"
class="form-input"
placeholder="76561198XXXXXXXXX"
/>
<button
@click="removeSteamId(index)"
class="btn-icon btn-danger-sm"
>
X
</button>
</div>
</div>
<button @click="addSteamId" class="btn btn-secondary btn-sm">
<Plus class="w-4 h-4" />
Add Steam ID
</button>
</div>
<div class="form-actions">
<button
@click="saveMaintenanceConfig"
:disabled="saving"
class="btn btn-primary"
>
<Loader2 v-if="saving" class="animate-spin" :size="16" />
<Save v-else :size="16" />
Save Changes
</button>
</div>
</div>
</div>
<!-- Announcements Tab -->
<div v-if="activeTab === 'announcements'" class="tab-content">
<div class="section-header">
<h2>Site Announcements</h2>
<p>Manage site-wide announcements and notifications</p>
<button @click="openAnnouncementModal()" class="btn btn-primary">
<Plus :size="18" />
New Announcement
</button>
</div>
<div v-if="loading" class="loading">
<Loader2 class="animate-spin" :size="32" />
<p>Loading announcements...</p>
</div>
<div
v-else-if="!config.announcements || config.announcements.length === 0"
class="no-data"
>
<Bell :size="48" />
<p>No announcements yet</p>
</div>
<div v-else class="announcements-list">
<div
v-for="announcement in config.announcements"
:key="announcement.id"
class="announcement-card"
>
<div class="announcement-header">
<div class="announcement-type" :class="`type-${announcement.type}`">
<component
:is="getAnnouncementIcon(announcement.type)"
:size="16"
/>
{{ announcement.type }}
</div>
<div class="announcement-status">
<span
:class="[
'status-badge',
announcement.enabled ? 'status-active' : 'status-inactive',
]"
>
{{ announcement.enabled ? "Active" : "Inactive" }}
</span>
</div>
</div>
<p class="announcement-message">{{ announcement.message }}</p>
<div class="announcement-meta">
<span class="meta-item">
<User :size="14" />
{{ announcement.createdBy }}
</span>
<span class="meta-item">
<Calendar :size="14" />
{{ formatDate(announcement.createdAt) }}
</span>
<span v-if="announcement.startDate" class="meta-item">
<Clock :size="14" />
Starts: {{ formatDate(announcement.startDate) }}
</span>
<span v-if="announcement.endDate" class="meta-item">
<Clock :size="14" />
Ends: {{ formatDate(announcement.endDate) }}
</span>
</div>
<div class="announcement-actions">
<button
@click="openAnnouncementModal(announcement)"
class="btn btn-secondary btn-sm"
>
<Edit2 :size="14" />
Edit
</button>
<button
@click="deleteAnnouncement(announcement)"
class="btn btn-danger btn-sm"
>
<Trash2 :size="14" />
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Promotions Tab -->
<div v-if="activeTab === 'promotions'" class="tab-content">
<div class="section-header">
<h2>Promotions</h2>
<p>Manage deposit bonuses, discounts, and special offers</p>
<button @click="openPromotionModal()" class="btn btn-primary">
<Plus :size="18" />
New Promotion
</button>
</div>
<div v-if="loading" class="loading">
<Loader2 class="animate-spin" :size="32" />
<p>Loading promotions...</p>
</div>
<div
v-else-if="!config.promotions || config.promotions.length === 0"
class="no-data"
>
<Gift :size="48" />
<p>No promotions yet</p>
</div>
<div v-else class="promotions-grid">
<div
v-for="promotion in config.promotions"
:key="promotion.id"
class="promotion-card"
>
<div class="promotion-header">
<h3>{{ promotion.name }}</h3>
<span
:class="[
'status-badge',
promotion.enabled ? 'status-active' : 'status-inactive',
]"
>
{{ promotion.enabled ? "Active" : "Inactive" }}
</span>
</div>
<p class="promotion-description">{{ promotion.description }}</p>
<div class="promotion-details">
<div class="detail-row">
<span class="detail-label">Type:</span>
<span class="detail-value type-badge">{{
promotion.type.replace("_", " ")
}}</span>
</div>
<div v-if="promotion.bonusPercentage" class="detail-row">
<span class="detail-label">Bonus:</span>
<span class="detail-value">{{ promotion.bonusPercentage }}%</span>
</div>
<div v-if="promotion.minDeposit" class="detail-row">
<span class="detail-label">Min Deposit:</span>
<span class="detail-value">${{ promotion.minDeposit }}</span>
</div>
<div v-if="promotion.maxBonus" class="detail-row">
<span class="detail-label">Max Bonus:</span>
<span class="detail-value">${{ promotion.maxBonus }}</span>
</div>
<div v-if="promotion.code" class="detail-row">
<span class="detail-label">Code:</span>
<span class="detail-value promo-code">{{ promotion.code }}</span>
</div>
</div>
<div class="promotion-dates">
<div class="date-item">
<Clock :size="14" />
<span
>{{ formatDate(promotion.startDate) }} -
{{ formatDate(promotion.endDate) }}</span
>
</div>
</div>
<div class="promotion-stats">
<div class="stat-item">
<span class="stat-label">Uses:</span>
<span class="stat-value"
>{{ promotion.currentUses || 0 }} /
{{ promotion.maxTotalUses || "∞" }}</span
>
</div>
<div class="stat-item">
<span class="stat-label">Per User:</span>
<span class="stat-value">{{ promotion.maxUsesPerUser }}</span>
</div>
</div>
<div class="promotion-actions">
<button
@click="openPromotionModal(promotion)"
class="btn btn-secondary btn-sm"
>
<Edit2 :size="14" />
Edit
</button>
<button
@click="viewPromotionStats(promotion)"
class="btn btn-info btn-sm"
>
<BarChart3 :size="14" />
Stats
</button>
<button
@click="deletePromotion(promotion)"
class="btn btn-danger btn-sm"
>
<Trash2 :size="14" />
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Trading & Market Tab -->
<div v-if="activeTab === 'trading'" class="tab-content">
<div class="section-header">
<h2>Trading & Market Settings</h2>
<p>Configure fees, limits, and pricing parameters</p>
<div class="info-banner">
<Info :size="16" />
<span
>Use Maintenance Mode to disable the entire site. These settings
control fees and limits only.</span
>
</div>
</div>
<div class="settings-grid">
<div class="settings-section">
<h3>💰 Trading Limits & Fees</h3>
<div class="form-group">
<label>Minimum Deposit ($)</label>
<input
v-model.number="tradingForm.minDeposit"
type="number"
step="0.01"
min="0"
class="form-input"
placeholder="0.10"
/>
<p class="form-help">Minimum amount users can deposit</p>
</div>
<div class="form-group">
<label>Minimum Withdrawal ($)</label>
<input
v-model.number="tradingForm.minWithdraw"
type="number"
step="0.01"
min="0"
class="form-input"
placeholder="0.50"
/>
<p class="form-help">Minimum amount users can withdraw</p>
</div>
<div class="form-group">
<label>Withdrawal Fee (%)</label>
<input
v-model.number="tradingForm.withdrawFee"
type="number"
step="0.01"
min="0"
max="100"
class="form-input"
placeholder="5.0"
/>
<p class="form-help">
Percentage fee charged on withdrawals (e.g., 5 = 5%)
</p>
</div>
<div class="form-group">
<label>Max Items Per Trade</label>
<input
v-model.number="tradingForm.maxItemsPerTrade"
type="number"
min="1"
class="form-input"
placeholder="50"
/>
<p class="form-help">Maximum items allowed in a single trade</p>
</div>
</div>
<div class="settings-section">
<h3>🏪 Market Pricing & Fees</h3>
<div class="form-group">
<label>Commission Rate (%)</label>
<input
v-model.number="marketForm.commission"
type="number"
step="0.01"
min="0"
max="100"
class="form-input"
placeholder="10.0"
/>
<p class="form-help">
Platform commission on market sales (e.g., 10 = 10%)
</p>
</div>
<div class="form-group">
<label>Min Listing Price ($)</label>
<input
v-model.number="marketForm.minListingPrice"
type="number"
step="0.01"
min="0"
class="form-input"
placeholder="0.01"
/>
<p class="form-help">Minimum price for marketplace listings</p>
</div>
<div class="form-group">
<label>Max Listing Price ($)</label>
<input
v-model.number="marketForm.maxListingPrice"
type="number"
step="1"
min="0"
class="form-input"
placeholder="100000"
/>
<p class="form-help">Maximum price for marketplace listings</p>
</div>
<div class="form-group">
<label>Price Update Interval (minutes)</label>
<input
v-model.number="priceUpdateMinutes"
type="number"
min="1"
class="form-input"
placeholder="60"
/>
<p class="form-help">How often to auto-update market prices</p>
</div>
</div>
</div>
<div class="form-actions-centered">
<button
@click="saveAllSettings"
:disabled="saving"
class="btn btn-primary btn-large"
type="button"
>
<Loader2 v-if="saving" class="animate-spin" :size="20" />
<Save v-else :size="20" />
{{ saving ? "Saving..." : "Save All Settings" }}
</button>
</div>
</div>
<!-- Announcement Modal -->
<div
v-if="showAnnouncementModal"
class="modal-overlay"
@click="closeAnnouncementModal"
>
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2>{{ editingAnnouncement ? "Edit" : "New" }} Announcement</h2>
<button @click="closeAnnouncementModal" class="close-btn">
<X :size="24" />
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>Type</label>
<select v-model="announcementForm.type" class="form-select">
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="success">Success</option>
<option value="error">Error</option>
</select>
</div>
<div class="form-group">
<label>Message</label>
<textarea
v-model="announcementForm.message"
rows="4"
placeholder="Announcement message..."
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<ToggleSwitch v-model="announcementForm.enabled" label="Enabled" />
</div>
<div class="form-group">
<ToggleSwitch
v-model="announcementForm.dismissible"
label="Dismissible"
/>
</div>
<div class="form-group">
<label
class="toggle-label"
style="cursor: pointer; user-select: none"
>
<input
type="checkbox"
v-model="showAnnouncementScheduling"
style="margin-right: 0.5rem"
/>
<span>Schedule Announcement (Optional)</span>
</label>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem">
Leave unchecked for permanent announcement
</p>
</div>
<div v-if="showAnnouncementScheduling" class="form-row">
<div class="form-group">
<label>Start Date</label>
<input
v-model="announcementForm.startDate"
type="datetime-local"
class="form-input"
/>
</div>
<div class="form-group">
<label>End Date</label>
<input
v-model="announcementForm.endDate"
type="datetime-local"
class="form-input"
/>
</div>
</div>
<div class="modal-actions">
<button
@click="saveAnnouncement"
:disabled="saving"
class="btn btn-primary"
>
<Loader2 v-if="saving" class="animate-spin" :size="16" />
<span v-else>{{
editingAnnouncement ? "Update" : "Create"
}}</span>
</button>
<button @click="closeAnnouncementModal" class="btn btn-secondary">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Promotion Modal -->
<div
v-if="showPromotionModal"
class="modal-overlay"
@click="closePromotionModal"
>
<div class="modal-content modal-large" @click.stop>
<div class="modal-header">
<h2>{{ editingPromotion ? "Edit" : "New" }} Promotion</h2>
<button @click="closePromotionModal" class="close-btn">
<X :size="24" />
</button>
</div>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label>Promotion Name</label>
<input
v-model="promotionForm.name"
type="text"
placeholder="e.g., Welcome Bonus"
class="form-input"
/>
</div>
<div class="form-group">
<label>Type</label>
<select v-model="promotionForm.type" class="form-select">
<option value="deposit_bonus">Deposit Bonus</option>
<option value="discount">Discount</option>
<option value="free_item">Free Item</option>
<option value="custom">Custom</option>
</select>
</div>
</div>
<div class="form-group">
<label>Description</label>
<textarea
v-model="promotionForm.description"
rows="3"
placeholder="Promotion description..."
class="form-textarea"
></textarea>
</div>
<div class="form-group">
<label
class="toggle-label"
style="cursor: pointer; user-select: none"
>
<input
type="checkbox"
v-model="showPromotionScheduling"
style="margin-right: 0.5rem"
/>
<span>Schedule Promotion (Optional)</span>
</label>
<p style="font-size: 0.875rem; color: #9ca3af; margin-top: 0.25rem">
Leave unchecked for always-active promotion
</p>
</div>
<div v-if="showPromotionScheduling" class="form-row">
<div class="form-group">
<label>Start Date</label>
<input
v-model="promotionForm.startDate"
type="datetime-local"
class="form-input"
/>
</div>
<div class="form-group">
<label>End Date</label>
<input
v-model="promotionForm.endDate"
type="datetime-local"
class="form-input"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Bonus Percentage (%)</label>
<input
v-model.number="promotionForm.bonusPercentage"
type="number"
step="1"
min="0"
max="100"
class="form-input"
/>
</div>
<div class="form-group">
<label>Bonus Amount ($)</label>
<input
v-model.number="promotionForm.bonusAmount"
type="number"
step="0.01"
min="0"
class="form-input"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Min Deposit ($)</label>
<input
v-model.number="promotionForm.minDeposit"
type="number"
step="0.01"
min="0"
class="form-input"
/>
</div>
<div class="form-group">
<label>Max Bonus ($)</label>
<input
v-model.number="promotionForm.maxBonus"
type="number"
step="0.01"
min="0"
class="form-input"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Discount Percentage (%)</label>
<input
v-model.number="promotionForm.discountPercentage"
type="number"
step="1"
min="0"
max="100"
class="form-input"
/>
</div>
<div class="form-group">
<label>Promo Code (Optional)</label>
<input
v-model="promotionForm.code"
type="text"
placeholder="PROMO2024"
class="form-input"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Max Uses Per User</label>
<input
v-model.number="promotionForm.maxUsesPerUser"
type="number"
min="1"
class="form-input"
/>
</div>
<div class="form-group">
<label>Max Total Uses (Optional)</label>
<input
v-model.number="promotionForm.maxTotalUses"
type="number"
min="1"
class="form-input"
/>
</div>
</div>
<div class="form-group">
<ToggleSwitch v-model="promotionForm.enabled" label="Enabled" />
</div>
<div class="form-group">
<ToggleSwitch
v-model="promotionForm.newUsersOnly"
label="New Users Only"
/>
</div>
<div class="modal-actions">
<button
@click="savePromotion"
:disabled="saving"
class="btn btn-primary"
>
<Loader2 v-if="saving" class="animate-spin" :size="16" />
<span v-else>{{ editingPromotion ? "Update" : "Create" }}</span>
</button>
<button @click="closePromotionModal" class="btn btn-secondary">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Users Tab -->
<div v-show="activeTab === 'users'" class="tab-content">
<UserManagementTab />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useToast } from "vue-toastification";
import {
Settings,
Bell,
Gift,
ShoppingCart,
AlertTriangle,
CheckCircle,
Info,
XCircle,
Save,
Plus,
X,
Edit2,
Trash2,
Loader2,
Calendar,
Clock,
User,
Users,
BarChart3,
} from "lucide-vue-next";
import axios from "../utils/axios";
import ToggleSwitch from "./ToggleSwitch.vue";
import ConfirmModal from "./ConfirmModal.vue";
import PromotionStatsModal from "./PromotionStatsModal.vue";
import UserManagementTab from "./UserManagementTab.vue";
const toast = useToast();
const loading = ref(false);
const saving = ref(false);
const deleting = ref(false);
const activeTab = ref("maintenance");
const showDeleteModal = ref(false);
const announcementToDelete = ref(null);
const config = ref({});
const showScheduling = ref(false);
const showAnnouncementScheduling = ref(false);
const showPromotionScheduling = ref(false);
// Watch showScheduling to clear dates when disabled
watch(showScheduling, (newVal) => {
if (!newVal) {
maintenanceForm.value.scheduledStart = null;
maintenanceForm.value.scheduledEnd = null;
}
});
watch(showAnnouncementScheduling, (newVal) => {
if (!newVal) {
announcementForm.value.startDate = null;
announcementForm.value.endDate = null;
}
});
watch(showPromotionScheduling, (newVal) => {
if (!newVal) {
promotionForm.value.startDate = null;
promotionForm.value.endDate = null;
}
});
const tabs = [
{ id: "maintenance", label: "Maintenance", icon: Settings },
{ id: "announcements", label: "Announcements", icon: Bell },
{ id: "promotions", label: "Promotions", icon: Gift },
{ id: "trading", label: "Trading & Market", icon: ShoppingCart },
{ id: "users", label: "User Management", icon: Users },
];
// Maintenance form
const maintenanceForm = ref({
enabled: false,
message: "",
allowedSteamIds: [],
scheduledStart: null,
scheduledEnd: null,
});
// Trading form (simplified - toggles removed, always enabled)
const tradingForm = ref({
enabled: true, // Always enabled, use maintenance mode to disable
depositEnabled: true, // Always enabled
withdrawEnabled: true, // Always enabled
minDeposit: 0.1,
minWithdraw: 0.5,
withdrawFee: 5.0, // Changed to percentage for easier input (5 = 5%)
maxItemsPerTrade: 50,
});
// Market form (simplified - toggles removed, always enabled)
const marketForm = ref({
enabled: true, // Always enabled, use maintenance mode to disable
commission: 10.0, // Changed to percentage for easier input (10 = 10%)
minListingPrice: 0.01,
maxListingPrice: 100000,
autoUpdatePrices: true, // Always enabled
priceUpdateInterval: 3600000,
});
// Helper for price update interval conversion (minutes to milliseconds)
const priceUpdateMinutes = computed({
get: () => Math.round(marketForm.value.priceUpdateInterval / 60000),
set: (val) => (marketForm.value.priceUpdateInterval = val * 60000),
});
// Announcement modal
const showAnnouncementModal = ref(false);
const editingAnnouncement = ref(null);
const announcementForm = ref({
type: "info",
message: "",
enabled: true,
dismissible: true,
startDate: null,
endDate: null,
});
// Promotion modal
const showPromotionModal = ref(false);
const showPromotionStatsModal = ref(false);
const selectedPromotionForStats = ref(null);
const editingPromotion = ref(null);
const promotionForm = ref({
name: "",
description: "",
type: "deposit_bonus",
enabled: true,
startDate: null,
endDate: null,
bonusPercentage: 0,
bonusAmount: 0,
minDeposit: 0,
maxBonus: 0,
discountPercentage: 0,
maxUsesPerUser: 1,
maxTotalUses: null,
newUsersOnly: false,
code: "",
});
// Methods
const loadConfig = async () => {
loading.value = true;
try {
const response = await axios.get("/api/admin/config");
if (response.data.success) {
config.value = response.data.config;
// Populate forms
if (config.value.maintenance) {
maintenanceForm.value = {
enabled: config.value.maintenance.enabled,
message: config.value.maintenance.message,
allowedSteamIds: [
...(config.value.maintenance.allowedSteamIds || []),
],
scheduledStart: config.value.maintenance.scheduledStart
? formatDateTimeLocal(config.value.maintenance.scheduledStart)
: null,
scheduledEnd: config.value.maintenance.scheduledEnd
? formatDateTimeLocal(config.value.maintenance.scheduledEnd)
: null,
};
// Show scheduling section if dates are set
showScheduling.value = !!(
config.value.maintenance.scheduledStart ||
config.value.maintenance.scheduledEnd
);
}
if (config.value.trading) {
tradingForm.value = {
...config.value.trading,
// Convert decimal to percentage for display
withdrawFee: config.value.trading.withdrawFee * 100,
};
}
if (config.value.market) {
marketForm.value = {
...config.value.market,
// Convert decimal to percentage for display
commission: config.value.market.commission * 100,
};
}
}
} catch (error) {
console.error("Failed to load config:", error);
toast.error("Failed to load configuration");
} finally {
loading.value = false;
}
};
const saveMaintenanceConfig = async () => {
saving.value = true;
try {
const response = await axios.patch(
"/api/admin/config/maintenance",
maintenanceForm.value
);
if (response.data.success) {
toast.success("Maintenance settings saved");
await loadConfig();
}
} catch (error) {
console.error("Failed to save maintenance config:", error);
toast.error(error.response?.data?.message || "Failed to save settings");
} finally {
saving.value = false;
}
};
// Combined save function for both trading and market settings
const saveAllSettings = async () => {
saving.value = true;
try {
// Prepare data with percentage conversion back to decimal
const tradingData = {
...tradingForm.value,
withdrawFee: tradingForm.value.withdrawFee / 100, // Convert percentage to decimal
};
const marketData = {
...marketForm.value,
commission: marketForm.value.commission / 100, // Convert percentage to decimal
};
console.log("💾 Saving all settings...");
console.log("Trading data:", tradingData);
console.log("Market data:", marketData);
// Save both in parallel
const [tradingResponse, marketResponse] = await Promise.all([
axios.patch("/api/admin/config/trading", tradingData),
axios.patch("/api/admin/config/market", marketData),
]);
if (tradingResponse.data.success && marketResponse.data.success) {
toast.success("✅ All settings saved successfully!");
await loadConfig();
} else {
throw new Error("One or more settings failed to save");
}
} catch (error) {
console.error("❌ Failed to save settings:", error);
console.error("Error response:", error.response?.data);
toast.error(error.response?.data?.message || "Failed to save settings");
} finally {
saving.value = false;
}
};
const addSteamId = () => {
maintenanceForm.value.allowedSteamIds.push("");
};
const removeSteamId = (index) => {
maintenanceForm.value.allowedSteamIds.splice(index, 1);
};
// Announcements
const openAnnouncementModal = (announcement = null) => {
if (announcement) {
editingAnnouncement.value = announcement;
announcementForm.value = {
type: announcement.type,
message: announcement.message,
enabled: announcement.enabled,
dismissible: announcement.dismissible,
startDate: announcement.startDate
? formatDateTimeLocal(announcement.startDate)
: null,
endDate: announcement.endDate
? formatDateTimeLocal(announcement.endDate)
: null,
};
showAnnouncementScheduling.value = !!(
announcement.startDate || announcement.endDate
);
} else {
editingAnnouncement.value = null;
announcementForm.value = {
type: "info",
message: "",
enabled: true,
dismissible: true,
startDate: null,
endDate: null,
};
showAnnouncementScheduling.value = false;
}
showAnnouncementModal.value = true;
};
const closeAnnouncementModal = () => {
showAnnouncementModal.value = false;
editingAnnouncement.value = null;
};
const saveAnnouncement = async () => {
saving.value = true;
try {
if (editingAnnouncement.value) {
const response = await axios.patch(
`/api/admin/announcements/${editingAnnouncement.value.id}`,
announcementForm.value
);
if (response.data.success) {
toast.success("Announcement updated");
}
} else {
const response = await axios.post(
"/api/admin/announcements",
announcementForm.value
);
if (response.data.success) {
toast.success("Announcement created");
}
}
await loadConfig();
closeAnnouncementModal();
} catch (error) {
console.error("Failed to save announcement:", error);
toast.error(error.response?.data?.message || "Failed to save announcement");
} finally {
saving.value = false;
}
};
const deleteAnnouncement = (announcement) => {
announcementToDelete.value = announcement;
showDeleteModal.value = true;
};
const confirmDeleteAnnouncement = async () => {
if (!announcementToDelete.value) return;
deleting.value = true;
try {
const response = await axios.delete(
`/api/admin/announcements/${announcementToDelete.value.id}`
);
if (response.data.success) {
toast.success("Announcement deleted successfully");
showDeleteModal.value = false;
announcementToDelete.value = null;
await loadConfig();
}
} catch (error) {
console.error("Failed to delete announcement:", error);
toast.error(
error.response?.data?.message || "Failed to delete announcement"
);
} finally {
deleting.value = false;
}
};
// Promotions
const openPromotionModal = (promotion = null) => {
if (promotion) {
editingPromotion.value = promotion;
promotionForm.value = {
name: promotion.name,
description: promotion.description,
type: promotion.type,
enabled: promotion.enabled,
startDate: promotion.startDate,
endDate: promotion.endDate,
bonusPercentage: promotion.bonusPercentage,
bonusAmount: promotion.bonusAmount,
minDeposit: promotion.minDeposit,
maxBonus: promotion.maxBonus,
discountPercentage: promotion.discountPercentage,
maxUsesPerUser: promotion.maxUsesPerUser,
maxTotalUses: promotion.maxTotalUses,
newUsersOnly: promotion.newUsersOnly,
code: promotion.code,
};
showPromotionScheduling.value = !!(
promotion.startDate || promotion.endDate
);
} else {
editingPromotion.value = null;
promotionForm.value = {
name: "",
description: "",
type: "deposit_bonus",
enabled: true,
startDate: null,
endDate: null,
bonusPercentage: 0,
bonusAmount: 0,
minDeposit: 0,
maxBonus: 0,
discountPercentage: 0,
maxUsesPerUser: 1,
maxTotalUses: null,
newUsersOnly: false,
code: "",
};
showPromotionScheduling.value = false;
}
showPromotionModal.value = true;
};
const closePromotionModal = () => {
showPromotionModal.value = false;
editingPromotion.value = null;
};
const savePromotion = async () => {
saving.value = true;
try {
// Clean up the form data before sending
const cleanData = {
...promotionForm.value,
// Convert empty strings to null
code: promotionForm.value.code?.trim() || null,
// Convert 0 or empty to null for optional fields
maxTotalUses: promotionForm.value.maxTotalUses || null,
bonusAmount: promotionForm.value.bonusAmount || 0,
maxBonus: promotionForm.value.maxBonus || 0,
minDeposit: promotionForm.value.minDeposit || 0,
discountPercentage: promotionForm.value.discountPercentage || 0,
// Ensure dates are properly formatted or null
startDate: promotionForm.value.startDate || null,
endDate: promotionForm.value.endDate || null,
};
console.log("📤 Sending promotion data:", cleanData);
if (editingPromotion.value) {
const response = await axios.patch(
`/api/admin/promotions/${editingPromotion.value.id}`,
cleanData
);
if (response.data.success) {
toast.success("Promotion updated");
}
} else {
const response = await axios.post("/api/admin/promotions", cleanData);
if (response.data.success) {
toast.success("Promotion created");
}
}
await loadConfig();
closePromotionModal();
} catch (error) {
console.error("Failed to save promotion:", error);
console.error("Error details:", error.response?.data);
toast.error(error.response?.data?.message || "Failed to save promotion");
} finally {
saving.value = false;
}
};
const deletePromotion = async (promotion) => {
if (!confirm("Are you sure you want to delete this promotion?")) return;
try {
const response = await axios.delete(
`/api/admin/promotions/${promotion.id}`
);
if (response.data.success) {
toast.success("Promotion deleted");
await loadConfig();
}
} catch (error) {
console.error("Failed to delete promotion:", error);
toast.error(error.response?.data?.message || "Failed to delete promotion");
}
};
const viewPromotionStats = async (promotion) => {
selectedPromotionForStats.value = promotion;
showPromotionStatsModal.value = true;
};
const closePromotionStatsModal = () => {
showPromotionStatsModal.value = false;
selectedPromotionForStats.value = null;
};
// Helpers
const getAnnouncementIcon = (type) => {
const icons = {
info: Info,
warning: AlertTriangle,
success: CheckCircle,
error: XCircle,
};
return icons[type] || Info;
};
const formatDate = (date) => {
if (!date) return "N/A";
return new Date(date).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatDateTimeLocal = (date) => {
if (!date) return null;
const d = new Date(date);
const offset = d.getTimezoneOffset();
const adjusted = new Date(d.getTime() - offset * 60 * 1000);
return adjusted.toISOString().slice(0, 16);
};
onMounted(() => {
loadConfig();
});
</script>
<style scoped>
.admin-config-panel {
padding: 1.5rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
border-bottom: 2px solid #374151;
overflow-x: auto;
}
.tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s;
white-space: nowrap;
}
.tab:hover {
color: white;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 2rem;
}
.section-header h2 {
font-size: 1.5rem;
font-weight: 600;
color: white;
margin: 0 0 0.25rem 0;
}
.section-header p {
color: #9ca3af;
margin: 0;
}
.maintenance-status {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: #1f2937;
border: 2px solid #10b981;
border-radius: 0.5rem;
font-weight: 600;
color: #10b981;
}
.status-indicator.active {
border-color: #f59e0b;
color: #f59e0b;
}
.status-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #10b981;
animation: pulse 2s infinite;
}
.status-indicator.active .status-dot {
background: #f59e0b;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.form-section {
background: #1f2937;
border: 1px solid #374151;
border-radius: 0.75rem;
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #d1d5db;
margin-bottom: 0.5rem;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 0.75rem;
background: #111827;
border: 1px solid #374151;
border-radius: 0.375rem;
color: white;
font-size: 1rem;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: #3b82f6;
}
.form-textarea {
resize: vertical;
font-family: inherit;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.steam-id-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.steam-id-item {
display: flex;
gap: 0.5rem;
}
.btn-icon {
padding: 0.75rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-danger-sm {
background: #7f1d1d;
color: #fca5a5;
}
.btn-danger-sm:hover {
background: #991b1b;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.form-actions-centered {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.btn-large {
padding: 14px 32px;
font-size: 1rem;
min-width: 200px;
}
.form-help {
margin: 6px 0 0 0;
font-size: 0.813rem;
color: #8b92a7;
font-style: italic;
}
.info-banner {
display: flex;
align-items: center;
gap: 12px;
margin-top: 12px;
padding: 12px 16px;
background: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
color: #3b82f6;
font-size: 0.875rem;
}
.settings-section h3 {
display: flex;
align-items: center;
gap: 8px;
}
.btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-secondary {
background: #374151;
color: white;
}
.btn-secondary:hover {
background: #4b5563;
}
.btn-sm {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.btn-danger {
background: #dc2626;
color: white;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn-info {
background: #0891b2;
color: white;
}
.btn-info:hover {
background: #0e7490;
}
.loading,
.no-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
color: #9ca3af;
gap: 1rem;
}
.announcements-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.announcement-card {
background: #1f2937;
border: 1px solid #374151;
border-radius: 0.75rem;
padding: 1.5rem;
}
.announcement-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.announcement-type {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
text-transform: uppercase;
}
.type-info {
background: #1e3a8a;
color: #93c5fd;
}
.type-warning {
background: #78350f;
color: #fcd34d;
}
.type-success {
background: #14532d;
color: #86efac;
}
.type-error {
background: #7f1d1d;
color: #fca5a5;
}
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
}
.status-active {
background: #14532d;
color: #86efac;
}
.status-inactive {
background: #374151;
color: #9ca3af;
}
.announcement-message {
color: white;
margin: 0 0 1rem 0;
line-height: 1.6;
}
.announcement-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: #9ca3af;
}
.announcement-actions {
display: flex;
gap: 0.5rem;
}
.promotions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 1.5rem;
}
.promotion-card {
background: #1f2937;
border: 1px solid #374151;
border-radius: 0.75rem;
padding: 1.5rem;
}
.promotion-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.promotion-header h3 {
font-size: 1.125rem;
font-weight: 600;
color: white;
margin: 0;
}
.promotion-description {
color: #9ca3af;
margin: 0 0 1rem 0;
line-height: 1.6;
}
.promotion-details {
background: #111827;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
}
.detail-row:not(:last-child) {
border-bottom: 1px solid #374151;
}
.detail-label {
font-size: 0.875rem;
color: #9ca3af;
}
.detail-value {
font-weight: 500;
color: white;
}
.type-badge {
padding: 0.25rem 0.5rem;
background: #3b82f6;
color: white;
border-radius: 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
}
.promo-code {
font-family: monospace;
background: #374151;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.promotion-dates {
margin-bottom: 1rem;
}
.date-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #9ca3af;
}
.promotion-stats {
display: flex;
gap: 1rem;
padding: 0.75rem;
background: #111827;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.stat-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.stat-label {
font-size: 0.75rem;
color: #9ca3af;
}
.stat-value {
font-size: 1rem;
font-weight: 600;
color: white;
}
.promotion-actions {
display: flex;
gap: 0.5rem;
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
margin-bottom: 2rem;
}
.settings-section {
background: #1f2937;
border: 1px solid #374151;
border-radius: 0.75rem;
padding: 1.5rem;
}
.settings-section h3 {
font-size: 1.125rem;
font-weight: 600;
color: white;
margin: 0 0 1.5rem 0;
}
/* Modal */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: #1f2937;
border: 1px solid #374151;
border-radius: 0.75rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
.modal-large {
max-width: 800px;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #374151;
}
.modal-header h2 {
margin: 0;
color: white;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
color: #9ca3af;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 0.2s;
}
.close-btn:hover {
color: white;
}
.modal-body {
padding: 1.5rem;
}
.modal-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 1024px) {
.settings-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.promotions-grid {
grid-template-columns: 1fr;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>