feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,16 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { StoreCategory, StoreItem } from '@/types'
|
||||
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
import Panel from '@/components/ds/data/Panel.vue'
|
||||
import Button from '@/components/ds/core/Button.vue'
|
||||
import Badge from '@/components/ds/core/Badge.vue'
|
||||
import IconButton from '@/components/ds/core/IconButton.vue'
|
||||
import Tabs from '@/components/ds/navigation/Tabs.vue'
|
||||
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
|
||||
import Input from '@/components/ds/forms/Input.vue'
|
||||
import Select from '@/components/ds/forms/Select.vue'
|
||||
import Checkbox from '@/components/ds/forms/Checkbox.vue'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -42,17 +50,23 @@ const itemTypes = [
|
||||
{ value: 'kit', label: 'Kit', example: 'inventory.giveto {steam_id} rifle.ak 1' },
|
||||
{ value: 'rank', label: 'Rank', example: 'oxide.usergroup add {steam_id} vip' },
|
||||
{ value: 'currency', label: 'Currency', example: 'eco deposit {steam_id} 1000' },
|
||||
{ value: 'command', label: 'Custom Command', example: 'yourplugin.givereward {steam_id}' }
|
||||
{ value: 'command', label: 'Custom command', example: 'yourplugin.givereward {steam_id}' }
|
||||
]
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'kit': return 'bg-blue-500/15 text-blue-400'
|
||||
case 'rank': return 'bg-purple-500/15 text-purple-400'
|
||||
case 'currency': return 'bg-yellow-500/15 text-yellow-400'
|
||||
case 'command': return 'bg-oxide-500/15 text-oxide-400'
|
||||
default: return 'bg-neutral-700/50 text-neutral-400'
|
||||
const tabItems = computed(() => [
|
||||
{ value: 'categories', label: 'Categories', count: categories.value.length },
|
||||
{ value: 'items', label: 'Items', count: items.value.length },
|
||||
])
|
||||
|
||||
type ItemTypeTone = 'info' | 'accent' | 'warn' | 'neutral'
|
||||
function typeTone(type: string): ItemTypeTone {
|
||||
const map: Record<string, ItemTypeTone> = {
|
||||
kit: 'info',
|
||||
rank: 'accent',
|
||||
currency: 'warn',
|
||||
command: 'accent',
|
||||
}
|
||||
return map[type] ?? 'neutral'
|
||||
}
|
||||
|
||||
function autoGenerateSlug(name: string): string {
|
||||
@@ -188,7 +202,6 @@ function removeCommand(index: number) {
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
// Validate
|
||||
if (!itemForm.value.name.trim()) {
|
||||
alert('Item name is required')
|
||||
return
|
||||
@@ -234,12 +247,22 @@ async function deleteItem(item: StoreItem) {
|
||||
function getCategoryName(categoryId: string | null): string {
|
||||
if (!categoryId) return 'Uncategorized'
|
||||
const cat = categories.value.find(c => c.id === categoryId)
|
||||
return cat?.name || 'Unknown'
|
||||
return cat?.name ?? 'Unknown'
|
||||
}
|
||||
|
||||
const selectedTypeExample = computed(() => {
|
||||
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
|
||||
return type?.example || ''
|
||||
return type?.example ?? ''
|
||||
})
|
||||
|
||||
const categorySelectOptions = computed(() => [
|
||||
{ value: '', label: 'Uncategorized' },
|
||||
...categories.value.map(c => ({ value: c.id, label: c.name }))
|
||||
])
|
||||
|
||||
const categorySelectValue = computed({
|
||||
get: () => itemForm.value.category_id ?? '',
|
||||
set: (v: string) => { itemForm.value.category_id = v || null }
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@@ -249,447 +272,408 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<ShoppingBag class="w-5 h-5 text-oxide-500" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-neutral-100">Store Items</h1>
|
||||
<p class="text-sm text-neutral-500 mt-0.5">
|
||||
{{ categories.length }} categories, {{ items.length }} items
|
||||
</p>
|
||||
</div>
|
||||
<div class="si-page">
|
||||
<!-- Page head -->
|
||||
<div class="page__head">
|
||||
<div>
|
||||
<div class="t-eyebrow">Management · Store</div>
|
||||
<h1 class="page__title">Store items</h1>
|
||||
<p class="page__sub">{{ categories.length }} categories · {{ items.length }} items</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
@click="tab === 'categories' ? fetchCategories() : fetchItems()"
|
||||
:disabled="isLoading"
|
||||
class="flex items-center gap-2 px-3 py-2 text-sm text-neutral-400 hover:text-neutral-200 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
<button
|
||||
v-if="tab === 'categories'"
|
||||
@click="openCategoryModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Category
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="openItemModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-oxide-600 hover:bg-oxide-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
Add Item
|
||||
</button>
|
||||
<div class="page__actions">
|
||||
<IconButton icon="refresh-cw" label="Refresh" :class="{ 'si-spin': isLoading }" @click="tab === 'categories' ? fetchCategories() : fetchItems()" />
|
||||
<Button v-if="tab === 'categories'" icon="plus" @click="openCategoryModal()">Add category</Button>
|
||||
<Button v-else icon="plus" @click="openItemModal()">Add item</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden w-fit">
|
||||
<button
|
||||
@click="tab = 'categories'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'categories' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Categories
|
||||
</button>
|
||||
<button
|
||||
@click="tab = 'items'"
|
||||
class="px-4 py-2 text-sm font-medium transition-colors"
|
||||
:class="tab === 'items' ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
Items
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tab bar + content panel -->
|
||||
<Panel :flush-body="true">
|
||||
<template #actions>
|
||||
<Tabs v-model="tab" :items="tabItems" />
|
||||
</template>
|
||||
|
||||
<!-- Categories Tab -->
|
||||
<div v-if="tab === 'categories'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- Categories table -->
|
||||
<table v-if="tab === 'categories'" class="si-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Slug</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Display Order</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Visible</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slug</th>
|
||||
<th class="si-col-num">Order</th>
|
||||
<th>Visibility</th>
|
||||
<th class="si-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tbody>
|
||||
<tr v-if="categories.length === 0">
|
||||
<td colspan="5" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading categories...</template>
|
||||
<template v-else>No categories yet. Add one to organize your store items.</template>
|
||||
<td colspan="5" class="si-empty-cell">
|
||||
<EmptyState
|
||||
icon="folder-open"
|
||||
:title="isLoading ? 'Loading…' : 'No categories'"
|
||||
:description="isLoading ? '' : 'Add a category to organize your store items.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ category.name }}</p>
|
||||
<p v-if="category.description" class="text-xs text-neutral-500 truncate max-w-md">{{ category.description }}</p>
|
||||
<tr v-for="category in categories" :key="category.id">
|
||||
<td class="si-cell-primary">
|
||||
<span class="si-name">{{ category.name }}</span>
|
||||
<span v-if="category.description" class="si-sub">{{ category.description }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">{{ category.slug }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ category.display_order }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="category.visible ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<td><span class="si-mono">{{ category.slug }}</span></td>
|
||||
<td class="si-col-num si-mono">{{ category.display_order }}</td>
|
||||
<td>
|
||||
<Badge :tone="category.visible ? 'online' : 'neutral'">
|
||||
{{ category.visible ? 'Visible' : 'Hidden' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="openCategoryModal(category)"
|
||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteCategory(category)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<td class="si-col-actions">
|
||||
<div class="si-row-actions">
|
||||
<IconButton icon="pencil" label="Edit" size="sm" @click="openCategoryModal(category)" />
|
||||
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteCategory(category)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Items Tab -->
|
||||
<div v-if="tab === 'items'" class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<table class="w-full">
|
||||
<!-- Items table -->
|
||||
<table v-if="tab === 'items'" class="si-table">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left">
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Item</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Type</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Price</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Commands</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-xs font-medium text-neutral-500 uppercase tracking-wider text-right">Actions</th>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Category</th>
|
||||
<th>Type</th>
|
||||
<th class="si-col-price">Price</th>
|
||||
<th>Commands</th>
|
||||
<th>Status</th>
|
||||
<th class="si-col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tbody>
|
||||
<tr v-if="items.length === 0">
|
||||
<td colspan="7" class="px-4 py-12 text-center text-neutral-500 text-sm">
|
||||
<template v-if="isLoading">Loading items...</template>
|
||||
<template v-else>No items yet. Add items to start selling.</template>
|
||||
<td colspan="7" class="si-empty-cell">
|
||||
<EmptyState
|
||||
icon="shopping-bag"
|
||||
:title="isLoading ? 'Loading…' : 'No items'"
|
||||
:description="isLoading ? '' : 'Add items to start selling.'"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="hover:bg-neutral-800/50 transition-colors"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<p class="text-sm font-medium text-neutral-100">{{ item.name }}</p>
|
||||
<p v-if="item.description" class="text-xs text-neutral-500 truncate max-w-xs">{{ item.description }}</p>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td class="si-cell-primary">
|
||||
<span class="si-name">{{ item.name }}</span>
|
||||
<span v-if="item.description" class="si-sub">{{ item.description }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ getCategoryName(item.category_id) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium px-2 py-0.5 rounded-full" :class="typeBadgeClass(item.item_type)">
|
||||
{{ item.item_type }}
|
||||
</span>
|
||||
<td class="si-text-secondary">{{ getCategoryName(item.category_id) }}</td>
|
||||
<td><Badge :tone="typeTone(item.item_type)">{{ item.item_type }}</Badge></td>
|
||||
<td class="si-col-price">
|
||||
<span class="si-price">${{ safeFixed(item.price, 2) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1 text-sm text-neutral-200">
|
||||
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
|
||||
{{ safeFixed(item.price, 2) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
:class="item.enabled ? 'bg-green-500/10 text-green-400' : 'bg-neutral-700/50 text-neutral-400'"
|
||||
>
|
||||
<td><span class="si-mono si-text-secondary">{{ item.delivery_commands.length }} cmd{{ item.delivery_commands.length !== 1 ? 's' : '' }}</span></td>
|
||||
<td>
|
||||
<Badge :tone="item.enabled ? 'online' : 'neutral'">
|
||||
{{ item.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button
|
||||
@click="openItemModal(item)"
|
||||
class="p-1.5 text-neutral-500 hover:text-oxide-400 rounded transition-colors"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteItem(item)"
|
||||
class="p-1.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
<td class="si-col-actions">
|
||||
<div class="si-row-actions">
|
||||
<IconButton icon="pencil" label="Edit" size="sm" @click="openItemModal(item)" />
|
||||
<IconButton icon="trash-2" label="Delete" size="sm" variant="danger" @click="deleteItem(item)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<!-- Category Modal -->
|
||||
<!-- ===== Category modal ===== -->
|
||||
<div
|
||||
v-if="showCategoryModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="si-modal-backdrop"
|
||||
@click.self="closeCategoryModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-lg w-full">
|
||||
<div class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingCategory ? 'Edit Category' : 'Add Category' }}</h2>
|
||||
<button @click="closeCategoryModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="si-modal">
|
||||
<div class="si-modal__head">
|
||||
<h2 class="si-modal__title">{{ editingCategory ? 'Edit category' : 'Add category' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeCategoryModal" />
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Name</label>
|
||||
<input
|
||||
v-model="categoryForm.name"
|
||||
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
||||
type="text"
|
||||
placeholder="VIP Kits"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Slug (URL-safe)</label>
|
||||
<input
|
||||
v-model="categoryForm.slug"
|
||||
type="text"
|
||||
placeholder="vip-kits"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||
<div class="si-modal__body">
|
||||
<Input
|
||||
v-model="categoryForm.name"
|
||||
label="Name"
|
||||
placeholder="VIP Kits"
|
||||
@input="categoryForm.slug = autoGenerateSlug(categoryForm.name)"
|
||||
/>
|
||||
<Input
|
||||
v-model="categoryForm.slug"
|
||||
label="Slug (URL-safe)"
|
||||
placeholder="vip-kits"
|
||||
:mono="true"
|
||||
/>
|
||||
<label class="cc-field">
|
||||
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||
<textarea
|
||||
v-model="categoryForm.description"
|
||||
class="cc-textarea"
|
||||
rows="2"
|
||||
placeholder="Premium kits for VIP players"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Display Order</label>
|
||||
<input
|
||||
v-model.number="categoryForm.display_order"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="categoryForm.visible"
|
||||
type="checkbox"
|
||||
id="category-visible"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
||||
/>
|
||||
<label for="category-visible" class="text-sm text-neutral-300">Visible to customers</label>
|
||||
</div>
|
||||
</label>
|
||||
<Input
|
||||
v-model="categoryForm.display_order as any"
|
||||
label="Display order"
|
||||
type="number"
|
||||
/>
|
||||
<Checkbox v-model="categoryForm.visible" label="Visible to customers" />
|
||||
</div>
|
||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeCategoryModal"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveCategory"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingCategory ? 'Save Changes' : 'Create Category' }}
|
||||
</button>
|
||||
<div class="si-modal__foot">
|
||||
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
|
||||
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Item Modal -->
|
||||
<!-- ===== Item modal ===== -->
|
||||
<div
|
||||
v-if="showItemModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
class="si-modal-backdrop"
|
||||
@click.self="closeItemModal"
|
||||
>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div class="sticky top-0 bg-neutral-900 border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-neutral-100">{{ editingItem ? 'Edit Item' : 'Add Item' }}</h2>
|
||||
<button @click="closeItemModal" class="p-2 text-neutral-400 hover:text-neutral-200 rounded-lg transition-colors">
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="si-modal si-modal--wide">
|
||||
<div class="si-modal__head si-modal__head--sticky">
|
||||
<h2 class="si-modal__title">{{ editingItem ? 'Edit item' : 'Add item' }}</h2>
|
||||
<IconButton icon="x" label="Close" @click="closeItemModal" />
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- Basic Info -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Basic Information</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Item Name</label>
|
||||
<input
|
||||
v-model="itemForm.name"
|
||||
type="text"
|
||||
placeholder="VIP Starter Kit"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Description (optional)</label>
|
||||
<div class="si-modal__body">
|
||||
<!-- Basic info section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Basic information</div>
|
||||
<Input v-model="itemForm.name" label="Item name" placeholder="VIP Starter Kit" />
|
||||
<label class="cc-field">
|
||||
<span class="cc-field__label">Description <span class="si-opt">(optional)</span></span>
|
||||
<textarea
|
||||
v-model="itemForm.description"
|
||||
class="cc-textarea"
|
||||
rows="2"
|
||||
placeholder="Get started with essential gear and resources"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Category</label>
|
||||
<select
|
||||
v-model="itemForm.category_id"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
>
|
||||
<option :value="null">Uncategorized</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
<Select
|
||||
v-model="categorySelectValue"
|
||||
label="Category"
|
||||
:options="categorySelectOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Pricing -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Pricing</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Price (USD)</label>
|
||||
<div class="relative">
|
||||
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" />
|
||||
<input
|
||||
v-model.number="itemForm.price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="9.99"
|
||||
class="w-full pl-10 pr-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pricing section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Pricing</div>
|
||||
<Input
|
||||
v-model="itemForm.price as any"
|
||||
label="Price (USD)"
|
||||
type="number"
|
||||
prefix="$"
|
||||
placeholder="9.99"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Item Type -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Item Type</h3>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<!-- Item type section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Item type</div>
|
||||
<div class="si-type-grid">
|
||||
<button
|
||||
v-for="type in itemTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
class="si-type-btn"
|
||||
:class="itemForm.item_type === type.value ? 'si-type-btn--active' : ''"
|
||||
@click="itemForm.item_type = type.value as any"
|
||||
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors"
|
||||
:class="itemForm.item_type === type.value
|
||||
? 'bg-oxide-500/15 border-oxide-500 text-oxide-400'
|
||||
: 'bg-neutral-800 border-neutral-700 text-neutral-400 hover:text-neutral-200'"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delivery Commands -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Delivery Commands</h3>
|
||||
<button
|
||||
@click="addCommand"
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 border border-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus class="w-3 h-3" />
|
||||
Add Command
|
||||
</button>
|
||||
<!-- Delivery commands section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label-row">
|
||||
<div class="si-section__label">Delivery commands</div>
|
||||
<Button size="sm" variant="ghost" icon="plus" @click="addCommand">Add command</Button>
|
||||
</div>
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-3 space-y-1.5 text-xs">
|
||||
<p class="text-neutral-400">Use placeholders:</p>
|
||||
<p class="text-neutral-300 font-mono">{'{steam_id}'} - Player's Steam ID</p>
|
||||
<p class="text-neutral-300 font-mono">{'{player_name}'} - Player's name</p>
|
||||
<p class="text-neutral-500 mt-2">Example: {{ selectedTypeExample }}</p>
|
||||
<div class="si-cmd-hint">
|
||||
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{steam_id}'}</span> Player's Steam ID</p>
|
||||
<p class="si-cmd-hint__row"><span class="si-cmd-hint__mono">{'{player_name}'}</span> Player's name</p>
|
||||
<p class="si-cmd-hint__example">Example: {{ selectedTypeExample }}</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="flex gap-2">
|
||||
<input
|
||||
<div class="si-commands">
|
||||
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
|
||||
<Input
|
||||
v-model="itemForm.delivery_commands[index]"
|
||||
type="text"
|
||||
:mono="true"
|
||||
placeholder="inventory.giveto {steam_id} rifle.ak 1"
|
||||
class="flex-1 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 font-mono placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
<IconButton
|
||||
v-if="itemForm.delivery_commands.length > 1"
|
||||
icon="trash-2"
|
||||
label="Remove"
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@click="removeCommand(index)"
|
||||
class="p-2 text-neutral-500 hover:text-red-400 rounded-lg transition-colors"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Settings -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-neutral-400 uppercase tracking-wider">Additional Settings</h3>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Image URL (optional)</label>
|
||||
<input
|
||||
v-model="itemForm.image_url"
|
||||
type="text"
|
||||
placeholder="https://example.com/image.png"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-neutral-300 mb-2">Purchase Limit Per Player (optional)</label>
|
||||
<input
|
||||
v-model.number="itemForm.limit_per_player"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Leave empty for unlimited"
|
||||
class="w-full px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg text-sm text-neutral-100 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-oxide-500/50 focus:border-oxide-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
v-model="itemForm.enabled"
|
||||
type="checkbox"
|
||||
id="item-enabled"
|
||||
class="w-4 h-4 rounded border-neutral-700 bg-neutral-800 text-oxide-600 focus:ring-2 focus:ring-oxide-500/50 transition-colors"
|
||||
/>
|
||||
<label for="item-enabled" class="text-sm text-neutral-300">Enabled and available for purchase</label>
|
||||
</div>
|
||||
<!-- Additional settings section -->
|
||||
<div class="si-section">
|
||||
<div class="si-section__label">Additional settings</div>
|
||||
<Input
|
||||
v-model="itemForm.image_url"
|
||||
label="Image URL"
|
||||
placeholder="https://example.com/image.png"
|
||||
hint="Optional preview image for the store page."
|
||||
/>
|
||||
<Input
|
||||
v-model="itemForm.limit_per_player as any"
|
||||
label="Purchase limit per player"
|
||||
type="number"
|
||||
placeholder="Leave empty for unlimited"
|
||||
hint="Optional. Restricts how many times a player can purchase this item."
|
||||
/>
|
||||
<Checkbox v-model="itemForm.enabled" label="Enabled and available for purchase" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-neutral-800 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<button
|
||||
@click="closeItemModal"
|
||||
class="px-4 py-2 text-sm font-medium text-neutral-300 bg-neutral-800 hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
@click="saveItem"
|
||||
class="px-6 py-2 text-sm font-medium text-white bg-oxide-600 hover:bg-oxide-700 rounded-lg transition-colors"
|
||||
>
|
||||
{{ editingItem ? 'Save Changes' : 'Create Item' }}
|
||||
</button>
|
||||
<div class="si-modal__foot">
|
||||
<Button variant="secondary" @click="closeItemModal">Cancel</Button>
|
||||
<Button @click="saveItem">{{ editingItem ? 'Save changes' : 'Create item' }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.si-page { max-width: 1200px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.page__head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 16px; flex-wrap: wrap; row-gap: 12px;
|
||||
}
|
||||
.page__title {
|
||||
font-size: var(--text-3xl); font-weight: 700; letter-spacing: -0.02em;
|
||||
color: var(--text-primary); margin-top: 5px;
|
||||
}
|
||||
.page__sub { font-size: var(--text-sm); color: var(--text-tertiary); margin-top: 3px; }
|
||||
.page__actions { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Table */
|
||||
.si-table { width: 100%; border-collapse: collapse; }
|
||||
.si-table thead th {
|
||||
padding: 10px 16px; text-align: left;
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.si-table tbody tr { border-bottom: 1px solid var(--border-subtle); }
|
||||
.si-table tbody tr:last-child { border-bottom: none; }
|
||||
.si-table tbody tr:hover { background: var(--surface-hover); }
|
||||
.si-table td { padding: 11px 16px; vertical-align: middle; }
|
||||
|
||||
.si-cell-primary { display: flex; flex-direction: column; gap: 2px; }
|
||||
.si-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-primary); }
|
||||
.si-sub { font-size: var(--text-xs); color: var(--text-tertiary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 320px; }
|
||||
.si-mono { font-family: var(--font-mono); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
|
||||
.si-text-secondary { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.si-price { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: var(--text-sm); font-weight: 600; color: var(--text-primary); }
|
||||
|
||||
.si-col-num { width: 80px; text-align: right; }
|
||||
.si-col-num.si-mono { text-align: right; }
|
||||
.si-col-price { width: 90px; }
|
||||
.si-col-actions { width: 80px; }
|
||||
.si-row-actions { display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
||||
|
||||
.si-empty-cell { padding: 0 !important; }
|
||||
|
||||
.si-spin { animation: si-spin 0.8s linear infinite; }
|
||||
@keyframes si-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Modal */
|
||||
.si-modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 60;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0,0,0,.6); backdrop-filter: blur(4px); padding: 16px;
|
||||
}
|
||||
.si-modal {
|
||||
background: var(--surface-base); border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-xl, var(--ring-default)); max-width: 520px; width: 100%;
|
||||
display: flex; flex-direction: column; max-height: 90vh;
|
||||
}
|
||||
.si-modal--wide { max-width: 680px; }
|
||||
|
||||
.si-modal__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
.si-modal__head--sticky { position: sticky; top: 0; background: var(--surface-base); z-index: 1; }
|
||||
.si-modal__title { font-size: var(--text-lg); font-weight: 700; color: var(--text-primary); }
|
||||
|
||||
.si-modal__body {
|
||||
padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px;
|
||||
}
|
||||
|
||||
.si-modal__foot {
|
||||
display: flex; align-items: center; justify-content: flex-end;
|
||||
gap: 8px; padding: 14px 20px; border-top: 1px solid var(--border-subtle);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Item modal sections */
|
||||
.si-section { display: flex; flex-direction: column; gap: 12px; }
|
||||
.si-section__label {
|
||||
font-size: var(--text-xs); font-weight: 600; color: var(--text-muted);
|
||||
text-transform: uppercase; letter-spacing: var(--tracking-wider);
|
||||
}
|
||||
.si-section__label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
|
||||
/* Item type selector */
|
||||
.si-type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.si-type-btn {
|
||||
padding: 9px 14px; font-size: var(--text-sm); font-weight: 500; font-family: var(--font-sans);
|
||||
border-radius: var(--radius-md); border: none; cursor: pointer;
|
||||
background: var(--surface-inset); color: var(--text-secondary);
|
||||
box-shadow: var(--ring-default); transition: var(--transition-colors);
|
||||
}
|
||||
.si-type-btn:hover { background: var(--surface-hover); color: var(--text-primary); }
|
||||
.si-type-btn--active {
|
||||
background: var(--accent-soft); color: var(--accent-text);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
|
||||
/* Command hint */
|
||||
.si-cmd-hint {
|
||||
background: var(--surface-inset); border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); padding: 10px 13px;
|
||||
display: flex; flex-direction: column; gap: 3px;
|
||||
}
|
||||
.si-cmd-hint__row { font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
.si-cmd-hint__mono { font-family: var(--font-mono); color: var(--text-primary); margin-right: 6px; }
|
||||
.si-cmd-hint__example { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-muted); margin-top: 4px; }
|
||||
|
||||
.si-commands { display: flex; flex-direction: column; gap: 8px; }
|
||||
.si-command-row { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.si-opt { font-weight: 400; color: var(--text-muted); }
|
||||
|
||||
/* Textarea token style */
|
||||
.cc-textarea {
|
||||
width: 100%; min-height: 68px; padding: 9px 11px;
|
||||
background: var(--surface-inset); border: none; border-radius: var(--radius-md);
|
||||
box-shadow: var(--ring-default); resize: vertical;
|
||||
font-family: var(--font-sans); font-size: var(--text-sm); color: var(--text-primary);
|
||||
line-height: var(--leading-normal); outline: none;
|
||||
transition: var(--transition-colors);
|
||||
}
|
||||
.cc-textarea::placeholder { color: var(--text-muted); }
|
||||
.cc-textarea:focus { box-shadow: inset 0 0 0 1px var(--accent), var(--glow-accent-sm); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user