system now uses seperate pricing.
All checks were successful
Build Frontend / Build Frontend (push) Successful in 22s

This commit is contained in:
2026-01-11 03:31:54 +00:00
parent 7a32454b83
commit 02d9727a72
3 changed files with 770 additions and 2 deletions

View 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>

View File

@@ -477,7 +477,12 @@
</div>
<!-- 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 -->
<div class="flex gap-2">
<button
@@ -753,6 +758,7 @@ import axios from "../utils/axios";
import AdminUsersPanel from "../components/AdminUsersPanel.vue";
import AdminConfigPanel from "../components/AdminConfigPanel.vue";
import AdminDebugPanel from "../components/AdminDebugPanel.vue";
import AdminItemsPanel from "../components/AdminItemsPanel.vue";
import {
Shield,
RefreshCw,