984 lines
32 KiB
Vue
984 lines
32 KiB
Vue
<template>
|
|
<div class="min-h-screen bg-surface py-8">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<!-- Header -->
|
|
<div class="mb-8">
|
|
<div class="flex items-center gap-3 mb-3">
|
|
<div class="p-3 bg-primary/10 rounded-lg">
|
|
<TrendingUp class="w-8 h-8 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-3xl font-bold text-white">Sell Your Items</h1>
|
|
<p class="text-text-secondary">
|
|
Sell your CS2 and Rust skins directly to TurboTrades for instant
|
|
cash
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trade URL Warning -->
|
|
<div
|
|
v-if="!hasTradeUrl"
|
|
class="bg-warning/10 border border-warning/30 rounded-lg p-4 flex items-start gap-3 mb-4"
|
|
>
|
|
<AlertTriangle class="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
|
|
<div class="text-sm flex-1">
|
|
<p class="text-white font-medium mb-1">Trade URL Required</p>
|
|
<p class="text-text-secondary mb-3">
|
|
You must set your Steam Trade URL before selling items. This
|
|
allows us to send you trade offers.
|
|
</p>
|
|
<router-link
|
|
to="/profile"
|
|
class="inline-flex items-center gap-2 px-4 py-2 bg-warning hover:bg-warning/90 text-surface-dark font-medium rounded-lg transition-colors text-sm"
|
|
>
|
|
<Settings class="w-4 h-4" />
|
|
Set Trade URL in Profile
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Info Banner -->
|
|
<div
|
|
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-start gap-3"
|
|
>
|
|
<Info class="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
|
<div class="text-sm">
|
|
<p class="text-white font-medium mb-1">How it works:</p>
|
|
<p class="text-text-secondary mb-2">
|
|
1. Select items from your Steam inventory<br />
|
|
2. We'll calculate an instant offer price<br />
|
|
3. Accept the trade offer we send to your Steam account<br />
|
|
4. Funds will be added to your balance once the trade is completed
|
|
</p>
|
|
<p class="text-xs text-text-secondary mt-2">
|
|
Note: Your Steam inventory must be public for us to fetch your
|
|
items.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Filters and Search -->
|
|
<div class="mb-6 grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
<!-- Search -->
|
|
<div class="md:col-span-2">
|
|
<div class="relative">
|
|
<Search
|
|
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-text-secondary"
|
|
/>
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
placeholder="Search your items..."
|
|
class="w-full pl-10 pr-4 py-2.5 bg-surface-light rounded-lg border border-surface-lighter text-white placeholder-text-secondary focus:outline-none focus:border-primary transition-colors"
|
|
@input="filterItems"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Game Filter -->
|
|
<select
|
|
v-model="selectedGame"
|
|
@change="handleGameChange"
|
|
class="px-4 py-2.5 bg-surface-light rounded-lg border border-surface-lighter text-white focus:outline-none focus:border-primary transition-colors"
|
|
>
|
|
<option value="cs2">Counter-Strike 2</option>
|
|
<option value="rust">Rust</option>
|
|
</select>
|
|
|
|
<!-- Sort -->
|
|
<select
|
|
v-model="sortBy"
|
|
@change="sortItems"
|
|
class="px-4 py-2.5 bg-surface-light rounded-lg border border-surface-lighter text-white focus:outline-none focus:border-primary transition-colors"
|
|
>
|
|
<option value="price-desc">Price: High to Low</option>
|
|
<option value="price-asc">Price: Low to High</option>
|
|
<option value="name-asc">Name: A-Z</option>
|
|
<option value="name-desc">Name: Z-A</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Selected Items Summary -->
|
|
<div
|
|
v-if="selectedItems.length > 0"
|
|
class="mb-6 bg-surface-light rounded-lg border border-primary/50 p-4"
|
|
>
|
|
<div class="flex items-center justify-between flex-wrap gap-4">
|
|
<div class="flex items-center gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<CheckCircle class="w-5 h-5 text-primary" />
|
|
<span class="text-white font-medium">
|
|
{{ selectedItems.length }} item{{
|
|
selectedItems.length > 1 ? "s" : ""
|
|
}}
|
|
selected
|
|
</span>
|
|
</div>
|
|
<div class="h-6 w-px bg-surface-lighter"></div>
|
|
<div class="text-white font-bold text-lg">
|
|
Total: {{ formatCurrency(totalSelectedValue) }}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="clearSelection"
|
|
class="px-4 py-2 text-sm text-text-secondary hover:text-white transition-colors"
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
@click="handleSellClick"
|
|
:disabled="!hasTradeUrl"
|
|
class="px-6 py-2 bg-gradient-to-r from-primary to-primary-dark text-surface-dark font-semibold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Sell Selected Items
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div
|
|
v-if="isLoading"
|
|
class="flex flex-col justify-center items-center py-20"
|
|
>
|
|
<Loader2 class="w-12 h-12 animate-spin text-primary mb-4" />
|
|
<p class="text-text-secondary">Loading your Steam inventory...</p>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="error" class="text-center py-20">
|
|
<AlertCircle class="w-16 h-16 text-error mx-auto mb-4 opacity-50" />
|
|
<h3 class="text-xl font-semibold text-white mb-2">
|
|
Failed to Load Inventory
|
|
</h3>
|
|
<p class="text-text-secondary mb-6">{{ error }}</p>
|
|
<button
|
|
@click="fetchInventory"
|
|
class="inline-flex items-center gap-2 px-6 py-3 bg-primary hover:bg-primary-dark text-surface-dark font-semibold rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw class="w-5 h-5" />
|
|
Retry
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div
|
|
v-else-if="filteredItems.length === 0 && !isLoading"
|
|
class="text-center py-20"
|
|
>
|
|
<Package
|
|
class="w-16 h-16 text-text-secondary mx-auto mb-4 opacity-50"
|
|
/>
|
|
<h3 class="text-xl font-semibold text-white mb-2">No Items Found</h3>
|
|
<p class="text-text-secondary mb-6">
|
|
{{
|
|
searchQuery
|
|
? "Try adjusting your search or filters"
|
|
: items.length === 0
|
|
? `You don't have any ${
|
|
selectedGame === "cs2" ? "CS2" : "Rust"
|
|
} items in your inventory`
|
|
: "No items match your current filters"
|
|
}}
|
|
</p>
|
|
<div class="flex items-center justify-center gap-4">
|
|
<button
|
|
@click="handleGameChange"
|
|
class="inline-flex items-center gap-2 px-6 py-3 bg-surface-light hover:bg-surface-lighter text-white font-semibold rounded-lg transition-colors"
|
|
>
|
|
<RefreshCw class="w-5 h-5" />
|
|
Switch Game
|
|
</button>
|
|
<router-link
|
|
to="/market"
|
|
class="inline-flex items-center gap-2 px-6 py-3 bg-primary hover:bg-primary-dark text-surface-dark font-semibold rounded-lg transition-colors"
|
|
>
|
|
<ShoppingCart class="w-5 h-5" />
|
|
Browse Market
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Items Grid -->
|
|
<div
|
|
v-else
|
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
|
>
|
|
<div
|
|
v-for="item in paginatedItems"
|
|
:key="item.assetid"
|
|
@click="toggleSelection(item)"
|
|
:class="[
|
|
'bg-surface-light rounded-lg overflow-hidden cursor-pointer transition-all border-2',
|
|
isSelected(item.assetid)
|
|
? 'border-primary ring-2 ring-primary/50'
|
|
: 'border-transparent hover:border-primary/30',
|
|
]"
|
|
>
|
|
<!-- Item Image -->
|
|
<div class="relative aspect-video bg-surface p-4">
|
|
<img
|
|
:src="item.image"
|
|
:alt="item.name"
|
|
class="w-full h-full object-contain"
|
|
@error="handleImageError"
|
|
/>
|
|
<!-- Selection Indicator -->
|
|
<div
|
|
v-if="isSelected(item.assetid)"
|
|
class="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center"
|
|
>
|
|
<Check class="w-4 h-4 text-surface-dark" />
|
|
</div>
|
|
<!-- Price Badge -->
|
|
<div
|
|
v-if="item.estimatedPrice"
|
|
class="absolute bottom-2 left-2 px-2 py-1 bg-surface-dark/90 rounded text-xs font-bold text-primary"
|
|
>
|
|
{{ formatCurrency(item.estimatedPrice) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Item Details -->
|
|
<div class="p-4">
|
|
<h3
|
|
class="font-semibold text-white mb-2 line-clamp-2 text-sm"
|
|
:title="item.name"
|
|
>
|
|
{{ item.name }}
|
|
</h3>
|
|
|
|
<!-- Tags -->
|
|
<div class="flex items-center gap-2 flex-wrap text-xs mb-3">
|
|
<span
|
|
v-if="item.wearName"
|
|
class="px-2 py-1 bg-surface rounded text-text-secondary"
|
|
>
|
|
{{ item.wearName }}
|
|
</span>
|
|
<span
|
|
v-if="item.rarity"
|
|
class="px-2 py-1 rounded text-white"
|
|
:style="{
|
|
backgroundColor: getRarityColor(item.rarity) + '40',
|
|
color: getRarityColor(item.rarity),
|
|
}"
|
|
>
|
|
{{ formatRarity(item.rarity) }}
|
|
</span>
|
|
<span
|
|
v-if="item.statTrak"
|
|
class="px-2 py-1 bg-warning/20 rounded text-warning"
|
|
>
|
|
StatTrak™
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Price Info -->
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<p class="text-xs text-text-secondary mb-1">You Get</p>
|
|
<p class="text-lg font-bold text-primary">
|
|
{{
|
|
item.estimatedPrice
|
|
? formatCurrency(item.estimatedPrice)
|
|
: "Price unavailable"
|
|
}}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div
|
|
v-if="totalPages > 1"
|
|
class="mt-8 flex items-center justify-center gap-2"
|
|
>
|
|
<button
|
|
@click="currentPage--"
|
|
:disabled="currentPage === 1"
|
|
class="px-4 py-2 bg-surface-light rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-lighter transition-colors"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span class="px-4 py-2 text-text-secondary">
|
|
Page {{ currentPage }} of {{ totalPages }}
|
|
</span>
|
|
<button
|
|
@click="currentPage++"
|
|
:disabled="currentPage === totalPages"
|
|
class="px-4 py-2 bg-surface-light rounded-lg text-white disabled:opacity-50 disabled:cursor-not-allowed hover:bg-surface-lighter transition-colors"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trade Status Modal -->
|
|
<div
|
|
v-if="showTradeModal"
|
|
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
|
@click.self="
|
|
tradeState === 'created' || tradeState === 'error'
|
|
? null
|
|
: (showTradeModal = false)
|
|
"
|
|
>
|
|
<div
|
|
class="bg-surface-light rounded-lg max-w-md w-full p-6 border border-surface-lighter"
|
|
>
|
|
<!-- Modal Header -->
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h3 class="text-xl font-bold text-white">{{ tradeModalTitle }}</h3>
|
|
<button
|
|
v-if="tradeState !== 'confirming' && tradeState !== 'created'"
|
|
@click="
|
|
showTradeModal = false;
|
|
currentTrade = null;
|
|
selectedItems = [];
|
|
"
|
|
class="text-text-secondary hover:text-white transition-colors"
|
|
>
|
|
<X class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Confirming State -->
|
|
<div v-if="tradeState === 'confirming'" class="space-y-4 mb-6">
|
|
<p class="text-text-secondary">
|
|
You're about to sell
|
|
<strong class="text-white">{{ selectedItems.length }}</strong>
|
|
item{{ selectedItems.length > 1 ? "s" : "" }} to TurboTrades.
|
|
</p>
|
|
|
|
<div class="bg-surface rounded-lg p-4 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Items Selected:</span>
|
|
<span class="text-white font-semibold">
|
|
{{ selectedItems.length }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Total Value:</span>
|
|
<span class="text-white font-semibold">
|
|
{{ formatCurrency(totalSelectedValue) }}
|
|
</span>
|
|
</div>
|
|
<div class="border-t border-surface-lighter pt-2"></div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-white font-bold">You Will Receive:</span>
|
|
<span class="text-primary font-bold text-xl">
|
|
{{ formatCurrency(totalSelectedValue) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="bg-primary/10 border border-primary/30 rounded-lg p-3 flex items-start gap-2"
|
|
>
|
|
<AlertCircle class="w-5 h-5 text-primary flex-shrink-0 mt-0.5" />
|
|
<p class="text-sm text-text-secondary">
|
|
<strong class="text-white">Important:</strong> You will receive a
|
|
Steam trade offer. Please verify the code before accepting.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Actions -->
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="showTradeModal = false"
|
|
class="flex-1 px-4 py-2.5 bg-surface hover:bg-surface-lighter text-white rounded-lg transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="confirmSale"
|
|
:disabled="isProcessing"
|
|
class="flex-1 px-4 py-2.5 bg-gradient-to-r from-primary to-primary-dark text-surface-dark font-semibold rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
>
|
|
<Loader2 v-if="isProcessing" class="w-4 h-4 animate-spin" />
|
|
<span>{{ isProcessing ? "Processing..." : "Confirm Sale" }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Trade Created State -->
|
|
<div v-else-if="tradeState === 'created'" class="space-y-4">
|
|
<div class="text-center py-4">
|
|
<div
|
|
class="w-16 h-16 bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
|
>
|
|
<CheckCircle class="w-8 h-8 text-primary" />
|
|
</div>
|
|
<p class="text-white font-semibold mb-2">Trade Offer Created!</p>
|
|
<p class="text-text-secondary text-sm">
|
|
Check your Steam for the trade offer
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Verification Code Display -->
|
|
<div
|
|
class="bg-gradient-to-br from-primary/20 to-primary-dark/20 border-2 border-primary rounded-lg p-6 text-center"
|
|
>
|
|
<p class="text-text-secondary text-sm mb-2">Verification Code</p>
|
|
<p class="text-4xl font-bold text-white tracking-widest font-mono">
|
|
{{ currentTrade?.verificationCode }}
|
|
</p>
|
|
<p class="text-text-secondary text-xs mt-2">
|
|
Match this code with the one in your Steam trade offer
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Open Trade Link Button -->
|
|
<a
|
|
v-if="currentTrade?.tradeOfferUrl"
|
|
:href="currentTrade.tradeOfferUrl"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="w-full px-6 py-3 bg-gradient-to-r from-primary to-primary-dark text-surface-dark font-semibold rounded-lg hover:opacity-90 transition-opacity flex items-center justify-center gap-2 text-center"
|
|
>
|
|
<ExternalLink class="w-5 h-5" />
|
|
Open Trade in Steam
|
|
</a>
|
|
|
|
<!-- Trade Details -->
|
|
<div class="bg-surface rounded-lg p-4 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Items:</span>
|
|
<span class="text-white font-semibold">
|
|
{{ currentTrade?.itemCount }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Value:</span>
|
|
<span class="text-white font-semibold">
|
|
{{ formatCurrency(currentTrade?.totalValue || 0) }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Status:</span>
|
|
<span
|
|
class="text-yellow-400 font-semibold flex items-center gap-1"
|
|
>
|
|
<Loader2 class="w-3 h-3 animate-spin" />
|
|
Pending
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Instructions -->
|
|
<div
|
|
class="bg-primary/10 border border-primary/30 rounded-lg p-3 space-y-2"
|
|
>
|
|
<p class="text-white font-semibold text-sm">Next Steps:</p>
|
|
<ol
|
|
class="text-text-secondary text-sm space-y-1 list-decimal list-inside"
|
|
>
|
|
<li>Click "Open Trade in Steam" button above</li>
|
|
<li>
|
|
Verify the code matches:
|
|
<span class="text-primary font-mono font-bold">{{
|
|
currentTrade?.verificationCode
|
|
}}</span>
|
|
</li>
|
|
<li>Accept the trade</li>
|
|
</ol>
|
|
</div>
|
|
|
|
<button
|
|
@click="
|
|
showTradeModal = false;
|
|
currentTrade = null;
|
|
selectedItems = [];
|
|
"
|
|
class="w-full px-4 py-2.5 bg-surface hover:bg-surface-lighter text-white rounded-lg transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Trade Accepted State -->
|
|
<div v-else-if="tradeState === 'accepted'" class="space-y-4">
|
|
<div class="text-center py-4">
|
|
<div
|
|
class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
|
>
|
|
<CheckCircle class="w-8 h-8 text-green-500" />
|
|
</div>
|
|
<p class="text-white font-semibold text-lg mb-2">Trade Complete!</p>
|
|
<p class="text-text-secondary text-sm">
|
|
Your balance has been credited
|
|
</p>
|
|
</div>
|
|
|
|
<div class="bg-surface rounded-lg p-4 space-y-2">
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">Amount Credited:</span>
|
|
<span class="text-green-500 font-bold text-xl">
|
|
+{{
|
|
formatCurrency(
|
|
currentTrade?.amount || currentTrade?.totalValue || 0
|
|
)
|
|
}}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center justify-between">
|
|
<span class="text-text-secondary">New Balance:</span>
|
|
<span class="text-white font-semibold">
|
|
{{
|
|
formatCurrency(
|
|
currentTrade?.newBalance || authStore.user?.balance || 0
|
|
)
|
|
}}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
@click="
|
|
showTradeModal = false;
|
|
currentTrade = null;
|
|
selectedItems = [];
|
|
"
|
|
class="w-full px-4 py-2.5 bg-gradient-to-r from-primary to-primary-dark text-surface-dark font-semibold rounded-lg hover:opacity-90 transition-opacity"
|
|
>
|
|
Done
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Error State -->
|
|
<div v-else-if="tradeState === 'error'" class="space-y-4">
|
|
<div class="text-center py-4">
|
|
<div
|
|
class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4"
|
|
>
|
|
<AlertCircle class="w-8 h-8 text-red-500" />
|
|
</div>
|
|
<p class="text-white font-semibold text-lg mb-2">Trade Failed</p>
|
|
<p class="text-text-secondary text-sm">{{ tradeError }}</p>
|
|
</div>
|
|
|
|
<button
|
|
@click="
|
|
showTradeModal = false;
|
|
currentTrade = null;
|
|
tradeState = 'confirming';
|
|
tradeError = null;
|
|
"
|
|
class="w-full px-4 py-2.5 bg-surface hover:bg-surface-lighter text-white rounded-lg transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
|
import { useRouter } from "vue-router";
|
|
import { useAuthStore } from "@/stores/auth";
|
|
import axios from "@/utils/axios";
|
|
import { useToast } from "vue-toastification";
|
|
import {
|
|
TrendingUp,
|
|
Search,
|
|
Package,
|
|
Loader2,
|
|
Check,
|
|
CheckCircle,
|
|
X,
|
|
AlertCircle,
|
|
Info,
|
|
ShoppingCart,
|
|
Settings,
|
|
AlertTriangle,
|
|
RefreshCw,
|
|
ExternalLink,
|
|
} from "lucide-vue-next";
|
|
|
|
const router = useRouter();
|
|
const authStore = useAuthStore();
|
|
const toast = useToast();
|
|
|
|
// State
|
|
const items = ref([]);
|
|
const filteredItems = ref([]);
|
|
const selectedItems = ref([]);
|
|
const isLoading = ref(false);
|
|
const isProcessing = ref(false);
|
|
|
|
const showTradeModal = ref(false);
|
|
const tradeState = ref("confirming"); // confirming, created, accepted, error
|
|
const currentTrade = ref(null);
|
|
const tradeError = ref(null);
|
|
const searchQuery = ref("");
|
|
const selectedGame = ref("cs2");
|
|
const sortBy = ref("price-desc");
|
|
const currentPage = ref(1);
|
|
const itemsPerPage = 20;
|
|
const error = ref(null);
|
|
const hasTradeUrl = ref(false);
|
|
|
|
// Computed
|
|
const totalPages = computed(() => {
|
|
return Math.ceil(filteredItems.value.length / itemsPerPage);
|
|
});
|
|
|
|
const paginatedItems = computed(() => {
|
|
const start = (currentPage.value - 1) * itemsPerPage;
|
|
const end = start + itemsPerPage;
|
|
return filteredItems.value.slice(start, end);
|
|
});
|
|
|
|
const totalSelectedValue = computed(() => {
|
|
return selectedItems.value.reduce((total, item) => {
|
|
return total + (item.estimatedPrice || 0);
|
|
}, 0);
|
|
});
|
|
|
|
const tradeModalTitle = computed(() => {
|
|
switch (tradeState.value) {
|
|
case "confirming":
|
|
return "Confirm Sale";
|
|
case "created":
|
|
return "Trade Offer Created";
|
|
case "accepted":
|
|
return "Trade Complete!";
|
|
case "error":
|
|
return "Trade Failed";
|
|
default:
|
|
return "Trade Status";
|
|
}
|
|
});
|
|
|
|
// WebSocket connection for real-time updates
|
|
let wsMessageHandler = null;
|
|
|
|
// Methods
|
|
const fetchInventory = async () => {
|
|
isLoading.value = true;
|
|
error.value = null;
|
|
|
|
try {
|
|
// Check if user has trade URL set
|
|
hasTradeUrl.value = !!authStore.user?.tradeUrl;
|
|
|
|
// Fetch Steam inventory (now includes prices!)
|
|
const response = await axios.get("/api/inventory/steam", {
|
|
params: { game: selectedGame.value },
|
|
});
|
|
|
|
if (response.data.success) {
|
|
// Items already have marketPrice from backend
|
|
items.value = (response.data.items || []).map((item) => ({
|
|
...item,
|
|
estimatedPrice: item.marketPrice || null,
|
|
hasPriceData: item.hasPriceData || false,
|
|
}));
|
|
|
|
filteredItems.value = [...items.value];
|
|
sortItems();
|
|
|
|
if (items.value.length === 0) {
|
|
toast.info(
|
|
`No ${
|
|
selectedGame.value === "cs2" ? "CS2" : "Rust"
|
|
} items found in your inventory`
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to fetch inventory:", err);
|
|
|
|
if (err.response?.status === 403) {
|
|
error.value =
|
|
"Your Steam inventory is private. Please make it public in your Steam settings.";
|
|
toast.error("Steam inventory is private");
|
|
} else if (err.response?.status === 404) {
|
|
error.value = "Steam profile not found or inventory is empty.";
|
|
} else if (err.response?.data?.message) {
|
|
error.value = err.response.data.message;
|
|
toast.error(err.response.data.message);
|
|
} else {
|
|
error.value = "Failed to load inventory. Please try again.";
|
|
toast.error("Failed to load inventory");
|
|
}
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
};
|
|
|
|
// Removed: Prices now come directly from inventory endpoint
|
|
// No need for separate pricing call - instant loading!
|
|
|
|
const filterItems = () => {
|
|
let filtered = [...items.value];
|
|
|
|
// Filter by search query
|
|
if (searchQuery.value.trim()) {
|
|
const query = searchQuery.value.toLowerCase();
|
|
filtered = filtered.filter((item) =>
|
|
item.name.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
filteredItems.value = filtered;
|
|
sortItems();
|
|
currentPage.value = 1;
|
|
};
|
|
|
|
const sortItems = () => {
|
|
const sorted = [...filteredItems.value];
|
|
|
|
switch (sortBy.value) {
|
|
case "price-desc":
|
|
sorted.sort((a, b) => (b.estimatedPrice || 0) - (a.estimatedPrice || 0));
|
|
break;
|
|
case "price-asc":
|
|
sorted.sort((a, b) => (a.estimatedPrice || 0) - (b.estimatedPrice || 0));
|
|
break;
|
|
case "name-asc":
|
|
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
break;
|
|
case "name-desc":
|
|
sorted.sort((a, b) => b.name.localeCompare(a.name));
|
|
break;
|
|
}
|
|
|
|
filteredItems.value = sorted;
|
|
};
|
|
|
|
const handleGameChange = async () => {
|
|
selectedItems.value = [];
|
|
items.value = [];
|
|
filteredItems.value = [];
|
|
error.value = null;
|
|
await fetchInventory();
|
|
};
|
|
|
|
const toggleSelection = (item) => {
|
|
if (!item.estimatedPrice) {
|
|
toast.warning("Price not calculated yet");
|
|
return;
|
|
}
|
|
|
|
const index = selectedItems.value.findIndex(
|
|
(i) => i.assetid === item.assetid
|
|
);
|
|
if (index > -1) {
|
|
selectedItems.value.splice(index, 1);
|
|
} else {
|
|
selectedItems.value.push(item);
|
|
}
|
|
};
|
|
|
|
const isSelected = (assetid) => {
|
|
return selectedItems.value.some((item) => item.assetid === assetid);
|
|
};
|
|
|
|
const clearSelection = () => {
|
|
selectedItems.value = [];
|
|
};
|
|
|
|
const handleSellClick = () => {
|
|
if (!hasTradeUrl.value) {
|
|
toast.warning("Please set your Steam Trade URL in your profile first");
|
|
router.push("/profile");
|
|
return;
|
|
}
|
|
|
|
// Reset modal state
|
|
tradeState.value = "confirming";
|
|
currentTrade.value = null;
|
|
tradeError.value = null;
|
|
showTradeModal.value = true;
|
|
};
|
|
|
|
const confirmSale = async () => {
|
|
if (selectedItems.value.length === 0) return;
|
|
|
|
if (!hasTradeUrl.value) {
|
|
toast.error("Trade URL is required to sell items");
|
|
showTradeModal.value = false;
|
|
router.push("/profile");
|
|
return;
|
|
}
|
|
|
|
isProcessing.value = true;
|
|
|
|
try {
|
|
const response = await axios.post("/api/inventory/sell", {
|
|
tradeUrl: authStore.user?.tradeUrl,
|
|
items: selectedItems.value.map((item) => ({
|
|
assetid: item.assetid,
|
|
appid: item.appid || (selectedGame.value === "cs2" ? 730 : 252490),
|
|
contextid: item.contextid || "2",
|
|
name: item.name,
|
|
price: item.estimatedPrice,
|
|
image: item.image,
|
|
wear: item.wear,
|
|
rarity: item.rarity,
|
|
category: item.category,
|
|
statTrak: item.statTrak,
|
|
souvenir: item.souvenir,
|
|
})),
|
|
});
|
|
|
|
if (response.data.success) {
|
|
const trade = response.data.trade;
|
|
|
|
// Update modal state to show verification code
|
|
currentTrade.value = trade;
|
|
tradeState.value = "created";
|
|
|
|
// Remove sold items from list
|
|
const soldAssetIds = selectedItems.value.map((item) => item.assetid);
|
|
items.value = items.value.filter(
|
|
(item) => !soldAssetIds.includes(item.assetid)
|
|
);
|
|
filteredItems.value = filteredItems.value.filter(
|
|
(item) => !soldAssetIds.includes(item.assetid)
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to create trade:", err);
|
|
const message =
|
|
err.response?.data?.message ||
|
|
"Failed to create trade offer. Please try again.";
|
|
|
|
tradeError.value = message;
|
|
tradeState.value = "error";
|
|
} finally {
|
|
isProcessing.value = false;
|
|
}
|
|
};
|
|
|
|
// Listen for WebSocket trade updates
|
|
const setupWebSocketListeners = () => {
|
|
// Get WebSocket from auth store if available
|
|
if (authStore.ws) {
|
|
wsMessageHandler = (event) => {
|
|
try {
|
|
const message = JSON.parse(event.data);
|
|
|
|
if (message.type === "trade_completed" && currentTrade.value) {
|
|
// Trade was accepted and completed
|
|
if (message.data.tradeId === currentTrade.value.tradeId) {
|
|
currentTrade.value = {
|
|
...currentTrade.value,
|
|
...message.data,
|
|
};
|
|
tradeState.value = "accepted";
|
|
|
|
// Update balance
|
|
if (message.data.newBalance !== undefined) {
|
|
authStore.updateBalance(message.data.newBalance);
|
|
}
|
|
|
|
toast.success(
|
|
`Trade completed! +${formatCurrency(message.data.amount)}`
|
|
);
|
|
}
|
|
} else if (message.type === "trade_declined" && currentTrade.value) {
|
|
if (message.data.tradeId === currentTrade.value.tradeId) {
|
|
tradeError.value = "Trade was declined on Steam";
|
|
tradeState.value = "error";
|
|
}
|
|
} else if (message.type === "trade_expired" && currentTrade.value) {
|
|
if (message.data.tradeId === currentTrade.value.tradeId) {
|
|
tradeError.value = "Trade offer expired";
|
|
tradeState.value = "error";
|
|
}
|
|
} else if (message.type === "balance_update") {
|
|
authStore.updateBalance(message.data.balance);
|
|
}
|
|
} catch (err) {
|
|
console.error("Error handling WebSocket message:", err);
|
|
}
|
|
};
|
|
|
|
authStore.ws.addEventListener("message", wsMessageHandler);
|
|
}
|
|
};
|
|
|
|
const cleanupWebSocketListeners = () => {
|
|
if (authStore.ws && wsMessageHandler) {
|
|
authStore.ws.removeEventListener("message", wsMessageHandler);
|
|
}
|
|
};
|
|
|
|
onMounted(() => {
|
|
setupWebSocketListeners();
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
cleanupWebSocketListeners();
|
|
});
|
|
|
|
const formatCurrency = (amount) => {
|
|
return new Intl.NumberFormat("en-US", {
|
|
style: "currency",
|
|
currency: "USD",
|
|
}).format(amount);
|
|
};
|
|
|
|
const formatRarity = (rarity) => {
|
|
if (!rarity) return "";
|
|
|
|
const rarityMap = {
|
|
Rarity_Common: "Common",
|
|
Rarity_Uncommon: "Uncommon",
|
|
Rarity_Rare: "Rare",
|
|
Rarity_Mythical: "Mythical",
|
|
Rarity_Legendary: "Legendary",
|
|
Rarity_Ancient: "Ancient",
|
|
Rarity_Contraband: "Contraband",
|
|
};
|
|
|
|
return rarityMap[rarity] || rarity;
|
|
};
|
|
|
|
const getRarityColor = (rarity) => {
|
|
const colors = {
|
|
Rarity_Common: "#b0c3d9",
|
|
Rarity_Uncommon: "#5e98d9",
|
|
Rarity_Rare: "#4b69ff",
|
|
Rarity_Mythical: "#8847ff",
|
|
Rarity_Legendary: "#d32ce6",
|
|
Rarity_Ancient: "#eb4b4b",
|
|
Rarity_Contraband: "#e4ae39",
|
|
};
|
|
|
|
return colors[rarity] || "#b0c3d9";
|
|
};
|
|
|
|
const handleImageError = (event) => {
|
|
event.target.src = "https://via.placeholder.com/400x300?text=No+Image";
|
|
};
|
|
|
|
onMounted(() => {
|
|
if (!authStore.isAuthenticated) {
|
|
router.push("/");
|
|
return;
|
|
}
|
|
|
|
fetchInventory();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.line-clamp-2 {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
</style>
|