first commit

This commit is contained in:
2026-01-10 04:57:43 +00:00
parent 16a76a2cd6
commit 232968de1e
131 changed files with 43262 additions and 0 deletions

View File

@@ -0,0 +1,727 @@
<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>
<!-- Confirm Sale Modal -->
<div
v-if="showConfirmModal"
class="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
@click.self="showConfirmModal = 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">Confirm Sale</h3>
<button
@click="showConfirmModal = false"
class="text-text-secondary hover:text-white transition-colors"
>
<X class="w-6 h-6" />
</button>
</div>
<!-- Modal Content -->
<div 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 shortly. Please accept it to complete the sale.
Funds will be credited to your balance after the trade is
accepted.
</p>
</div>
</div>
<!-- Modal Actions -->
<div class="flex items-center gap-3">
<button
@click="showConfirmModal = 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>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } 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,
} 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 showConfirmModal = ref(false);
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);
});
// 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;
}
showConfirmModal.value = true;
};
const confirmSale = async () => {
if (selectedItems.value.length === 0) return;
if (!hasTradeUrl.value) {
toast.error("Trade URL is required to sell items");
showConfirmModal.value = false;
router.push("/profile");
return;
}
isProcessing.value = true;
try {
const response = await axios.post("/api/inventory/sell", {
items: selectedItems.value.map((item) => ({
assetid: item.assetid,
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) {
toast.success(
`Successfully listed ${selectedItems.value.length} item${
selectedItems.value.length > 1 ? "s" : ""
} for ${formatCurrency(response.data.totalEarned)}!`
);
toast.info(
"You will receive a Steam trade offer shortly. Please accept it to complete the sale."
);
// Update balance
if (response.data.newBalance !== undefined) {
authStore.updateBalance(response.data.newBalance);
}
// 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)
);
// Clear selection and close modal
selectedItems.value = [];
showConfirmModal.value = false;
}
} catch (err) {
console.error("Failed to sell items:", err);
const message =
err.response?.data?.message ||
"Failed to complete sale. Please try again.";
toast.error(message);
} finally {
isProcessing.value = false;
}
};
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>