- 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.
2005 lines
49 KiB
Vue
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>
|