Files
corrosion-admin-panel/frontend/src/views/admin/StoreItemsView.vue
Vantz Stockwell b42a2d7ea7
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
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>
2026-06-11 02:34:46 -04:00

680 lines
23 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useApi } from '@/composables/useApi'
import type { StoreCategory, StoreItem } from '@/types'
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()
const tab = ref<'categories' | 'items'>('categories')
const isLoading = ref(false)
const categories = ref<StoreCategory[]>([])
const items = ref<StoreItem[]>([])
// Category modal state
const showCategoryModal = ref(false)
const editingCategory = ref<StoreCategory | null>(null)
const categoryForm = ref({
name: '',
slug: '',
description: '',
display_order: 0,
visible: true
})
// Item modal state
const showItemModal = ref(false)
const editingItem = ref<StoreItem | null>(null)
const itemForm = ref({
category_id: null as string | null,
name: '',
description: '',
price: 0,
image_url: '',
item_type: 'kit' as 'kit' | 'rank' | 'currency' | 'command',
delivery_commands: [''],
limit_per_player: null as number | null,
enabled: true
})
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}' }
]
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 {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
}
async function fetchCategories() {
isLoading.value = true
try {
const data = await api.get<StoreCategory[]>('/webstore/categories')
categories.value = data
} catch (error) {
console.error('Failed to fetch categories:', error)
} finally {
isLoading.value = false
}
}
async function fetchItems() {
isLoading.value = true
try {
const data = await api.get<StoreItem[]>('/webstore/items')
items.value = data
} catch (error) {
console.error('Failed to fetch items:', error)
} finally {
isLoading.value = false
}
}
function openCategoryModal(category?: StoreCategory) {
if (category) {
editingCategory.value = category
categoryForm.value = {
name: category.name,
slug: category.slug,
description: category.description || '',
display_order: category.display_order,
visible: category.visible
}
} else {
editingCategory.value = null
categoryForm.value = {
name: '',
slug: '',
description: '',
display_order: categories.value.length,
visible: true
}
}
showCategoryModal.value = true
}
function closeCategoryModal() {
showCategoryModal.value = false
editingCategory.value = null
}
async function saveCategory() {
try {
if (editingCategory.value) {
await api.put(`/webstore/categories/${editingCategory.value.id}`, categoryForm.value)
} else {
await api.post('/webstore/categories', categoryForm.value)
}
await fetchCategories()
closeCategoryModal()
} catch (error) {
console.error('Failed to save category:', error)
}
}
async function deleteCategory(category: StoreCategory) {
if (!confirm(`Delete category "${category.name}"? Items in this category will become uncategorized.`)) return
try {
await api.del(`/webstore/categories/${category.id}`)
await fetchCategories()
} catch (error) {
console.error('Failed to delete category:', error)
}
}
function openItemModal(item?: StoreItem) {
if (item) {
editingItem.value = item
itemForm.value = {
category_id: item.category_id,
name: item.name,
description: item.description || '',
price: item.price,
image_url: item.image_url || '',
item_type: item.item_type,
delivery_commands: item.delivery_commands.length > 0 ? item.delivery_commands : [''],
limit_per_player: item.limit_per_player,
enabled: item.enabled
}
} else {
editingItem.value = null
itemForm.value = {
category_id: null,
name: '',
description: '',
price: 0,
image_url: '',
item_type: 'kit',
delivery_commands: [''],
limit_per_player: null,
enabled: true
}
}
showItemModal.value = true
}
function closeItemModal() {
showItemModal.value = false
editingItem.value = null
}
function addCommand() {
itemForm.value.delivery_commands.push('')
}
function removeCommand(index: number) {
itemForm.value.delivery_commands.splice(index, 1)
if (itemForm.value.delivery_commands.length === 0) {
itemForm.value.delivery_commands.push('')
}
}
async function saveItem() {
if (!itemForm.value.name.trim()) {
alert('Item name is required')
return
}
if (itemForm.value.price <= 0) {
alert('Price must be greater than 0')
return
}
const validCommands = itemForm.value.delivery_commands.filter(c => c.trim())
if (validCommands.length === 0) {
alert('At least one delivery command is required')
return
}
try {
const payload = {
...itemForm.value,
delivery_commands: validCommands
}
if (editingItem.value) {
await api.put(`/webstore/items/${editingItem.value.id}`, payload)
} else {
await api.post('/webstore/items', payload)
}
await fetchItems()
closeItemModal()
} catch (error) {
console.error('Failed to save item:', error)
}
}
async function deleteItem(item: StoreItem) {
if (!confirm(`Delete item "${item.name}"?`)) return
try {
await api.del(`/webstore/items/${item.id}`)
await fetchItems()
} catch (error) {
console.error('Failed to delete item:', error)
}
}
function getCategoryName(categoryId: string | null): string {
if (!categoryId) return 'Uncategorized'
const cat = categories.value.find(c => c.id === categoryId)
return cat?.name ?? 'Unknown'
}
const selectedTypeExample = computed(() => {
const type = itemTypes.find(t => t.value === itemForm.value.item_type)
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(() => {
fetchCategories()
fetchItems()
})
</script>
<template>
<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="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>
<!-- Tab bar + content panel -->
<Panel :flush-body="true">
<template #actions>
<Tabs v-model="tab" :items="tabItems" />
</template>
<!-- Categories table -->
<table v-if="tab === 'categories'" class="si-table">
<thead>
<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>
<tr v-if="categories.length === 0">
<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">
<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><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' }}
</Badge>
</td>
<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>
<!-- Items table -->
<table v-if="tab === 'items'" class="si-table">
<thead>
<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>
<tr v-if="items.length === 0">
<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">
<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="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><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' }}
</Badge>
</td>
<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>
</Panel>
<!-- ===== Category modal ===== -->
<div
v-if="showCategoryModal"
class="si-modal-backdrop"
@click.self="closeCategoryModal"
>
<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="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"
/>
</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="si-modal__foot">
<Button variant="secondary" @click="closeCategoryModal">Cancel</Button>
<Button @click="saveCategory">{{ editingCategory ? 'Save changes' : 'Create category' }}</Button>
</div>
</div>
</div>
<!-- ===== Item modal ===== -->
<div
v-if="showItemModal"
class="si-modal-backdrop"
@click.self="closeItemModal"
>
<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="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"
/>
</label>
<Select
v-model="categorySelectValue"
label="Category"
:options="categorySelectOptions"
/>
</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 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"
>
{{ type.label }}
</button>
</div>
</div>
<!-- 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="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="si-commands">
<div v-for="(_cmd, index) in itemForm.delivery_commands" :key="index" class="si-command-row">
<Input
v-model="itemForm.delivery_commands[index]"
:mono="true"
placeholder="inventory.giveto {steam_id} rifle.ak 1"
/>
<IconButton
v-if="itemForm.delivery_commands.length > 1"
icon="trash-2"
label="Remove"
variant="danger"
size="sm"
@click="removeCommand(index)"
/>
</div>
</div>
</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="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>