system now uses seperate pricing.
All checks were successful
Build Frontend / Build Frontend (push) Successful in 22s
All checks were successful
Build Frontend / Build Frontend (push) Successful in 22s
This commit is contained in:
523
frontend/src/components/AdminItemsPanel.vue
Normal file
523
frontend/src/components/AdminItemsPanel.vue
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header with Stats -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||||
|
<Package class="w-6 h-6 text-blue-400" />
|
||||||
|
Market Price Database
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-400 text-sm mt-1">
|
||||||
|
Manage reference prices for instant sell
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="refreshPrices"
|
||||||
|
:disabled="isRefreshing"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isRefreshing }" />
|
||||||
|
{{ isRefreshing ? 'Updating...' : 'Update All Prices' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-sm mb-1">Total Items</div>
|
||||||
|
<div class="text-2xl font-bold">{{ stats.total || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-sm mb-1">CS2 Items</div>
|
||||||
|
<div class="text-2xl font-bold text-blue-400">{{ stats.cs2 || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-sm mb-1">Rust Items</div>
|
||||||
|
<div class="text-2xl font-bold text-orange-400">{{ stats.rust || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-gray-400 text-sm mb-1">Last Updated</div>
|
||||||
|
<div class="text-lg font-semibold">
|
||||||
|
{{ stats.lastUpdated ? formatDate(stats.lastUpdated) : 'Never' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Search Items
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
v-model="filters.search"
|
||||||
|
@input="debouncedSearch"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name..."
|
||||||
|
class="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Game Filter -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Game
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="filters.game"
|
||||||
|
@change="loadItems"
|
||||||
|
class="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||||
|
>
|
||||||
|
<option value="">All Games</option>
|
||||||
|
<option value="cs2">CS2</option>
|
||||||
|
<option value="rust">Rust</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sort -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||||
|
Sort By
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
v-model="filters.sort"
|
||||||
|
@change="loadItems"
|
||||||
|
class="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||||
|
>
|
||||||
|
<option value="name-asc">Name (A-Z)</option>
|
||||||
|
<option value="name-desc">Name (Z-A)</option>
|
||||||
|
<option value="price-desc">Price (High to Low)</option>
|
||||||
|
<option value="price-asc">Price (Low to High)</option>
|
||||||
|
<option value="updated-desc">Recently Updated</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Table -->
|
||||||
|
<div class="bg-gray-800 rounded-lg overflow-hidden">
|
||||||
|
<div v-if="isLoading" class="p-8 text-center">
|
||||||
|
<RefreshCw class="w-8 h-8 animate-spin mx-auto mb-2 text-blue-400" />
|
||||||
|
<p class="text-gray-400">Loading items...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="items.length === 0" class="p-8 text-center">
|
||||||
|
<Package class="w-12 h-12 mx-auto mb-2 text-gray-600" />
|
||||||
|
<p class="text-gray-400">No items found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-900 border-b border-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Item
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Game
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Price
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Last Updated
|
||||||
|
</th>
|
||||||
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700">
|
||||||
|
<tr
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item._id"
|
||||||
|
class="hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
v-if="item.image"
|
||||||
|
:src="item.image"
|
||||||
|
:alt="item.name"
|
||||||
|
class="w-12 h-12 rounded"
|
||||||
|
@error="(e) => (e.target.src = '/placeholder.png')"
|
||||||
|
/>
|
||||||
|
<div class="w-12 h-12 bg-gray-700 rounded flex items-center justify-center" v-else>
|
||||||
|
<Package class="w-6 h-6 text-gray-500" />
|
||||||
|
</div>
|
||||||
|
<div class="max-w-md">
|
||||||
|
<div class="font-medium text-white truncate">{{ item.name }}</div>
|
||||||
|
<div class="text-xs text-gray-500 truncate">{{ item.marketHashName }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span
|
||||||
|
class="px-2 py-1 text-xs font-semibold rounded"
|
||||||
|
:class="
|
||||||
|
item.game === 'cs2'
|
||||||
|
? 'bg-blue-900/50 text-blue-400'
|
||||||
|
: 'bg-orange-900/50 text-orange-400'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ item.game.toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div v-if="editingItem?._id === item._id" class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
v-model.number="editingItem.price"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
class="w-24 px-2 py-1 bg-gray-900 border border-gray-700 rounded text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="font-semibold text-green-400">
|
||||||
|
{{ formatCurrency(item.price) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-400">{{ item.priceType || 'safe' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
{{ formatDate(item.lastUpdated) }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
<div v-if="editingItem?._id === item._id" class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
@click="saveItem"
|
||||||
|
class="p-2 bg-green-600 hover:bg-green-700 rounded transition-colors"
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Check class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="cancelEdit"
|
||||||
|
class="p-2 bg-gray-600 hover:bg-gray-700 rounded transition-colors"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
@click="editItem(item)"
|
||||||
|
class="p-2 bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
||||||
|
title="Edit Price"
|
||||||
|
>
|
||||||
|
<Edit class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteItem(item)"
|
||||||
|
class="p-2 bg-red-600 hover:bg-red-700 rounded transition-colors"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="px-6 py-4 bg-gray-900 border-t border-gray-700 flex items-center justify-between">
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
Showing {{ (pagination.page - 1) * pagination.limit + 1 }} to
|
||||||
|
{{ Math.min(pagination.page * pagination.limit, pagination.total) }} of
|
||||||
|
{{ pagination.total }} items
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="goToPage(1)"
|
||||||
|
:disabled="pagination.page === 1"
|
||||||
|
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronsLeft class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goToPage(pagination.page - 1)"
|
||||||
|
:disabled="pagination.page === 1"
|
||||||
|
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Page Numbers -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
v-for="page in visiblePages"
|
||||||
|
:key="page"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
class="px-3 py-1 rounded transition-colors"
|
||||||
|
:class="
|
||||||
|
page === pagination.page
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-800 hover:bg-gray-700'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="goToPage(pagination.page + 1)"
|
||||||
|
:disabled="pagination.page >= pagination.pages"
|
||||||
|
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="goToPage(pagination.pages)"
|
||||||
|
:disabled="pagination.page >= pagination.pages"
|
||||||
|
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronsRight class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import axios from '@/utils/axios';
|
||||||
|
import { useToast } from 'vue-toastification';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronsLeft,
|
||||||
|
ChevronsRight,
|
||||||
|
} from 'lucide-vue-next';
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const items = ref([]);
|
||||||
|
const stats = ref({
|
||||||
|
total: 0,
|
||||||
|
cs2: 0,
|
||||||
|
rust: 0,
|
||||||
|
lastUpdated: null,
|
||||||
|
});
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
const editingItem = ref(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const filters = ref({
|
||||||
|
search: '',
|
||||||
|
game: '',
|
||||||
|
sort: 'name-asc',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const pagination = ref({
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
total: 0,
|
||||||
|
pages: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
const visiblePages = computed(() => {
|
||||||
|
const current = pagination.value.page;
|
||||||
|
const total = pagination.value.pages;
|
||||||
|
const delta = 2;
|
||||||
|
const pages = [];
|
||||||
|
|
||||||
|
for (let i = Math.max(1, current - delta); i <= Math.min(total, current + delta); i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Methods
|
||||||
|
const loadItems = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.value.page,
|
||||||
|
limit: pagination.value.limit,
|
||||||
|
game: filters.value.game,
|
||||||
|
search: filters.value.search,
|
||||||
|
sort: filters.value.sort,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.get('/api/admin/marketprices', { params });
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
items.value = response.data.items || [];
|
||||||
|
pagination.value = {
|
||||||
|
page: response.data.page || 1,
|
||||||
|
limit: response.data.limit || 50,
|
||||||
|
total: response.data.total || 0,
|
||||||
|
pages: response.data.pages || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load items:', error);
|
||||||
|
toast.error('Failed to load items');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStats = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/admin/marketprices/stats');
|
||||||
|
if (response.data.success) {
|
||||||
|
stats.value = response.data.stats;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load stats:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPrices = async () => {
|
||||||
|
if (isRefreshing.value) return;
|
||||||
|
|
||||||
|
isRefreshing.value = true;
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/admin/prices/update', {
|
||||||
|
game: 'all',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('Price update started! This may take a few minutes.');
|
||||||
|
// Reload after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
loadItems();
|
||||||
|
loadStats();
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh prices:', error);
|
||||||
|
toast.error('Failed to start price update');
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editItem = (item) => {
|
||||||
|
editingItem.value = { ...item };
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
editingItem.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveItem = async () => {
|
||||||
|
if (!editingItem.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.patch(
|
||||||
|
`/api/admin/marketprices/${editingItem.value._id}`,
|
||||||
|
{
|
||||||
|
price: editingItem.value.price,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('Price updated successfully');
|
||||||
|
// Update item in list
|
||||||
|
const index = items.value.findIndex((i) => i._id === editingItem.value._id);
|
||||||
|
if (index !== -1) {
|
||||||
|
items.value[index] = { ...editingItem.value };
|
||||||
|
}
|
||||||
|
editingItem.value = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update item:', error);
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to update item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteItem = async (item) => {
|
||||||
|
if (!confirm(`Delete "${item.name}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.delete(`/api/admin/marketprices/${item._id}`);
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('Item deleted successfully');
|
||||||
|
loadItems();
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete item:', error);
|
||||||
|
toast.error(error.response?.data?.message || 'Failed to delete item');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToPage = (page) => {
|
||||||
|
if (page < 1 || page > pagination.value.pages) return;
|
||||||
|
pagination.value.page = page;
|
||||||
|
loadItems();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
let searchTimeout;
|
||||||
|
const debouncedSearch = () => {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
pagination.value.page = 1; // Reset to first page
|
||||||
|
loadItems();
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Formatting helpers
|
||||||
|
const formatCurrency = (value) => {
|
||||||
|
if (value === null || value === undefined) return 'N/A';
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return 'N/A';
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
onMounted(() => {
|
||||||
|
loadItems();
|
||||||
|
loadStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Add any custom styles here */
|
||||||
|
</style>
|
||||||
@@ -477,7 +477,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items Tab -->
|
<!-- Items Tab -->
|
||||||
<div v-if="activeTab === 'items'" class="space-y-6">
|
<div v-if="activeTab === 'items'">
|
||||||
|
<AdminItemsPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Old Items Tab (keeping for reference, remove if not needed) -->
|
||||||
|
<div v-if="activeTab === 'items-old'" class="space-y-6">
|
||||||
<!-- Game Filter -->
|
<!-- Game Filter -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -753,6 +758,7 @@ import axios from "../utils/axios";
|
|||||||
import AdminUsersPanel from "../components/AdminUsersPanel.vue";
|
import AdminUsersPanel from "../components/AdminUsersPanel.vue";
|
||||||
import AdminConfigPanel from "../components/AdminConfigPanel.vue";
|
import AdminConfigPanel from "../components/AdminConfigPanel.vue";
|
||||||
import AdminDebugPanel from "../components/AdminDebugPanel.vue";
|
import AdminDebugPanel from "../components/AdminDebugPanel.vue";
|
||||||
|
import AdminItemsPanel from "../components/AdminItemsPanel.vue";
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
|||||||
241
routes/admin.js
241
routes/admin.js
@@ -1,6 +1,7 @@
|
|||||||
import { authenticate } from "../middleware/auth.js";
|
import { authenticate, isAdmin } from "../middleware/auth.js";
|
||||||
import pricingService from "../services/pricing.js";
|
import pricingService from "../services/pricing.js";
|
||||||
import Item from "../models/Item.js";
|
import Item from "../models/Item.js";
|
||||||
|
import MarketPrice from "../models/MarketPrice.js";
|
||||||
import Transaction from "../models/Transaction.js";
|
import Transaction from "../models/Transaction.js";
|
||||||
import User from "../models/User.js";
|
import User from "../models/User.js";
|
||||||
|
|
||||||
@@ -1115,4 +1116,242 @@ export default async function adminRoutes(fastify, options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// MARKETPRICE MANAGEMENT
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// GET /admin/marketprices - Get paginated list of MarketPrice items
|
||||||
|
fastify.get(
|
||||||
|
"/marketprices",
|
||||||
|
{
|
||||||
|
preHandler: [authenticate, isAdmin],
|
||||||
|
schema: {
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
page: { type: "integer", minimum: 1, default: 1 },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
||||||
|
game: { type: "string", enum: ["cs2", "rust", ""] },
|
||||||
|
search: { type: "string" },
|
||||||
|
sort: { type: "string", default: "name-asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 50,
|
||||||
|
game = "",
|
||||||
|
search = "",
|
||||||
|
sort = "name-asc",
|
||||||
|
} = request.query;
|
||||||
|
|
||||||
|
// Build query
|
||||||
|
const query = {};
|
||||||
|
if (game) {
|
||||||
|
query.game = game;
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
query.$or = [
|
||||||
|
{ name: { $regex: search, $options: "i" } },
|
||||||
|
{ marketHashName: { $regex: search, $options: "i" } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sort
|
||||||
|
let sortObj = {};
|
||||||
|
switch (sort) {
|
||||||
|
case "name-asc":
|
||||||
|
sortObj = { name: 1 };
|
||||||
|
break;
|
||||||
|
case "name-desc":
|
||||||
|
sortObj = { name: -1 };
|
||||||
|
break;
|
||||||
|
case "price-asc":
|
||||||
|
sortObj = { price: 1 };
|
||||||
|
break;
|
||||||
|
case "price-desc":
|
||||||
|
sortObj = { price: -1 };
|
||||||
|
break;
|
||||||
|
case "updated-desc":
|
||||||
|
sortObj = { lastUpdated: -1 };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
sortObj = { name: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const total = await MarketPrice.countDocuments(query);
|
||||||
|
|
||||||
|
// Get items
|
||||||
|
const items = await MarketPrice.find(query)
|
||||||
|
.sort(sortObj)
|
||||||
|
.limit(limit)
|
||||||
|
.skip((page - 1) * limit)
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
items,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to get market prices:", error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get market prices",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /admin/marketprices/stats - Get MarketPrice statistics
|
||||||
|
fastify.get(
|
||||||
|
"/marketprices/stats",
|
||||||
|
{
|
||||||
|
preHandler: [authenticate, isAdmin],
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const total = await MarketPrice.countDocuments();
|
||||||
|
const cs2 = await MarketPrice.countDocuments({ game: "cs2" });
|
||||||
|
const rust = await MarketPrice.countDocuments({ game: "rust" });
|
||||||
|
|
||||||
|
// Get most recent update
|
||||||
|
const lastItem = await MarketPrice.findOne()
|
||||||
|
.sort({ lastUpdated: -1 })
|
||||||
|
.select("lastUpdated")
|
||||||
|
.lean();
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
total,
|
||||||
|
cs2,
|
||||||
|
rust,
|
||||||
|
lastUpdated: lastItem?.lastUpdated || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to get market price stats:", error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to get stats",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// PATCH /admin/marketprices/:id - Update a MarketPrice item
|
||||||
|
fastify.patch(
|
||||||
|
"/marketprices/:id",
|
||||||
|
{
|
||||||
|
preHandler: [authenticate, isAdmin],
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
price: { type: "number", minimum: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
const { price } = request.body;
|
||||||
|
|
||||||
|
const item = await MarketPrice.findById(id);
|
||||||
|
if (!item) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
message: "Item not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
item.price = price;
|
||||||
|
item.priceType = "manual"; // Mark as manually updated
|
||||||
|
item.lastUpdated = new Date();
|
||||||
|
await item.save();
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`⚙️ Admin ${request.user.username} updated price for ${item.name}: $${price}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
message: "Price updated successfully",
|
||||||
|
item,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to update market price:", error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to update price",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// DELETE /admin/marketprices/:id - Delete a MarketPrice item
|
||||||
|
fastify.delete(
|
||||||
|
"/marketprices/:id",
|
||||||
|
{
|
||||||
|
preHandler: [authenticate, isAdmin],
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["id"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
const item = await MarketPrice.findByIdAndDelete(id);
|
||||||
|
if (!item) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
success: false,
|
||||||
|
message: "Item not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`⚙️ Admin ${request.user.username} deleted market price for ${item.name}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
message: "Item deleted successfully",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Failed to delete market price:", error);
|
||||||
|
return reply.status(500).send({
|
||||||
|
success: false,
|
||||||
|
message: "Failed to delete item",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user