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

75
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,75 @@
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { RouterView } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useWebSocketStore } from '@/stores/websocket'
import { useMarketStore } from '@/stores/market'
import NavBar from '@/components/NavBar.vue'
import Footer from '@/components/Footer.vue'
const authStore = useAuthStore()
const wsStore = useWebSocketStore()
const marketStore = useMarketStore()
onMounted(async () => {
// Initialize authentication
await authStore.initialize()
// Connect WebSocket
wsStore.connect()
// Setup market WebSocket listeners
marketStore.setupWebSocketListeners()
})
onUnmounted(() => {
// Disconnect WebSocket on app unmount
wsStore.disconnect()
})
</script>
<template>
<div id="app" class="min-h-screen flex flex-col bg-mesh-gradient">
<!-- Navigation Bar -->
<NavBar />
<!-- Main Content -->
<main class="flex-1">
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</RouterView>
</main>
<!-- Footer -->
<Footer />
<!-- Connection Status Indicator (bottom right) -->
<div
v-if="!wsStore.isConnected"
class="fixed bottom-4 right-4 z-50 px-4 py-2 bg-accent-red/90 backdrop-blur-sm text-white rounded-lg shadow-lg flex items-center gap-2 animate-pulse"
>
<div class="w-2 h-2 rounded-full bg-white"></div>
<span class="text-sm font-medium">
{{ wsStore.isConnecting ? 'Connecting...' : 'Disconnected' }}
</span>
</div>
</div>
</template>
<style scoped>
#app {
font-family: 'Inter', system-ui, sans-serif;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,395 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-surface-lighter;
}
body {
@apply bg-dark-500 text-white antialiased;
font-feature-settings: "cv11", "ss01";
}
html {
scroll-behavior: smooth;
}
}
@layer components {
/* Button Styles */
.btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply bg-primary-500 hover:bg-primary-600 text-white shadow-lg hover:shadow-glow active:scale-95;
}
.btn-secondary {
@apply bg-surface-light hover:bg-surface-lighter border border-surface-lighter text-white;
}
.btn-outline {
@apply border border-primary-500 text-primary-500 hover:bg-primary-500/10;
}
.btn-ghost {
@apply hover:bg-surface-light text-gray-300 hover:text-white;
}
.btn-success {
@apply bg-accent-green hover:bg-accent-green/90 text-white;
}
.btn-danger {
@apply bg-accent-red hover:bg-accent-red/90 text-white;
}
.btn-sm {
@apply px-3 py-1.5 text-sm;
}
.btn-lg {
@apply px-6 py-3 text-lg;
}
/* Card Styles */
.card {
@apply bg-surface rounded-xl border border-surface-lighter overflow-hidden;
}
.card-hover {
@apply transition-all duration-300 hover:border-primary-500/30 hover:shadow-glow cursor-pointer;
}
.card-body {
@apply p-4;
}
/* Input Styles */
.input {
@apply w-full px-4 py-2.5 bg-surface-light border border-surface-lighter rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 transition-colors;
}
.input-error {
@apply border-accent-red focus:border-accent-red focus:ring-accent-red/20;
}
.input-group {
@apply flex flex-col gap-1.5;
}
.input-label {
@apply text-sm font-medium text-gray-300;
}
.input-hint {
@apply text-xs text-gray-500;
}
.input-error-text {
@apply text-xs text-accent-red;
}
/* Badge Styles */
.badge {
@apply inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded;
}
.badge-primary {
@apply bg-primary-500/20 text-primary-400 border border-primary-500/30;
}
.badge-success {
@apply bg-accent-green/20 text-accent-green border border-accent-green/30;
}
.badge-danger {
@apply bg-accent-red/20 text-accent-red border border-accent-red/30;
}
.badge-warning {
@apply bg-accent-yellow/20 text-accent-yellow border border-accent-yellow/30;
}
.badge-info {
@apply bg-accent-blue/20 text-accent-blue border border-accent-blue/30;
}
.badge-rarity-common {
@apply bg-gray-500/20 text-gray-400 border border-gray-500/30;
}
.badge-rarity-uncommon {
@apply bg-green-500/20 text-green-400 border border-green-500/30;
}
.badge-rarity-rare {
@apply bg-blue-500/20 text-blue-400 border border-blue-500/30;
}
.badge-rarity-epic {
@apply bg-purple-500/20 text-purple-400 border border-purple-500/30;
}
.badge-rarity-legendary {
@apply bg-amber-500/20 text-amber-400 border border-amber-500/30;
}
/* Navigation */
.nav-link {
@apply px-4 py-2 rounded-lg text-gray-300 hover:text-white hover:bg-surface-light transition-colors;
}
.nav-link-active {
@apply text-primary-500 bg-surface-light;
}
/* Container */
.container-custom {
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
}
/* Loading Spinner */
.spinner {
@apply animate-spin rounded-full border-2 border-gray-600 border-t-primary-500;
}
/* Gradient Text */
.gradient-text {
@apply bg-gradient-to-r from-primary-400 to-primary-600 bg-clip-text text-transparent;
}
/* Divider */
.divider {
@apply border-t border-surface-lighter;
}
/* Item Card Styles */
.item-card {
@apply card card-hover relative overflow-hidden;
}
.item-card-image {
@apply w-full aspect-square object-contain bg-gradient-to-br from-surface-light to-surface p-4;
}
.item-card-wear {
@apply absolute top-2 left-2 px-2 py-1 text-xs font-medium rounded bg-black/50 backdrop-blur-sm;
}
.item-card-price {
@apply flex items-center justify-between p-3 bg-surface-dark;
}
/* Search Bar */
.search-bar {
@apply relative w-full;
}
.search-input {
@apply w-full pl-10 pr-4 py-3 bg-surface-light border border-surface-lighter rounded-lg text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 transition-colors;
}
.search-icon {
@apply absolute left-3 top-1/2 -translate-y-1/2 text-gray-500;
}
/* Filter Tag */
.filter-tag {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-surface-light border border-surface-lighter rounded-lg text-sm hover:border-primary-500/50 transition-colors cursor-pointer;
}
.filter-tag-active {
@apply bg-primary-500/20 border-primary-500 text-primary-400;
}
/* Modal Overlay */
.modal-overlay {
@apply fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4;
}
.modal-content {
@apply bg-surface-light rounded-xl border border-surface-lighter max-w-2xl w-full max-h-[90vh] overflow-y-auto;
}
/* Skeleton Loader */
.skeleton {
@apply animate-pulse bg-surface-light rounded;
}
/* Price Text */
.price-primary {
@apply text-xl font-bold text-primary-500;
}
.price-secondary {
@apply text-sm text-gray-400;
}
/* Status Indicators */
.status-online {
@apply w-2 h-2 rounded-full bg-accent-green animate-pulse;
}
.status-offline {
@apply w-2 h-2 rounded-full bg-gray-600;
}
.status-away {
@apply w-2 h-2 rounded-full bg-accent-yellow animate-pulse;
}
}
@layer utilities {
/* Text Utilities */
.text-balance {
text-wrap: balance;
}
/* Scrollbar Hide */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom Scrollbar */
.scrollbar-custom {
scrollbar-width: thin;
scrollbar-color: theme("colors.surface.lighter")
theme("colors.surface.DEFAULT");
}
.scrollbar-custom::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.scrollbar-custom::-webkit-scrollbar-track {
background: theme("colors.surface.DEFAULT");
}
.scrollbar-custom::-webkit-scrollbar-thumb {
background: theme("colors.surface.lighter");
border-radius: 4px;
}
.scrollbar-custom::-webkit-scrollbar-thumb:hover {
background: theme("colors.dark.400");
}
/* Glass Effect */
.glass {
background: rgba(21, 29, 40, 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
/* Glow Effect */
.glow-effect {
position: relative;
}
.glow-effect::before {
content: "";
position: absolute;
inset: -2px;
background: linear-gradient(
135deg,
theme("colors.primary.500"),
theme("colors.primary.700")
);
border-radius: inherit;
opacity: 0;
transition: opacity 0.3s ease;
z-index: -1;
filter: blur(10px);
}
.glow-effect:hover::before {
opacity: 0.5;
}
}
/* Vue Toastification Custom Styles */
.Vue-Toastification__toast {
@apply bg-surface-light border border-surface-lighter rounded-lg shadow-xl;
}
.Vue-Toastification__toast--success {
@apply border-accent-green/50;
}
.Vue-Toastification__toast--error {
@apply border-accent-red/50;
}
.Vue-Toastification__toast--warning {
@apply border-accent-yellow/50;
}
.Vue-Toastification__toast--info {
@apply border-accent-blue/50;
}
.Vue-Toastification__toast-body {
@apply text-white;
}
.Vue-Toastification__progress-bar {
@apply bg-primary-500;
}
/* Loading States */
@keyframes shimmer {
0% {
background-position: -1000px 0;
}
100% {
background-position: 1000px 0;
}
}
.shimmer {
animation: shimmer 2s linear infinite;
background: linear-gradient(
to right,
transparent 0%,
rgba(245, 135, 0, 0.1) 50%,
transparent 100%
);
background-size: 1000px 100%;
}
/* Fade Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* Slide Transitions */
.slide-up-enter-active,
.slide-up-leave-active {
transition: all 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(10px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-10px);
}

View File

@@ -0,0 +1,143 @@
<script setup>
import { Github, Twitter, Mail, MessageCircle } from 'lucide-vue-next'
const currentYear = new Date().getFullYear()
const footerLinks = {
marketplace: [
{ name: 'Browse Market', path: '/market' },
{ name: 'Sell Items', path: '/sell' },
{ name: 'Recent Sales', path: '/market?tab=recent' },
{ name: 'Featured Items', path: '/market?tab=featured' },
],
support: [
{ name: 'FAQ', path: '/faq' },
{ name: 'Support Center', path: '/support' },
{ name: 'Contact Us', path: '/support' },
{ name: 'Report Issue', path: '/support' },
],
legal: [
{ name: 'Terms of Service', path: '/terms' },
{ name: 'Privacy Policy', path: '/privacy' },
{ name: 'Refund Policy', path: '/terms#refunds' },
{ name: 'Cookie Policy', path: '/privacy#cookies' },
],
}
const socialLinks = [
{ name: 'Discord', icon: MessageCircle, url: '#' },
{ name: 'Twitter', icon: Twitter, url: '#' },
{ name: 'GitHub', icon: Github, url: '#' },
{ name: 'Email', icon: Mail, url: 'mailto:support@turbotrades.com' },
]
</script>
<template>
<footer class="bg-surface-dark border-t border-surface-lighter mt-auto">
<div class="container-custom py-12">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-8">
<!-- Brand Column -->
<div class="lg:col-span-2">
<div class="flex items-center gap-2 mb-4">
<div class="w-10 h-10 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center">
<span class="text-white font-bold">TT</span>
</div>
<span class="text-xl font-display font-bold text-white">TurboTrades</span>
</div>
<p class="text-gray-400 text-sm mb-6 max-w-md">
The premier marketplace for CS2 and Rust skins. Buy, sell, and trade with confidence. Fast transactions, secure trading, and competitive prices.
</p>
<!-- Social Links -->
<div class="flex items-center gap-3">
<a
v-for="social in socialLinks"
:key="social.name"
:href="social.url"
:aria-label="social.name"
class="w-10 h-10 flex items-center justify-center rounded-lg bg-surface-light hover:bg-surface-lighter border border-surface-lighter hover:border-primary-500/50 text-gray-400 hover:text-primary-500 transition-all"
target="_blank"
rel="noopener noreferrer"
>
<component :is="social.icon" class="w-5 h-5" />
</a>
</div>
</div>
<!-- Marketplace Links -->
<div>
<h3 class="text-white font-semibold mb-4">Marketplace</h3>
<ul class="space-y-2">
<li v-for="link in footerLinks.marketplace" :key="link.path">
<router-link
:to="link.path"
class="text-gray-400 hover:text-primary-500 text-sm transition-colors"
>
{{ link.name }}
</router-link>
</li>
</ul>
</div>
<!-- Support Links -->
<div>
<h3 class="text-white font-semibold mb-4">Support</h3>
<ul class="space-y-2">
<li v-for="link in footerLinks.support" :key="link.path">
<router-link
:to="link.path"
class="text-gray-400 hover:text-primary-500 text-sm transition-colors"
>
{{ link.name }}
</router-link>
</li>
</ul>
</div>
<!-- Legal Links -->
<div>
<h3 class="text-white font-semibold mb-4">Legal</h3>
<ul class="space-y-2">
<li v-for="link in footerLinks.legal" :key="link.path">
<router-link
:to="link.path"
class="text-gray-400 hover:text-primary-500 text-sm transition-colors"
>
{{ link.name }}
</router-link>
</li>
</ul>
</div>
</div>
<!-- Bottom Bar -->
<div class="mt-12 pt-8 border-t border-surface-lighter">
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-gray-500 text-sm">
© {{ currentYear }} TurboTrades. All rights reserved.
</p>
<div class="flex items-center gap-6">
<span class="text-gray-500 text-sm">
Made with for the gaming community
</span>
</div>
</div>
<!-- Disclaimer -->
<div class="mt-4 text-center md:text-left">
<p class="text-gray-600 text-xs">
TurboTrades is not affiliated with Valve Corporation or Facepunch Studios.
CS2, Counter-Strike, and Rust are trademarks of their respective owners.
</p>
</div>
</div>
</div>
</footer>
</template>
<style scoped>
footer {
background: linear-gradient(180deg, rgba(15, 25, 35, 0.8) 0%, rgba(10, 15, 20, 0.95) 100%);
}
</style>

View File

@@ -0,0 +1,323 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
import {
Menu,
User,
LogOut,
Settings,
Package,
CreditCard,
History,
ShoppingCart,
Wallet,
TrendingUp,
Shield,
X,
ChevronDown,
Plus,
} from "lucide-vue-next";
const router = useRouter();
const authStore = useAuthStore();
const showMobileMenu = ref(false);
const showUserMenu = ref(false);
const showBalanceMenu = ref(false);
const userMenuRef = ref(null);
const balanceMenuRef = ref(null);
const navigationLinks = [
{ name: "Market", path: "/market", icon: ShoppingCart },
{ name: "Sell", path: "/sell", icon: TrendingUp, requiresAuth: true },
{ name: "FAQ", path: "/faq", icon: null },
];
const userMenuLinks = computed(() => [
{ name: "Profile", path: "/profile", icon: User },
{ name: "Inventory", path: "/inventory", icon: Package },
{ name: "Transactions", path: "/transactions", icon: History },
{ name: "Withdraw", path: "/withdraw", icon: CreditCard },
...(authStore.isAdmin
? [{ name: "Admin", path: "/admin", icon: Shield }]
: []),
]);
const formattedBalance = computed(() => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(authStore.balance);
});
const handleLogin = () => {
authStore.login();
};
const handleLogout = async () => {
showUserMenu.value = false;
await authStore.logout();
};
const handleDeposit = () => {
showBalanceMenu.value = false;
router.push("/deposit");
};
const toggleMobileMenu = () => {
showMobileMenu.value = !showMobileMenu.value;
};
const toggleUserMenu = () => {
showUserMenu.value = !showUserMenu.value;
};
const toggleBalanceMenu = () => {
showBalanceMenu.value = !showBalanceMenu.value;
};
const closeMenus = () => {
showMobileMenu.value = false;
showUserMenu.value = false;
showBalanceMenu.value = false;
};
const handleClickOutside = (event) => {
if (userMenuRef.value && !userMenuRef.value.contains(event.target)) {
showUserMenu.value = false;
}
if (balanceMenuRef.value && !balanceMenuRef.value.contains(event.target)) {
showBalanceMenu.value = false;
}
};
onMounted(() => {
document.addEventListener("click", handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
});
</script>
<template>
<nav
class="sticky top-0 z-40 bg-surface/95 backdrop-blur-md border-b border-surface-lighter"
>
<div class="w-full px-6">
<div class="flex items-center justify-between h-16">
<!-- Logo -->
<router-link
to="/"
class="flex items-center gap-2 text-xl font-display font-bold text-white hover:text-primary-500 transition-colors"
@click="closeMenus"
>
<div
class="w-8 h-8 bg-gradient-to-br from-primary-500 to-primary-700 rounded-lg flex items-center justify-center"
>
<span class="text-white text-sm font-bold">TT</span>
</div>
<span class="hidden sm:inline">TurboTrades</span>
</router-link>
<!-- Desktop Navigation - Centered -->
<div
class="hidden lg:flex items-center gap-8 absolute left-1/2 transform -translate-x-1/2"
>
<template v-for="link in navigationLinks" :key="link.path">
<router-link
v-if="!link.requiresAuth || authStore.isAuthenticated"
:to="link.path"
class="nav-link flex items-center gap-2"
active-class="nav-link-active"
>
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" />
{{ link.name }}
</router-link>
</template>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-3">
<!-- Balance with Inline Deposit Button (when authenticated) -->
<div
v-if="authStore.isAuthenticated"
class="hidden sm:flex items-center bg-surface-light rounded-lg border border-surface-lighter overflow-hidden h-10"
>
<!-- Balance Display -->
<div class="flex items-center gap-2 px-4 py-2">
<Wallet class="w-5 h-5 text-primary-500" />
<span class="text-base font-semibold text-white">{{
formattedBalance
}}</span>
</div>
<!-- Deposit Button -->
<button
@click="handleDeposit"
class="h-full px-4 bg-primary-500 hover:bg-primary-600 transition-colors flex items-center justify-center"
title="Deposit"
>
<Plus class="w-5 h-5 text-white" />
</button>
</div>
<!-- User Menu / Login Button -->
<div
v-if="authStore.isAuthenticated"
class="relative"
ref="userMenuRef"
>
<button
@click.stop="toggleUserMenu"
class="flex items-center gap-2 px-3 py-2 bg-surface-light hover:bg-surface-lighter rounded-lg border border-surface-lighter transition-colors"
>
<img
v-if="authStore.avatar"
:src="authStore.avatar"
:alt="authStore.username"
class="w-8 h-8 rounded-full"
/>
<div
v-else
class="w-8 h-8 rounded-full bg-primary-500 flex items-center justify-center"
>
<User class="w-4 h-4 text-white" />
</div>
<span class="hidden lg:inline text-sm font-medium text-white">
{{ authStore.username }}
</span>
<ChevronDown class="w-4 h-4 text-gray-400" />
</button>
<!-- User Dropdown Menu -->
<Transition name="fade">
<div
v-if="showUserMenu"
class="absolute right-0 mt-2 w-56 bg-surface-light border border-surface-lighter rounded-lg shadow-xl overflow-hidden"
>
<!-- Balance (Mobile) -->
<div
class="sm:hidden px-4 py-3 bg-surface-dark border-b border-surface-lighter"
>
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-400">Balance</span>
<span class="text-sm font-semibold text-primary-500">{{
formattedBalance
}}</span>
</div>
<button
@click="handleDeposit"
class="w-full px-3 py-1.5 bg-primary-500 hover:bg-primary-600 text-surface-dark text-sm font-medium rounded transition-colors flex items-center justify-center gap-1.5"
>
<Plus class="w-3.5 h-3.5" />
Deposit
</button>
</div>
<!-- Menu Items -->
<div class="py-2">
<router-link
v-for="link in userMenuLinks"
:key="link.path"
:to="link.path"
:class="[
'flex items-center gap-3 px-4 py-2.5 text-sm transition-colors',
link.name === 'Admin'
? 'bg-gradient-to-r from-yellow-900/40 to-yellow-800/40 text-yellow-400 hover:from-yellow-900/60 hover:to-yellow-800/60 hover:text-yellow-300 border-l-2 border-yellow-500'
: 'text-gray-300 hover:text-white hover:bg-surface',
]"
@click="closeMenus"
>
<component :is="link.icon" class="w-4 h-4" />
{{ link.name }}
</router-link>
</div>
<!-- Logout -->
<div class="border-t border-surface-lighter">
<button
@click="handleLogout"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-accent-red hover:bg-surface transition-colors"
>
<LogOut class="w-4 h-4" />
Logout
</button>
</div>
</div>
</Transition>
</div>
<!-- Login Button -->
<button v-else @click="handleLogin" class="btn btn-primary">
<img
src="https://community.cloudflare.steamstatic.com/public/images/signinthroughsteam/sits_01.png"
alt="Sign in through Steam"
class="h-6"
/>
</button>
<!-- Mobile Menu Toggle -->
<button
@click="toggleMobileMenu"
class="lg:hidden p-2 text-gray-400 hover:text-white transition-colors"
>
<Menu v-if="!showMobileMenu" class="w-6 h-6" />
<X v-else class="w-6 h-6" />
</button>
</div>
</div>
</div>
<!-- Mobile Menu -->
<Transition name="slide-down">
<div
v-if="showMobileMenu"
class="lg:hidden border-t border-surface-lighter bg-surface"
>
<div class="container-custom py-4 space-y-2">
<template v-for="link in navigationLinks" :key="link.path">
<router-link
v-if="!link.requiresAuth || authStore.isAuthenticated"
:to="link.path"
class="flex items-center gap-3 px-4 py-3 text-gray-300 hover:text-white hover:bg-surface-light rounded-lg transition-colors"
active-class="text-primary-500 bg-surface-light"
@click="closeMenus"
>
<component :is="link.icon" v-if="link.icon" class="w-5 h-5" />
{{ link.name }}
</router-link>
</template>
</div>
</div>
</Transition>
</nav>
</template>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
}
.slide-down-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.slide-down-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

62
frontend/src/main.js Normal file
View File

@@ -0,0 +1,62 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import Toast from 'vue-toastification'
import App from './App.vue'
// Styles
import './assets/main.css'
import 'vue-toastification/dist/index.css'
// Create Vue app
const app = createApp(App)
// Create Pinia store
const pinia = createPinia()
// Toast configuration
const toastOptions = {
position: 'top-right',
timeout: 4000,
closeOnClick: true,
pauseOnFocusLoss: true,
pauseOnHover: true,
draggable: true,
draggablePercent: 0.6,
showCloseButtonOnHover: false,
hideProgressBar: false,
closeButton: 'button',
icon: true,
rtl: false,
transition: 'Vue-Toastification__fade',
maxToasts: 5,
newestOnTop: true,
toastClassName: 'custom-toast',
bodyClassName: 'custom-toast-body',
}
// Use plugins
app.use(pinia)
app.use(router)
app.use(Toast, toastOptions)
// Global error handler
app.config.errorHandler = (err, instance, info) => {
console.error('Global error:', err)
console.error('Error info:', info)
}
// Mount app
app.mount('#app')
// Remove loading screen
const loadingElement = document.querySelector('.app-loading')
if (loadingElement) {
setTimeout(() => {
loadingElement.style.opacity = '0'
loadingElement.style.transition = 'opacity 0.3s ease-out'
setTimeout(() => {
loadingElement.remove()
}, 300)
}, 100)
}

View File

@@ -0,0 +1,161 @@
import { createRouter, createWebHistory } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const routes = [
{
path: "/",
name: "Home",
component: () => import("@/views/HomePage.vue"),
meta: { title: "Home" },
},
{
path: "/market",
name: "Market",
component: () => import("@/views/MarketPage.vue"),
meta: { title: "Marketplace" },
},
{
path: "/item/:id",
name: "ItemDetails",
component: () => import("@/views/ItemDetailsPage.vue"),
meta: { title: "Item Details" },
},
{
path: "/inventory",
name: "Inventory",
component: () => import("@/views/InventoryPage.vue"),
meta: { title: "My Inventory", requiresAuth: true },
},
{
path: "/profile",
name: "Profile",
component: () => import("@/views/ProfilePage.vue"),
meta: { title: "Profile", requiresAuth: true },
},
{
path: "/profile/:steamId",
name: "PublicProfile",
component: () => import("@/views/PublicProfilePage.vue"),
meta: { title: "User Profile" },
},
{
path: "/transactions",
name: "Transactions",
component: () => import("@/views/TransactionsPage.vue"),
meta: { title: "Transactions", requiresAuth: true },
},
{
path: "/sell",
name: "Sell",
component: () => import("@/views/SellPage.vue"),
meta: { title: "Sell Items", requiresAuth: true },
},
{
path: "/deposit",
name: "Deposit",
component: () => import("@/views/DepositPage.vue"),
meta: { title: "Deposit", requiresAuth: true },
},
{
path: "/withdraw",
name: "Withdraw",
component: () => import("@/views/WithdrawPage.vue"),
meta: { title: "Withdraw", requiresAuth: true },
},
{
path: "/diagnostic",
name: "Diagnostic",
component: () => import("@/views/DiagnosticPage.vue"),
meta: { title: "Authentication Diagnostic" },
},
{
path: "/admin",
name: "Admin",
component: () => import("@/views/AdminPage.vue"),
meta: { title: "Admin Dashboard", requiresAuth: true, requiresAdmin: true },
},
{
path: "/support",
name: "Support",
component: () => import("@/views/SupportPage.vue"),
meta: { title: "Support" },
},
{
path: "/faq",
name: "FAQ",
component: () => import("@/views/FAQPage.vue"),
meta: { title: "FAQ" },
},
{
path: "/terms",
name: "Terms",
component: () => import("@/views/TermsPage.vue"),
meta: { title: "Terms of Service" },
},
{
path: "/privacy",
name: "Privacy",
component: () => import("@/views/PrivacyPage.vue"),
meta: { title: "Privacy Policy" },
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("@/views/NotFoundPage.vue"),
meta: { title: "404 - Not Found" },
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
} else if (to.hash) {
return {
el: to.hash,
behavior: "smooth",
};
} else {
return { top: 0, behavior: "smooth" };
}
},
});
// Navigation guards
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
// Update page title
document.title = to.meta.title
? `${to.meta.title} - TurboTrades`
: "TurboTrades - CS2 & Rust Marketplace";
// Initialize auth if not already done
if (!authStore.isInitialized) {
await authStore.initialize();
}
// Check authentication requirement
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: "Home", query: { redirect: to.fullPath } });
return;
}
// Check admin requirement
if (to.meta.requiresAdmin && !authStore.isAdmin) {
next({ name: "Home" });
return;
}
// Check if user is banned
if (authStore.isBanned && to.name !== "Home") {
next({ name: "Home" });
return;
}
next();
});
export default router;

260
frontend/src/stores/auth.js Normal file
View File

@@ -0,0 +1,260 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import { useToast } from 'vue-toastification'
const toast = useToast()
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const isAuthenticated = ref(false)
const isLoading = ref(false)
const isInitialized = ref(false)
// Computed
const username = computed(() => user.value?.username || 'Guest')
const steamId = computed(() => user.value?.steamId || null)
const avatar = computed(() => user.value?.avatar || null)
const balance = computed(() => user.value?.balance || 0)
const staffLevel = computed(() => user.value?.staffLevel || 0)
const isStaff = computed(() => staffLevel.value > 0)
const isModerator = computed(() => staffLevel.value >= 2)
const isAdmin = computed(() => staffLevel.value >= 3)
const tradeUrl = computed(() => user.value?.tradeUrl || null)
const email = computed(() => user.value?.email?.address || null)
const emailVerified = computed(() => user.value?.email?.verified || false)
const isBanned = computed(() => user.value?.ban?.banned || false)
const banReason = computed(() => user.value?.ban?.reason || null)
const twoFactorEnabled = computed(() => user.value?.twoFactor?.enabled || false)
// Actions
const setUser = (userData) => {
user.value = userData
isAuthenticated.value = !!userData
}
const clearUser = () => {
user.value = null
isAuthenticated.value = false
}
const fetchUser = async () => {
if (isLoading.value) return
isLoading.value = true
try {
const response = await axios.get('/api/auth/me', {
withCredentials: true,
})
if (response.data.success && response.data.user) {
setUser(response.data.user)
return response.data.user
} else {
clearUser()
return null
}
} catch (error) {
console.error('Failed to fetch user:', error)
clearUser()
return null
} finally {
isLoading.value = false
isInitialized.value = true
}
}
const login = () => {
// Redirect to Steam login
window.location.href = '/api/auth/steam'
}
const logout = async () => {
isLoading.value = true
try {
await axios.post('/api/auth/logout', {}, {
withCredentials: true,
})
clearUser()
toast.success('Successfully logged out')
// Redirect to home page
if (window.location.pathname !== '/') {
window.location.href = '/'
}
} catch (error) {
console.error('Logout error:', error)
toast.error('Failed to logout')
} finally {
isLoading.value = false
}
}
const refreshToken = async () => {
try {
await axios.post('/api/auth/refresh', {}, {
withCredentials: true,
})
return true
} catch (error) {
console.error('Token refresh failed:', error)
clearUser()
return false
}
}
const updateTradeUrl = async (tradeUrl) => {
isLoading.value = true
try {
const response = await axios.patch('/api/user/trade-url',
{ tradeUrl },
{ withCredentials: true }
)
if (response.data.success) {
user.value.tradeUrl = tradeUrl
toast.success('Trade URL updated successfully')
return true
}
return false
} catch (error) {
console.error('Failed to update trade URL:', error)
toast.error(error.response?.data?.message || 'Failed to update trade URL')
return false
} finally {
isLoading.value = false
}
}
const updateEmail = async (email) => {
isLoading.value = true
try {
const response = await axios.patch('/api/user/email',
{ email },
{ withCredentials: true }
)
if (response.data.success) {
user.value.email = { address: email, verified: false }
toast.success('Email updated! Check your inbox for verification link')
return true
}
return false
} catch (error) {
console.error('Failed to update email:', error)
toast.error(error.response?.data?.message || 'Failed to update email')
return false
} finally {
isLoading.value = false
}
}
const verifyEmail = async (token) => {
isLoading.value = true
try {
const response = await axios.get(`/api/user/verify-email/${token}`, {
withCredentials: true
})
if (response.data.success) {
toast.success('Email verified successfully!')
await fetchUser() // Refresh user data
return true
}
return false
} catch (error) {
console.error('Failed to verify email:', error)
toast.error(error.response?.data?.message || 'Failed to verify email')
return false
} finally {
isLoading.value = false
}
}
const getUserStats = async () => {
try {
const response = await axios.get('/api/user/stats', {
withCredentials: true
})
if (response.data.success) {
return response.data.stats
}
return null
} catch (error) {
console.error('Failed to fetch user stats:', error)
return null
}
}
const getBalance = async () => {
try {
const response = await axios.get('/api/user/balance', {
withCredentials: true
})
if (response.data.success) {
user.value.balance = response.data.balance
return response.data.balance
}
return null
} catch (error) {
console.error('Failed to fetch balance:', error)
return null
}
}
const updateBalance = (newBalance) => {
if (user.value) {
user.value.balance = newBalance
}
}
// Initialize on store creation
const initialize = async () => {
if (!isInitialized.value) {
await fetchUser()
}
}
return {
// State
user,
isAuthenticated,
isLoading,
isInitialized,
// Computed
username,
steamId,
avatar,
balance,
staffLevel,
isStaff,
isModerator,
isAdmin,
tradeUrl,
email,
emailVerified,
isBanned,
banReason,
twoFactorEnabled,
// Actions
setUser,
clearUser,
fetchUser,
login,
logout,
refreshToken,
updateTradeUrl,
updateEmail,
verifyEmail,
getUserStats,
getBalance,
updateBalance,
initialize,
}
})

View File

@@ -0,0 +1,452 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'
import { useToast } from 'vue-toastification'
import { useWebSocketStore } from './websocket'
const toast = useToast()
export const useMarketStore = defineStore('market', () => {
// State
const items = ref([])
const featuredItems = ref([])
const recentSales = ref([])
const isLoading = ref(false)
const isLoadingMore = ref(false)
const currentPage = ref(1)
const totalPages = ref(1)
const totalItems = ref(0)
const itemsPerPage = ref(24)
// Filters
const filters = ref({
search: '',
game: null, // 'cs2', 'rust', null for all
minPrice: null,
maxPrice: null,
rarity: null,
wear: null,
category: null,
sortBy: 'price_asc', // price_asc, price_desc, name_asc, name_desc, date_new, date_old
statTrak: null,
souvenir: null,
})
// Categories
const categories = ref([
{ id: 'all', name: 'All Items', icon: 'Grid' },
{ id: 'rifles', name: 'Rifles', icon: 'Crosshair' },
{ id: 'pistols', name: 'Pistols', icon: 'Target' },
{ id: 'knives', name: 'Knives', icon: 'Sword' },
{ id: 'gloves', name: 'Gloves', icon: 'Hand' },
{ id: 'stickers', name: 'Stickers', icon: 'Sticker' },
{ id: 'cases', name: 'Cases', icon: 'Package' },
])
// Rarities
const rarities = ref([
{ id: 'common', name: 'Consumer Grade', color: '#b0c3d9' },
{ id: 'uncommon', name: 'Industrial Grade', color: '#5e98d9' },
{ id: 'rare', name: 'Mil-Spec', color: '#4b69ff' },
{ id: 'mythical', name: 'Restricted', color: '#8847ff' },
{ id: 'legendary', name: 'Classified', color: '#d32ce6' },
{ id: 'ancient', name: 'Covert', color: '#eb4b4b' },
{ id: 'exceedingly', name: 'Contraband', color: '#e4ae39' },
])
// Wear conditions
const wearConditions = ref([
{ id: 'fn', name: 'Factory New', abbr: 'FN' },
{ id: 'mw', name: 'Minimal Wear', abbr: 'MW' },
{ id: 'ft', name: 'Field-Tested', abbr: 'FT' },
{ id: 'ww', name: 'Well-Worn', abbr: 'WW' },
{ id: 'bs', name: 'Battle-Scarred', abbr: 'BS' },
])
// Computed
const filteredItems = computed(() => {
let result = [...items.value]
if (filters.value.search) {
const searchTerm = filters.value.search.toLowerCase()
result = result.filter(item =>
item.name.toLowerCase().includes(searchTerm) ||
item.description?.toLowerCase().includes(searchTerm)
)
}
if (filters.value.game) {
result = result.filter(item => item.game === filters.value.game)
}
if (filters.value.minPrice !== null) {
result = result.filter(item => item.price >= filters.value.minPrice)
}
if (filters.value.maxPrice !== null) {
result = result.filter(item => item.price <= filters.value.maxPrice)
}
if (filters.value.rarity) {
result = result.filter(item => item.rarity === filters.value.rarity)
}
if (filters.value.wear) {
result = result.filter(item => item.wear === filters.value.wear)
}
if (filters.value.category && filters.value.category !== 'all') {
result = result.filter(item => item.category === filters.value.category)
}
if (filters.value.statTrak !== null) {
result = result.filter(item => item.statTrak === filters.value.statTrak)
}
if (filters.value.souvenir !== null) {
result = result.filter(item => item.souvenir === filters.value.souvenir)
}
// Apply sorting
switch (filters.value.sortBy) {
case 'price_asc':
result.sort((a, b) => a.price - b.price)
break
case 'price_desc':
result.sort((a, b) => b.price - a.price)
break
case 'name_asc':
result.sort((a, b) => a.name.localeCompare(b.name))
break
case 'name_desc':
result.sort((a, b) => b.name.localeCompare(a.name))
break
case 'date_new':
result.sort((a, b) => new Date(b.listedAt) - new Date(a.listedAt))
break
case 'date_old':
result.sort((a, b) => new Date(a.listedAt) - new Date(b.listedAt))
break
}
return result
})
const hasMore = computed(() => currentPage.value < totalPages.value)
// Actions
const fetchItems = async (page = 1, append = false) => {
if (!append) {
isLoading.value = true
} else {
isLoadingMore.value = true
}
try {
const params = {
page,
limit: itemsPerPage.value,
...filters.value,
}
const response = await axios.get('/api/market/items', { params })
if (response.data.success) {
const newItems = response.data.items || []
if (append) {
items.value = [...items.value, ...newItems]
} else {
items.value = newItems
}
currentPage.value = response.data.page || page
totalPages.value = response.data.totalPages || 1
totalItems.value = response.data.total || 0
return true
}
return false
} catch (error) {
console.error('Failed to fetch items:', error)
toast.error('Failed to load marketplace items')
return false
} finally {
isLoading.value = false
isLoadingMore.value = false
}
}
const loadMore = async () => {
if (hasMore.value && !isLoadingMore.value) {
await fetchItems(currentPage.value + 1, true)
}
}
const fetchFeaturedItems = async () => {
try {
const response = await axios.get('/api/market/featured')
if (response.data.success) {
featuredItems.value = response.data.items || []
return true
}
return false
} catch (error) {
console.error('Failed to fetch featured items:', error)
return false
}
}
const fetchRecentSales = async (limit = 10) => {
try {
const response = await axios.get('/api/market/recent-sales', {
params: { limit }
})
if (response.data.success) {
recentSales.value = response.data.sales || []
return true
}
return false
} catch (error) {
console.error('Failed to fetch recent sales:', error)
return false
}
}
const getItemById = async (itemId) => {
try {
const response = await axios.get(`/api/market/items/${itemId}`)
if (response.data.success) {
return response.data.item
}
return null
} catch (error) {
console.error('Failed to fetch item:', error)
toast.error('Failed to load item details')
return null
}
}
const purchaseItem = async (itemId) => {
try {
const response = await axios.post(`/api/market/purchase/${itemId}`, {}, {
withCredentials: true
})
if (response.data.success) {
toast.success('Item purchased successfully!')
// Remove item from local state
items.value = items.value.filter(item => item.id !== itemId)
return true
}
return false
} catch (error) {
console.error('Failed to purchase item:', error)
const message = error.response?.data?.message || 'Failed to purchase item'
toast.error(message)
return false
}
}
const listItem = async (itemData) => {
try {
const response = await axios.post('/api/market/list', itemData, {
withCredentials: true
})
if (response.data.success) {
toast.success('Item listed successfully!')
// Add item to local state
if (response.data.item) {
items.value.unshift(response.data.item)
}
return response.data.item
}
return null
} catch (error) {
console.error('Failed to list item:', error)
const message = error.response?.data?.message || 'Failed to list item'
toast.error(message)
return null
}
}
const updateListing = async (itemId, updates) => {
try {
const response = await axios.patch(`/api/market/listing/${itemId}`, updates, {
withCredentials: true
})
if (response.data.success) {
toast.success('Listing updated successfully!')
// Update item in local state
const index = items.value.findIndex(item => item.id === itemId)
if (index !== -1 && response.data.item) {
items.value[index] = response.data.item
}
return true
}
return false
} catch (error) {
console.error('Failed to update listing:', error)
const message = error.response?.data?.message || 'Failed to update listing'
toast.error(message)
return false
}
}
const removeListing = async (itemId) => {
try {
const response = await axios.delete(`/api/market/listing/${itemId}`, {
withCredentials: true
})
if (response.data.success) {
toast.success('Listing removed successfully!')
// Remove item from local state
items.value = items.value.filter(item => item.id !== itemId)
return true
}
return false
} catch (error) {
console.error('Failed to remove listing:', error)
const message = error.response?.data?.message || 'Failed to remove listing'
toast.error(message)
return false
}
}
const updateFilter = (key, value) => {
filters.value[key] = value
currentPage.value = 1
}
const resetFilters = () => {
filters.value = {
search: '',
game: null,
minPrice: null,
maxPrice: null,
rarity: null,
wear: null,
category: null,
sortBy: 'price_asc',
statTrak: null,
souvenir: null,
}
currentPage.value = 1
}
const updateItemPrice = (itemId, newPrice) => {
const item = items.value.find(i => i.id === itemId)
if (item) {
item.price = newPrice
}
const featuredItem = featuredItems.value.find(i => i.id === itemId)
if (featuredItem) {
featuredItem.price = newPrice
}
}
const removeItem = (itemId) => {
items.value = items.value.filter(item => item.id !== itemId)
featuredItems.value = featuredItems.value.filter(item => item.id !== itemId)
}
const addItem = (item) => {
items.value.unshift(item)
totalItems.value++
}
// WebSocket integration
const setupWebSocketListeners = () => {
const wsStore = useWebSocketStore()
wsStore.on('listing_update', (data) => {
if (data?.itemId && data?.price) {
updateItemPrice(data.itemId, data.price)
}
})
wsStore.on('listing_removed', (data) => {
if (data?.itemId) {
removeItem(data.itemId)
}
})
wsStore.on('listing_added', (data) => {
if (data?.item) {
addItem(data.item)
}
})
wsStore.on('price_update', (data) => {
if (data?.itemId && data?.newPrice) {
updateItemPrice(data.itemId, data.newPrice)
}
})
wsStore.on('market_update', (data) => {
// Handle bulk market updates
console.log('Market update received:', data)
})
}
return {
// State
items,
featuredItems,
recentSales,
isLoading,
isLoadingMore,
currentPage,
totalPages,
totalItems,
itemsPerPage,
filters,
categories,
rarities,
wearConditions,
// Computed
filteredItems,
hasMore,
// Actions
fetchItems,
loadMore,
fetchFeaturedItems,
fetchRecentSales,
getItemById,
purchaseItem,
listItem,
updateListing,
removeListing,
updateFilter,
resetFilters,
updateItemPrice,
removeItem,
addItem,
setupWebSocketListeners,
}
})

View File

@@ -0,0 +1,341 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useAuthStore } from './auth'
import { useToast } from 'vue-toastification'
const toast = useToast()
export const useWebSocketStore = defineStore('websocket', () => {
// State
const ws = ref(null)
const isConnected = ref(false)
const isConnecting = ref(false)
const reconnectAttempts = ref(0)
const maxReconnectAttempts = ref(5)
const reconnectDelay = ref(1000)
const heartbeatInterval = ref(null)
const reconnectTimeout = ref(null)
const messageQueue = ref([])
const listeners = ref(new Map())
// Computed
const connectionStatus = computed(() => {
if (isConnected.value) return 'connected'
if (isConnecting.value) return 'connecting'
return 'disconnected'
})
const canReconnect = computed(() => {
return reconnectAttempts.value < maxReconnectAttempts.value
})
// Helper functions
const getWebSocketUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
// In development, use the proxy
if (import.meta.env.DEV) {
return `ws://localhost:3000/ws`
}
return `${protocol}//${host}/ws`
}
const clearHeartbeat = () => {
if (heartbeatInterval.value) {
clearInterval(heartbeatInterval.value)
heartbeatInterval.value = null
}
}
const clearReconnectTimeout = () => {
if (reconnectTimeout.value) {
clearTimeout(reconnectTimeout.value)
reconnectTimeout.value = null
}
}
const startHeartbeat = () => {
clearHeartbeat()
// Send ping every 30 seconds
heartbeatInterval.value = setInterval(() => {
if (isConnected.value && ws.value?.readyState === WebSocket.OPEN) {
send({ type: 'ping' })
}
}, 30000)
}
// Actions
const connect = () => {
if (ws.value?.readyState === WebSocket.OPEN || isConnecting.value) {
console.log('WebSocket already connected or connecting')
return
}
isConnecting.value = true
clearReconnectTimeout()
try {
const wsUrl = getWebSocketUrl()
console.log('Connecting to WebSocket:', wsUrl)
ws.value = new WebSocket(wsUrl)
ws.value.onopen = () => {
console.log('WebSocket connected')
isConnected.value = true
isConnecting.value = false
reconnectAttempts.value = 0
startHeartbeat()
// Send queued messages
while (messageQueue.value.length > 0) {
const message = messageQueue.value.shift()
send(message)
}
// Emit connected event
emit('connected', { timestamp: Date.now() })
}
ws.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
console.log('WebSocket message received:', data)
handleMessage(data)
} catch (error) {
console.error('Failed to parse WebSocket message:', error)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket error:', error)
isConnecting.value = false
}
ws.value.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason)
isConnected.value = false
isConnecting.value = false
clearHeartbeat()
// Emit disconnected event
emit('disconnected', {
code: event.code,
reason: event.reason,
timestamp: Date.now()
})
// Attempt to reconnect
if (!event.wasClean && canReconnect.value) {
scheduleReconnect()
}
}
} catch (error) {
console.error('Failed to create WebSocket connection:', error)
isConnecting.value = false
}
}
const disconnect = () => {
clearHeartbeat()
clearReconnectTimeout()
reconnectAttempts.value = maxReconnectAttempts.value // Prevent auto-reconnect
if (ws.value) {
ws.value.close(1000, 'Client disconnect')
ws.value = null
}
isConnected.value = false
isConnecting.value = false
}
const scheduleReconnect = () => {
if (!canReconnect.value) {
console.log('Max reconnect attempts reached')
toast.error('Lost connection to server. Please refresh the page.')
return
}
reconnectAttempts.value++
const delay = reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1)
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts.value})`)
clearReconnectTimeout()
reconnectTimeout.value = setTimeout(() => {
connect()
}, delay)
}
const send = (message) => {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected, queueing message:', message)
messageQueue.value.push(message)
return false
}
try {
const payload = typeof message === 'string' ? message : JSON.stringify(message)
ws.value.send(payload)
return true
} catch (error) {
console.error('Failed to send WebSocket message:', error)
return false
}
}
const handleMessage = (data) => {
const { type, data: payload, timestamp } = data
switch (type) {
case 'connected':
console.log('Server confirmed connection:', payload)
break
case 'pong':
// Heartbeat response
break
case 'notification':
if (payload?.message) {
toast.info(payload.message)
}
break
case 'balance_update':
// Update user balance
const authStore = useAuthStore()
if (payload?.balance !== undefined) {
authStore.updateBalance(payload.balance)
}
break
case 'item_sold':
toast.success(`Your item "${payload?.itemName || 'item'}" has been sold!`)
break
case 'item_purchased':
toast.success(`Successfully purchased "${payload?.itemName || 'item'}"!`)
break
case 'trade_status':
if (payload?.status === 'completed') {
toast.success('Trade completed successfully!')
} else if (payload?.status === 'failed') {
toast.error(`Trade failed: ${payload?.reason || 'Unknown error'}`)
}
break
case 'price_update':
case 'listing_update':
case 'market_update':
// These will be handled by listeners
break
case 'announcement':
if (payload?.message) {
toast.warning(payload.message, { timeout: 10000 })
}
break
case 'error':
console.error('Server error:', payload)
if (payload?.message) {
toast.error(payload.message)
}
break
default:
console.log('Unhandled message type:', type)
}
// Emit to listeners
emit(type, payload)
}
const on = (event, callback) => {
if (!listeners.value.has(event)) {
listeners.value.set(event, [])
}
listeners.value.get(event).push(callback)
// Return unsubscribe function
return () => off(event, callback)
}
const off = (event, callback) => {
if (!listeners.value.has(event)) return
const callbacks = listeners.value.get(event)
const index = callbacks.indexOf(callback)
if (index > -1) {
callbacks.splice(index, 1)
}
if (callbacks.length === 0) {
listeners.value.delete(event)
}
}
const emit = (event, data) => {
if (!listeners.value.has(event)) return
const callbacks = listeners.value.get(event)
callbacks.forEach(callback => {
try {
callback(data)
} catch (error) {
console.error(`Error in event listener for "${event}":`, error)
}
})
}
const once = (event, callback) => {
const wrappedCallback = (data) => {
callback(data)
off(event, wrappedCallback)
}
return on(event, wrappedCallback)
}
const clearListeners = () => {
listeners.value.clear()
}
// Ping the server
const ping = () => {
send({ type: 'ping' })
}
return {
// State
ws,
isConnected,
isConnecting,
reconnectAttempts,
maxReconnectAttempts,
messageQueue,
// Computed
connectionStatus,
canReconnect,
// Actions
connect,
disconnect,
send,
on,
off,
once,
emit,
clearListeners,
ping,
}
})

102
frontend/src/utils/axios.js Normal file
View File

@@ -0,0 +1,102 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import { useToast } from 'vue-toastification'
// Create axios instance
const axiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
timeout: 15000,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
axiosInstance.interceptors.request.use(
(config) => {
// You can add auth token to headers here if needed
// const token = localStorage.getItem('token')
// if (token) {
// config.headers.Authorization = `Bearer ${token}`
// }
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
axiosInstance.interceptors.response.use(
(response) => {
return response
},
async (error) => {
const toast = useToast()
const authStore = useAuthStore()
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// Unauthorized - token expired or invalid
if (data.code === 'TokenExpired') {
// Try to refresh token
try {
const refreshed = await authStore.refreshToken()
if (refreshed) {
// Retry the original request
return axiosInstance.request(error.config)
}
} catch (refreshError) {
// Refresh failed, logout user
authStore.clearUser()
window.location.href = '/'
}
} else {
authStore.clearUser()
toast.error('Please login to continue')
}
break
case 403:
// Forbidden
toast.error(data.message || 'Access denied')
break
case 404:
// Not found
toast.error(data.message || 'Resource not found')
break
case 429:
// Too many requests
toast.error('Too many requests. Please slow down.')
break
case 500:
// Server error
toast.error('Server error. Please try again later.')
break
default:
// Other errors
if (data.message) {
toast.error(data.message)
}
}
} else if (error.request) {
// Request made but no response
toast.error('Network error. Please check your connection.')
} else {
// Something else happened
toast.error('An unexpected error occurred')
}
return Promise.reject(error)
}
)
export default axiosInstance

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
<script setup>
import { ref } from 'vue'
import { Wallet, CreditCard, DollarSign } from 'lucide-vue-next'
const amount = ref(10)
const paymentMethod = ref('card')
const quickAmounts = [10, 25, 50, 100, 250, 500]
</script>
<template>
<div class="deposit-page min-h-screen py-8">
<div class="container-custom max-w-3xl">
<h1 class="text-3xl font-display font-bold text-white mb-2">Deposit Funds</h1>
<p class="text-gray-400 mb-8">Add funds to your account balance</p>
<div class="card card-body space-y-6">
<!-- Quick Amount Selection -->
<div>
<label class="input-label mb-3">Select Amount</label>
<div class="grid grid-cols-3 sm:grid-cols-6 gap-3">
<button
v-for="quickAmount in quickAmounts"
:key="quickAmount"
@click="amount = quickAmount"
:class="[
'py-3 rounded-lg font-semibold transition-all',
amount === quickAmount
? 'bg-primary-500 text-white shadow-glow'
: 'bg-surface-light text-gray-300 hover:bg-surface-lighter border border-surface-lighter'
]"
>
${{ quickAmount }}
</button>
</div>
</div>
<!-- Custom Amount -->
<div>
<label class="input-label mb-2">Custom Amount</label>
<div class="relative">
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
v-model.number="amount"
type="number"
min="5"
max="10000"
class="input pl-10"
placeholder="Enter amount"
/>
</div>
<p class="text-xs text-gray-500 mt-2">Minimum: $5 Maximum: $10,000</p>
</div>
<!-- Payment Method -->
<div>
<label class="input-label mb-3">Payment Method</label>
<div class="space-y-3">
<label class="flex items-center gap-3 p-4 bg-surface-light rounded-lg border border-surface-lighter hover:border-primary-500/50 cursor-pointer transition-colors">
<input
type="radio"
v-model="paymentMethod"
value="card"
class="w-4 h-4"
/>
<CreditCard class="w-5 h-5 text-gray-400" />
<div class="flex-1">
<div class="text-white font-medium">Credit/Debit Card</div>
<div class="text-sm text-gray-400">Visa, Mastercard, Amex</div>
</div>
</label>
<label class="flex items-center gap-3 p-4 bg-surface-light rounded-lg border border-surface-lighter hover:border-primary-500/50 cursor-pointer transition-colors">
<input
type="radio"
v-model="paymentMethod"
value="crypto"
class="w-4 h-4"
/>
<Wallet class="w-5 h-5 text-gray-400" />
<div class="flex-1">
<div class="text-white font-medium">Cryptocurrency</div>
<div class="text-sm text-gray-400">BTC, ETH, USDT</div>
</div>
</label>
</div>
</div>
<!-- Summary -->
<div class="p-4 bg-surface-dark rounded-lg space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-400">Amount</span>
<span class="text-white font-medium">${{ amount.toFixed(2) }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="text-gray-400">Processing Fee</span>
<span class="text-white font-medium">$0.00</span>
</div>
<div class="divider"></div>
<div class="flex justify-between">
<span class="text-white font-semibold">Total</span>
<span class="text-xl font-bold text-primary-500">${{ amount.toFixed(2) }}</span>
</div>
</div>
<!-- Submit Button -->
<button class="btn btn-primary w-full btn-lg">
Continue to Payment
</button>
<!-- Info Notice -->
<div class="p-4 bg-accent-blue/10 border border-accent-blue/30 rounded-lg text-sm text-gray-400">
<p>💡 Deposits are processed instantly. Your balance will be updated immediately after payment confirmation.</p>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,457 @@
<template>
<div class="min-h-screen bg-surface py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">🔍 Authentication Diagnostic</h1>
<p class="text-text-secondary">
Use this page to diagnose cookie and authentication issues
</p>
</div>
<!-- Quick Info -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-surface-light rounded-lg border border-surface-lighter p-4">
<div class="text-text-secondary text-sm mb-1">Login Status</div>
<div class="text-xl font-bold" :class="isLoggedIn ? 'text-success' : 'text-danger'">
{{ isLoggedIn ? '✅ Logged In' : '❌ Not Logged In' }}
</div>
</div>
<div class="bg-surface-light rounded-lg border border-surface-lighter p-4">
<div class="text-text-secondary text-sm mb-1">Browser Cookies</div>
<div class="text-xl font-bold" :class="hasBrowserCookies ? 'text-success' : 'text-danger'">
{{ hasBrowserCookies ? '✅ Present' : '❌ Missing' }}
</div>
</div>
<div class="bg-surface-light rounded-lg border border-surface-lighter p-4">
<div class="text-text-secondary text-sm mb-1">Backend Sees Cookies</div>
<div class="text-xl font-bold" :class="backendHasCookies ? 'text-success' : 'text-danger'">
{{ backendHasCookies === null ? '⏳ Testing...' : backendHasCookies ? '✅ Yes' : '❌ No' }}
</div>
</div>
</div>
<!-- Test Results -->
<div class="space-y-6">
<!-- Browser Cookies Test -->
<div class="bg-surface-light rounded-lg border border-surface-lighter p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span>1</span> Browser Cookies Check
</h2>
<button @click="checkBrowserCookies" class="btn-secondary text-sm">
Refresh
</button>
</div>
<div v-if="browserCookies" class="space-y-2">
<div class="flex items-start gap-2">
<span :class="browserCookies.hasAccessToken ? 'text-success' : 'text-danger'">
{{ browserCookies.hasAccessToken ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">accessToken:</span>
<span class="text-text-secondary ml-2">
{{ browserCookies.hasAccessToken ? 'Present (' + browserCookies.accessTokenLength + ' chars)' : 'Missing' }}
</span>
</div>
</div>
<div class="flex items-start gap-2">
<span :class="browserCookies.hasRefreshToken ? 'text-success' : 'text-danger'">
{{ browserCookies.hasRefreshToken ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">refreshToken:</span>
<span class="text-text-secondary ml-2">
{{ browserCookies.hasRefreshToken ? 'Present (' + browserCookies.refreshTokenLength + ' chars)' : 'Missing' }}
</span>
</div>
</div>
<div v-if="!browserCookies.hasAccessToken" class="mt-4 p-4 bg-danger/10 border border-danger/30 rounded-lg">
<p class="text-danger font-medium mb-2"> No cookies found in browser!</p>
<p class="text-sm text-text-secondary">
You need to log in via Steam. Click "Login with Steam" in the navigation bar.
</p>
</div>
</div>
</div>
<!-- Backend Cookie Check -->
<div class="bg-surface-light rounded-lg border border-surface-lighter p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span>2</span> Backend Cookie Check
</h2>
<button @click="checkBackendCookies" class="btn-secondary text-sm" :disabled="loadingBackend">
<Loader v-if="loadingBackend" class="w-4 h-4 animate-spin" />
<span v-else>Test Now</span>
</button>
</div>
<div v-if="backendDebug" class="space-y-3">
<div class="flex items-start gap-2">
<span :class="backendDebug.hasAccessToken ? 'text-success' : 'text-danger'">
{{ backendDebug.hasAccessToken ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">Backend received accessToken:</span>
<span class="text-text-secondary ml-2">{{ backendDebug.hasAccessToken ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="flex items-start gap-2">
<span :class="backendDebug.hasRefreshToken ? 'text-success' : 'text-danger'">
{{ backendDebug.hasRefreshToken ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">Backend received refreshToken:</span>
<span class="text-text-secondary ml-2">{{ backendDebug.hasRefreshToken ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="mt-4 pt-4 border-t border-surface-lighter">
<h3 class="text-white font-medium mb-2">Backend Configuration:</h3>
<div class="space-y-1 text-sm">
<div class="flex justify-between">
<span class="text-text-secondary">Cookie Domain:</span>
<span class="text-white font-mono">{{ backendDebug.config?.cookieDomain || 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Cookie Secure:</span>
<span class="text-white font-mono">{{ backendDebug.config?.cookieSecure }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Cookie SameSite:</span>
<span class="text-white font-mono">{{ backendDebug.config?.cookieSameSite || 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">CORS Origin:</span>
<span class="text-white font-mono">{{ backendDebug.config?.corsOrigin || 'N/A' }}</span>
</div>
</div>
</div>
<div v-if="hasBrowserCookies && !backendDebug.hasAccessToken" class="mt-4 p-4 bg-danger/10 border border-danger/30 rounded-lg">
<p class="text-danger font-medium mb-2">🚨 PROBLEM DETECTED!</p>
<p class="text-sm text-text-secondary mb-2">
Browser has cookies but backend is NOT receiving them!
</p>
<p class="text-sm text-text-secondary mb-2">Likely causes:</p>
<ul class="text-sm text-text-secondary list-disc list-inside space-y-1 ml-2">
<li>Cookie Domain mismatch (should be "localhost", not "127.0.0.1")</li>
<li>Cookie Secure flag is true on HTTP connection</li>
<li>Cookie SameSite is too restrictive</li>
</ul>
<p class="text-sm text-white mt-3 font-medium">🔧 Fix:</p>
<p class="text-sm text-text-secondary">Update backend <code class="px-1 py-0.5 bg-surface rounded">config/index.js</code> or <code class="px-1 py-0.5 bg-surface rounded">.env</code>:</p>
<pre class="mt-2 p-2 bg-surface rounded text-xs text-white overflow-x-auto">COOKIE_DOMAIN=localhost
COOKIE_SECURE=false
COOKIE_SAME_SITE=lax</pre>
</div>
</div>
<div v-else class="text-text-secondary text-center py-4">
Click "Test Now" to check if backend receives cookies
</div>
</div>
<!-- Authentication Test -->
<div class="bg-surface-light rounded-lg border border-surface-lighter p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span>3</span> Authentication Test
</h2>
<button @click="testAuth" class="btn-secondary text-sm" :disabled="loadingAuth">
<Loader v-if="loadingAuth" class="w-4 h-4 animate-spin" />
<span v-else>Test Now</span>
</button>
</div>
<div v-if="authTest" class="space-y-2">
<div class="flex items-start gap-2">
<span :class="authTest.success ? 'text-success' : 'text-danger'">
{{ authTest.success ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">/auth/me endpoint:</span>
<span class="text-text-secondary ml-2">{{ authTest.message }}</span>
</div>
</div>
<div v-if="authTest.success && authTest.user" class="mt-4 p-4 bg-success/10 border border-success/30 rounded-lg">
<p class="text-success font-medium mb-2"> Successfully authenticated!</p>
<div class="text-sm space-y-1">
<div class="flex justify-between">
<span class="text-text-secondary">Username:</span>
<span class="text-white">{{ authTest.user.username }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Steam ID:</span>
<span class="text-white font-mono">{{ authTest.user.steamId }}</span>
</div>
<div class="flex justify-between">
<span class="text-text-secondary">Balance:</span>
<span class="text-white">${{ authTest.user.balance.toFixed(2) }}</span>
</div>
</div>
</div>
</div>
<div v-else class="text-text-secondary text-center py-4">
Click "Test Now" to verify authentication
</div>
</div>
<!-- Sessions Test -->
<div class="bg-surface-light rounded-lg border border-surface-lighter p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span>4</span> Sessions Endpoint Test
</h2>
<button @click="testSessions" class="btn-secondary text-sm" :disabled="loadingSessions">
<Loader v-if="loadingSessions" class="w-4 h-4 animate-spin" />
<span v-else>Test Now</span>
</button>
</div>
<div v-if="sessionsTest" class="space-y-2">
<div class="flex items-start gap-2">
<span :class="sessionsTest.success ? 'text-success' : 'text-danger'">
{{ sessionsTest.success ? '✅' : '❌' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">/user/sessions endpoint:</span>
<span class="text-text-secondary ml-2">{{ sessionsTest.message }}</span>
</div>
</div>
<div v-if="sessionsTest.success && sessionsTest.sessions" class="mt-4 space-y-2">
<p class="text-success">Found {{ sessionsTest.sessions.length }} active session(s)</p>
<div v-for="(session, i) in sessionsTest.sessions" :key="i" class="p-3 bg-surface rounded border border-surface-lighter">
<div class="text-sm space-y-1">
<div class="text-white font-medium">{{ session.browser }} on {{ session.os }}</div>
<div class="text-text-secondary">Device: {{ session.device }}</div>
<div class="text-text-secondary">IP: {{ session.ip }}</div>
</div>
</div>
</div>
<div v-else-if="sessionsTest.error" class="mt-4 p-4 bg-danger/10 border border-danger/30 rounded-lg">
<p class="text-danger font-medium mb-2"> Sessions endpoint failed!</p>
<p class="text-sm text-text-secondary">{{ sessionsTest.error }}</p>
</div>
</div>
<div v-else class="text-text-secondary text-center py-4">
Click "Test Now" to test sessions endpoint (this is what's failing for you)
</div>
</div>
<!-- 2FA Test -->
<div class="bg-surface-light rounded-lg border border-surface-lighter p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<span>5⃣</span> 2FA Setup Endpoint Test
</h2>
<button @click="test2FA" class="btn-secondary text-sm" :disabled="loading2FA">
<Loader v-if="loading2FA" class="w-4 h-4 animate-spin" />
<span v-else>Test Now</span>
</button>
</div>
<div v-if="twoFATest" class="space-y-2">
<div class="flex items-start gap-2">
<span :class="twoFATest.success ? 'text-success' : 'text-danger'">
{{ twoFATest.success ? '' : '' }}
</span>
<div class="flex-1">
<span class="text-white font-medium">/user/2fa/setup endpoint:</span>
<span class="text-text-secondary ml-2">{{ twoFATest.message }}</span>
</div>
</div>
<div v-if="twoFATest.success" class="mt-4 p-4 bg-success/10 border border-success/30 rounded-lg">
<p class="text-success font-medium">✅ 2FA setup endpoint works!</p>
<p class="text-sm text-text-secondary mt-1">QR code and secret were generated successfully</p>
</div>
<div v-else-if="twoFATest.error" class="mt-4 p-4 bg-danger/10 border border-danger/30 rounded-lg">
<p class="text-danger font-medium mb-2">❌ 2FA setup failed!</p>
<p class="text-sm text-text-secondary">{{ twoFATest.error }}</p>
</div>
</div>
<div v-else class="text-text-secondary text-center py-4">
Click "Test Now" to test 2FA setup endpoint
</div>
</div>
<!-- Summary -->
<div class="bg-gradient-to-r from-primary/20 to-primary/10 rounded-lg border border-primary/30 p-6">
<h2 class="text-xl font-bold text-white mb-4">📋 Summary & Next Steps</h2>
<div class="space-y-2 text-sm">
<p class="text-text-secondary" v-if="!hasBrowserCookies">
<strong class="text-white">Step 1:</strong> Log in via Steam to get authentication cookies.
</p>
<p class="text-text-secondary" v-else-if="!backendHasCookies">
<strong class="text-white">Step 2:</strong> Fix cookie configuration so backend receives them. See the red warning box above for details.
</p>
<p class="text-text-secondary" v-else-if="backendHasCookies && !authTest?.success">
<strong class="text-white">Step 3:</strong> Run the authentication test to verify your token is valid.
</p>
<p class="text-text-secondary" v-else-if="authTest?.success && !sessionsTest?.success">
<strong class="text-white">Step 4:</strong> Test the sessions endpoint - this should work now!
</p>
<p class="text-success font-medium" v-else-if="sessionsTest?.success">
✅ Everything is working! You can now use sessions and 2FA features.
</p>
</div>
<div class="mt-4 pt-4 border-t border-primary/20">
<p class="text-text-secondary text-xs">
For more detailed troubleshooting, see <code class="px-1 py-0.5 bg-surface rounded">TurboTrades/TROUBLESHOOTING_AUTH.md</code>
</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { Loader } from 'lucide-vue-next'
import axios from '@/utils/axios'
// State
const browserCookies = ref(null)
const backendDebug = ref(null)
const authTest = ref(null)
const sessionsTest = ref(null)
const twoFATest = ref(null)
const loadingBackend = ref(false)
const loadingAuth = ref(false)
const loadingSessions = ref(false)
const loading2FA = ref(false)
// Computed
const hasBrowserCookies = computed(() => {
return browserCookies.value?.hasAccessToken || false
})
const backendHasCookies = computed(() => {
if (!backendDebug.value) return null
return backendDebug.value.hasAccessToken
})
const isLoggedIn = computed(() => {
return hasBrowserCookies.value && backendHasCookies.value && authTest.value?.success
})
// Methods
const checkBrowserCookies = () => {
const cookies = document.cookie
const accessToken = cookies.split(';').find(c => c.trim().startsWith('accessToken='))
const refreshToken = cookies.split(';').find(c => c.trim().startsWith('refreshToken='))
browserCookies.value = {
hasAccessToken: !!accessToken,
hasRefreshToken: !!refreshToken,
accessTokenLength: accessToken ? accessToken.split('=')[1].length : 0,
refreshTokenLength: refreshToken ? refreshToken.split('=')[1].length : 0,
}
}
const checkBackendCookies = async () => {
loadingBackend.value = true
try {
const response = await axios.get('/api/auth/debug-cookies', {
withCredentials: true
})
backendDebug.value = response.data
} catch (error) {
console.error('Backend cookie check failed:', error)
backendDebug.value = {
hasAccessToken: false,
hasRefreshToken: false,
error: error.message
}
} finally {
loadingBackend.value = false
}
}
const testAuth = async () => {
loadingAuth.value = true
try {
const response = await axios.get('/api/auth/me', {
withCredentials: true
})
authTest.value = {
success: true,
message: 'Successfully authenticated',
user: response.data.user
}
} catch (error) {
authTest.value = {
success: false,
message: error.response?.data?.message || error.message,
error: error.response?.data?.error || 'Error'
}
} finally {
loadingAuth.value = false
}
}
const testSessions = async () => {
loadingSessions.value = true
try {
const response = await axios.get('/api/user/sessions', {
withCredentials: true
})
sessionsTest.value = {
success: true,
message: 'Sessions retrieved successfully',
sessions: response.data.sessions
}
} catch (error) {
sessionsTest.value = {
success: false,
message: error.response?.data?.message || error.message,
error: error.response?.data?.message || error.message
}
} finally {
loadingSessions.value = false
}
}
const test2FA = async () => {
loading2FA.value = true
try {
const response = await axios.post('/api/user/2fa/setup', {}, {
withCredentials: true
})
twoFATest.value = {
success: true,
message: '2FA setup successful',
data: response.data
}
} catch (error) {
twoFATest.value = {
success: false,
message: error.response?.data?.message || error.message,
error: error.response?.data?.message || error.message
}
} finally {
loading2FA.value = false
}
}
const runAllTests = async () => {
checkBrowserCookies()
await checkBackendCookies()
if (backendHasCookies.value) {
await testAuth()
if (authTest.value?.success) {
await testSessions()
}
}
}
// Lifecycle
onMounted(() => {
checkBrowserCookies()
checkBackendCookies()
})
</script>
<style scoped>
code {
font-family: 'Courier New', monospace;
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup>
import { ref } from 'vue'
import { ChevronDown } from 'lucide-vue-next'
const faqs = ref([
{
id: 1,
question: 'How do I buy items?',
answer: 'Browse the marketplace, click on an item you like, and click "Buy Now". Make sure you have sufficient balance and your trade URL is set in your profile.',
open: false
},
{
id: 2,
question: 'How do I sell items?',
answer: 'Go to the "Sell" page, select items from your Steam inventory, set your price, and list them on the marketplace.',
open: false
},
{
id: 3,
question: 'How long does delivery take?',
answer: 'Delivery is instant! Once you purchase an item, you will receive a Steam trade offer automatically within seconds.',
open: false
},
{
id: 4,
question: 'What payment methods do you accept?',
answer: 'We accept various payment methods including credit/debit cards, PayPal, and cryptocurrency. You can deposit funds in the Deposit page.',
open: false
},
{
id: 5,
question: 'How do I withdraw my balance?',
answer: 'Go to the Withdraw page, enter the amount you want to withdraw, and select your preferred withdrawal method. Processing typically takes 1-3 business days.',
open: false
},
{
id: 6,
question: 'Is trading safe?',
answer: 'Yes! We use bank-grade security, SSL encryption, and automated trading bots to ensure safe and secure transactions.',
open: false
},
{
id: 7,
question: 'What are the fees?',
answer: 'We charge a small marketplace fee of 5% on sales. There are no fees for buying items.',
open: false
},
{
id: 8,
question: 'Can I cancel a listing?',
answer: 'Yes, you can cancel your listings anytime from your inventory page before they are sold.',
open: false
}
])
const toggleFaq = (id) => {
const faq = faqs.value.find(f => f.id === id)
if (faq) {
faq.open = !faq.open
}
}
</script>
<template>
<div class="faq-page min-h-screen py-12">
<div class="container-custom max-w-4xl">
<!-- Header -->
<div class="text-center mb-12">
<h1 class="text-4xl sm:text-5xl font-display font-bold text-white mb-4">
Frequently Asked Questions
</h1>
<p class="text-lg text-gray-400">
Find answers to common questions about TurboTrades
</p>
</div>
<!-- FAQ List -->
<div class="space-y-4">
<div
v-for="faq in faqs"
:key="faq.id"
class="card overflow-hidden"
>
<button
@click="toggleFaq(faq.id)"
class="w-full flex items-center justify-between p-6 text-left hover:bg-surface-light transition-colors"
>
<span class="text-lg font-semibold text-white pr-4">{{ faq.question }}</span>
<ChevronDown
:class="['w-5 h-5 text-gray-400 transition-transform flex-shrink-0', faq.open ? 'rotate-180' : '']"
/>
</button>
<Transition name="slide-down">
<div v-if="faq.open" class="px-6 pb-6">
<p class="text-gray-400 leading-relaxed">{{ faq.answer }}</p>
</div>
</Transition>
</div>
</div>
<!-- Contact Support -->
<div class="mt-12 p-8 bg-surface rounded-xl border border-surface-lighter text-center">
<h3 class="text-2xl font-bold text-white mb-3">Still have questions?</h3>
<p class="text-gray-400 mb-6">
Our support team is here to help you with any questions or concerns.
</p>
<router-link to="/support" class="btn btn-primary">
Contact Support
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.slide-down-enter-active,
.slide-down-leave-active {
transition: all 0.3s ease;
max-height: 200px;
}
.slide-down-enter-from,
.slide-down-leave-to {
opacity: 0;
max-height: 0;
}
</style>

View File

@@ -0,0 +1,419 @@
<script setup>
import { ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import { useMarketStore } from "@/stores/market";
import { useAuthStore } from "@/stores/auth";
import {
TrendingUp,
Shield,
Zap,
Users,
ArrowRight,
Sparkles,
ChevronRight,
} from "lucide-vue-next";
const router = useRouter();
const marketStore = useMarketStore();
const authStore = useAuthStore();
const featuredItems = ref([]);
const recentSales = ref([]);
const isLoading = ref(true);
const stats = ref({
totalUsers: "50,000+",
totalTrades: "1M+",
avgTradeTime: "< 2 min",
activeListings: "25,000+",
});
const features = [
{
icon: Zap,
title: "Instant Trading",
description: "Lightning-fast transactions with automated trade bot system",
},
{
icon: Shield,
title: "Secure & Safe",
description: "Bank-grade security with SSL encryption and fraud protection",
},
{
icon: TrendingUp,
title: "Best Prices",
description: "Competitive marketplace pricing with real-time market data",
},
{
icon: Users,
title: "Active Community",
description: "Join thousands of traders in our vibrant marketplace",
},
];
onMounted(async () => {
isLoading.value = true;
await Promise.all([
marketStore.fetchFeaturedItems(),
marketStore.fetchRecentSales(6),
]);
featuredItems.value = marketStore.featuredItems.slice(0, 8);
recentSales.value = marketStore.recentSales;
isLoading.value = false;
});
const navigateToMarket = () => {
router.push("/market");
};
const navigateToSell = () => {
if (authStore.isAuthenticated) {
router.push("/sell");
} else {
authStore.login();
}
};
const formatPrice = (price) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
};
const formatTimeAgo = (timestamp) => {
const seconds = Math.floor((Date.now() - new Date(timestamp)) / 1000);
if (seconds < 60) return "Just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
};
const getRarityColor = (rarity) => {
const colors = {
common: "text-gray-400",
uncommon: "text-green-400",
rare: "text-blue-400",
mythical: "text-purple-400",
legendary: "text-amber-400",
ancient: "text-red-400",
exceedingly: "text-orange-400",
};
return colors[rarity] || "text-gray-400";
};
</script>
<template>
<div class="home-page">
<!-- Hero Section -->
<section class="relative py-20 lg:py-32 overflow-hidden">
<!-- Background Effects -->
<div class="absolute inset-0 opacity-30">
<div
class="absolute top-1/4 left-1/4 w-96 h-96 bg-primary-500/20 rounded-full blur-3xl"
></div>
<div
class="absolute bottom-1/4 right-1/4 w-96 h-96 bg-accent-blue/20 rounded-full blur-3xl"
></div>
</div>
<div class="container-custom relative z-10">
<div class="max-w-4xl mx-auto text-center">
<!-- Badge -->
<div
class="inline-flex items-center gap-2 px-4 py-2 bg-surface-light/50 backdrop-blur-sm border border-primary-500/30 rounded-full mb-6 animate-fade-in"
>
<Sparkles class="w-4 h-4 text-primary-500" />
<span class="text-sm font-medium text-gray-300"
>Premium CS2 & Rust Marketplace</span
>
</div>
<!-- Heading -->
<h1
class="text-4xl sm:text-5xl lg:text-7xl font-display font-bold mb-6 animate-slide-up"
>
<span class="text-white">Trade Your Skins</span>
<br />
<span class="gradient-text">Lightning Fast</span>
</h1>
<!-- Description -->
<p
class="text-lg sm:text-xl text-gray-400 mb-10 max-w-2xl mx-auto animate-slide-up"
style="animation-delay: 0.1s"
>
Buy, sell, and trade CS2 and Rust skins with instant delivery. Join
thousands of traders in the most trusted marketplace.
</p>
<!-- CTA Buttons -->
<div
class="flex flex-col sm:flex-row items-center justify-center gap-4 animate-slide-up"
style="animation-delay: 0.2s"
>
<button
@click="navigateToMarket"
class="btn btn-primary btn-lg group"
>
Browse Market
<ArrowRight
class="w-5 h-5 group-hover:translate-x-1 transition-transform"
/>
</button>
<button @click="navigateToSell" class="btn btn-outline btn-lg">
Start Selling
</button>
</div>
<!-- Stats -->
<div
class="grid grid-cols-2 md:grid-cols-4 gap-6 mt-16 animate-slide-up"
style="animation-delay: 0.3s"
>
<div
v-for="(value, key) in stats"
:key="key"
class="p-6 bg-surface/50 backdrop-blur-sm rounded-xl border border-surface-lighter"
>
<div class="text-2xl sm:text-3xl font-bold text-primary-500 mb-1">
{{ value }}
</div>
<div class="text-sm text-gray-400 capitalize">
{{ key.replace(/([A-Z])/g, " $1").trim() }}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Features Section -->
<section class="py-16 lg:py-24 bg-surface/30">
<div class="container-custom">
<div class="text-center mb-12">
<h2
class="text-3xl sm:text-4xl font-display font-bold text-white mb-4"
>
Why Choose TurboTrades?
</h2>
<p class="text-lg text-gray-400 max-w-2xl mx-auto">
Experience the best trading platform with industry-leading features
and security
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div
v-for="feature in features"
:key="feature.title"
class="p-6 bg-surface rounded-xl border border-surface-lighter hover:border-primary-500/50 transition-all group"
>
<div
class="w-12 h-12 bg-primary-500/10 rounded-lg flex items-center justify-center mb-4 group-hover:bg-primary-500/20 transition-colors"
>
<component :is="feature.icon" class="w-6 h-6 text-primary-500" />
</div>
<h3 class="text-xl font-semibold text-white mb-2">
{{ feature.title }}
</h3>
<p class="text-gray-400 text-sm">{{ feature.description }}</p>
</div>
</div>
</div>
</section>
<!-- Featured Items Section -->
<section class="py-16 lg:py-24">
<div class="container-custom">
<div class="flex items-center justify-between mb-8">
<div>
<h2
class="text-3xl sm:text-4xl font-display font-bold text-white mb-2"
>
Featured Items
</h2>
<p class="text-gray-400">Hand-picked premium skins</p>
</div>
<button @click="navigateToMarket" class="btn btn-ghost group">
View All
<ChevronRight
class="w-4 h-4 group-hover:translate-x-1 transition-transform"
/>
</button>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"
>
<div v-for="i in 8" :key="i" class="card">
<div class="aspect-square skeleton"></div>
<div class="card-body space-y-3">
<div class="h-4 skeleton w-3/4"></div>
<div class="h-3 skeleton w-1/2"></div>
</div>
</div>
</div>
<!-- Items Grid -->
<div
v-else
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"
>
<div
v-for="item in featuredItems"
:key="item.id"
@click="router.push(`/item/${item.id}`)"
class="item-card group"
>
<!-- Image -->
<div class="relative">
<img :src="item.image" :alt="item.name" class="item-card-image" />
<div v-if="item.wear" class="item-card-wear">
{{ item.wear }}
</div>
<div
v-if="item.statTrak"
class="absolute top-2 right-2 badge badge-warning"
>
StatTrak
</div>
</div>
<!-- Content -->
<div class="p-4 space-y-3">
<div>
<h3 class="font-semibold text-white text-sm line-clamp-2 mb-1">
{{ item.name }}
</h3>
<p
:class="['text-xs font-medium', getRarityColor(item.rarity)]"
>
{{ item.rarity }}
</p>
</div>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-primary-500">
{{ formatPrice(item.price) }}
</span>
<button class="btn btn-sm btn-primary">Buy Now</button>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Recent Sales Section -->
<section class="py-16 lg:py-24 bg-surface/30">
<div class="container-custom">
<div class="text-center mb-12">
<h2
class="text-3xl sm:text-4xl font-display font-bold text-white mb-4"
>
Recent Sales
</h2>
<p class="text-lg text-gray-400">Live marketplace activity</p>
</div>
<div class="max-w-4xl mx-auto space-y-3">
<div
v-for="sale in recentSales"
:key="sale.id"
class="flex items-center gap-4 p-4 bg-surface rounded-lg border border-surface-lighter hover:border-primary-500/30 transition-colors"
>
<img
:src="sale.itemImage"
:alt="sale.itemName"
class="w-16 h-16 object-contain bg-surface-light rounded-lg"
/>
<div class="flex-1 min-w-0">
<h4 class="font-medium text-white text-sm truncate">
{{ sale.itemName }}
</h4>
<p class="text-xs text-gray-400">{{ sale.wear }}</p>
</div>
<div class="text-right">
<div class="font-bold text-accent-green">
{{ formatPrice(sale.price) }}
</div>
<div class="text-xs text-gray-500">
{{ formatTimeAgo(sale.soldAt) }}
</div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-16 lg:py-24">
<div class="container-custom">
<div
class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-primary-600 to-primary-800 p-12 text-center"
>
<!-- Background Pattern -->
<div class="absolute inset-0 opacity-10">
<div
class="absolute top-0 left-1/4 w-72 h-72 bg-white rounded-full blur-3xl"
></div>
<div
class="absolute bottom-0 right-1/4 w-72 h-72 bg-white rounded-full blur-3xl"
></div>
</div>
<div class="relative z-10 max-w-3xl mx-auto">
<h2
class="text-3xl sm:text-5xl font-display font-bold text-white mb-6"
>
Ready to Start Trading?
</h2>
<p class="text-lg text-primary-100 mb-8">
Join TurboTrades today and experience the fastest, most secure way
to trade gaming skins
</p>
<div
class="flex flex-col sm:flex-row items-center justify-center gap-4"
>
<button
v-if="!authStore.isAuthenticated"
@click="authStore.login"
class="btn btn-lg bg-white text-primary-600 hover:bg-gray-100"
>
<img
src="https://community.cloudflare.steamstatic.com/public/images/signinthroughsteam/sits_01.png"
alt="Sign in through Steam"
class="h-6"
/>
</button>
<button
v-else
@click="navigateToMarket"
class="btn btn-lg bg-white text-primary-600 hover:bg-gray-100"
>
Browse Market
<ArrowRight class="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.home-page {
min-height: 100vh;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,21 @@
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Package } from 'lucide-vue-next'
const authStore = useAuthStore()
const hasItems = computed(() => false) // Placeholder
</script>
<template>
<div class="inventory-page min-h-screen py-8">
<div class="container-custom">
<h1 class="text-3xl font-display font-bold text-white mb-8">My Inventory</h1>
<div class="text-center py-20">
<Package class="w-16 h-16 text-gray-500 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-400 mb-2">No items yet</h3>
<p class="text-gray-500">Purchase items from the marketplace to see them here</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,304 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useMarketStore } from '@/stores/market'
import { useAuthStore } from '@/stores/auth'
import {
ArrowLeft,
ShoppingCart,
Heart,
Share2,
AlertCircle,
CheckCircle,
Loader2,
TrendingUp,
Clock,
Package
} from 'lucide-vue-next'
const route = useRoute()
const router = useRouter()
const marketStore = useMarketStore()
const authStore = useAuthStore()
const item = ref(null)
const isLoading = ref(true)
const isPurchasing = ref(false)
const isFavorite = ref(false)
onMounted(async () => {
const itemId = route.params.id
item.value = await marketStore.getItemById(itemId)
isLoading.value = false
})
const formatPrice = (price) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(price)
}
const handlePurchase = async () => {
if (!authStore.isAuthenticated) {
authStore.login()
return
}
if (authStore.balance < item.value.price) {
alert('Insufficient balance')
return
}
isPurchasing.value = true
const success = await marketStore.purchaseItem(item.value.id)
if (success) {
router.push('/inventory')
}
isPurchasing.value = false
}
const toggleFavorite = () => {
isFavorite.value = !isFavorite.value
}
const shareItem = () => {
navigator.clipboard.writeText(window.location.href)
alert('Link copied to clipboard!')
}
const goBack = () => {
router.back()
}
const getRarityColor = (rarity) => {
const colors = {
common: 'text-gray-400 border-gray-400/30',
uncommon: 'text-green-400 border-green-400/30',
rare: 'text-blue-400 border-blue-400/30',
mythical: 'text-purple-400 border-purple-400/30',
legendary: 'text-amber-400 border-amber-400/30',
ancient: 'text-red-400 border-red-400/30',
exceedingly: 'text-orange-400 border-orange-400/30'
}
return colors[rarity] || 'text-gray-400 border-gray-400/30'
}
</script>
<template>
<div class="item-details-page min-h-screen py-8">
<div class="container-custom">
<!-- Back Button -->
<button @click="goBack" class="btn btn-ghost mb-6 flex items-center gap-2">
<ArrowLeft class="w-4 h-4" />
Back to Market
</button>
<!-- Loading State -->
<div v-if="isLoading" class="flex items-center justify-center py-20">
<Loader2 class="w-12 h-12 text-primary-500 animate-spin" />
</div>
<!-- Item Details -->
<div v-else-if="item" class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Column - Image -->
<div class="space-y-4">
<div class="card overflow-hidden">
<div class="aspect-square bg-gradient-to-br from-surface-light to-surface p-8 flex items-center justify-center relative">
<img
:src="item.image"
:alt="item.name"
class="w-full h-full object-contain"
/>
<div v-if="item.wear" class="absolute top-4 left-4 badge badge-primary">
{{ item.wear }}
</div>
<div v-if="item.statTrak" class="absolute top-4 right-4 badge badge-warning">
StatTrak
</div>
</div>
</div>
<!-- Item Stats -->
<div class="card card-body space-y-3">
<h3 class="text-white font-semibold">Item Information</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-gray-400">Game</span>
<span class="text-white font-medium">{{ item.game?.toUpperCase() || 'N/A' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Category</span>
<span class="text-white font-medium capitalize">{{ item.category }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Rarity</span>
<span :class="['font-medium capitalize', getRarityColor(item.rarity).split(' ')[0]]">
{{ item.rarity }}
</span>
</div>
<div v-if="item.float" class="flex justify-between">
<span class="text-gray-400">Float Value</span>
<span class="text-white font-medium">{{ item.float }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-400">Listed</span>
<span class="text-white font-medium">{{ new Date(item.listedAt).toLocaleDateString() }}</span>
</div>
</div>
</div>
</div>
<!-- Right Column - Details & Purchase -->
<div class="space-y-6">
<!-- Title & Actions -->
<div>
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h1 class="text-3xl font-display font-bold text-white mb-2">
{{ item.name }}
</h1>
<p class="text-gray-400">{{ item.description || 'No description available' }}</p>
</div>
<div class="flex gap-2">
<button
@click="toggleFavorite"
:class="['btn btn-ghost p-2', isFavorite ? 'text-accent-red' : 'text-gray-400']"
>
<Heart :class="['w-5 h-5', isFavorite ? 'fill-current' : '']" />
</button>
<button @click="shareItem" class="btn btn-ghost p-2">
<Share2 class="w-5 h-5" />
</button>
</div>
</div>
<!-- Rarity Badge -->
<div :class="['inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border', getRarityColor(item.rarity)]">
<div class="w-2 h-2 rounded-full bg-current"></div>
<span class="text-sm font-medium capitalize">{{ item.rarity }}</span>
</div>
</div>
<!-- Price & Purchase -->
<div class="card card-body space-y-4">
<div>
<div class="text-sm text-gray-400 mb-1">Current Price</div>
<div class="text-4xl font-bold text-primary-500">
{{ formatPrice(item.price) }}
</div>
</div>
<!-- Purchase Button -->
<button
@click="handlePurchase"
:disabled="isPurchasing || (authStore.isAuthenticated && authStore.balance < item.price)"
class="btn btn-primary w-full btn-lg"
>
<Loader2 v-if="isPurchasing" class="w-5 h-5 animate-spin" />
<template v-else>
<ShoppingCart class="w-5 h-5" />
<span v-if="!authStore.isAuthenticated">Login to Purchase</span>
<span v-else-if="authStore.balance < item.price">Insufficient Balance</span>
<span v-else>Buy Now</span>
</template>
</button>
<!-- Balance Check -->
<div v-if="authStore.isAuthenticated" class="p-3 bg-surface-light rounded-lg">
<div class="flex items-center justify-between text-sm">
<span class="text-gray-400">Your Balance</span>
<span class="font-semibold text-white">{{ formatPrice(authStore.balance) }}</span>
</div>
<div v-if="authStore.balance < item.price" class="flex items-center gap-2 mt-2 text-accent-red text-xs">
<AlertCircle class="w-4 h-4" />
<span>Insufficient funds. Please deposit more to continue.</span>
</div>
</div>
</div>
<!-- Seller Info -->
<div class="card card-body">
<h3 class="text-white font-semibold mb-4">Seller Information</h3>
<div class="flex items-center gap-3">
<img
:src="item.seller?.avatar || 'https://via.placeholder.com/40'"
:alt="item.seller?.username"
class="w-12 h-12 rounded-full"
/>
<div class="flex-1">
<div class="font-medium text-white">{{ item.seller?.username || 'Anonymous' }}</div>
<div class="text-sm text-gray-400">{{ item.seller?.totalSales || 0 }} successful trades</div>
</div>
<router-link
v-if="item.seller?.steamId"
:to="`/profile/${item.seller.steamId}`"
class="btn btn-sm btn-secondary"
>
View Profile
</router-link>
</div>
</div>
<!-- Features -->
<div class="grid grid-cols-3 gap-4">
<div class="card card-body text-center">
<CheckCircle class="w-6 h-6 text-accent-green mx-auto mb-2" />
<div class="text-xs text-gray-400">Instant</div>
<div class="text-sm font-medium text-white">Delivery</div>
</div>
<div class="card card-body text-center">
<Package class="w-6 h-6 text-accent-blue mx-auto mb-2" />
<div class="text-xs text-gray-400">Secure</div>
<div class="text-sm font-medium text-white">Trading</div>
</div>
<div class="card card-body text-center">
<TrendingUp class="w-6 h-6 text-primary-500 mx-auto mb-2" />
<div class="text-xs text-gray-400">Market</div>
<div class="text-sm font-medium text-white">Price</div>
</div>
</div>
<!-- Trade Offer Notice -->
<div class="p-4 bg-accent-blue/10 border border-accent-blue/30 rounded-lg">
<div class="flex gap-3">
<AlertCircle class="w-5 h-5 text-accent-blue flex-shrink-0 mt-0.5" />
<div class="text-sm">
<div class="font-medium text-white mb-1">Trade Offer Information</div>
<p class="text-gray-400">
After purchase, you will receive a Steam trade offer automatically.
Please make sure your trade URL is set in your profile settings.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Item Not Found -->
<div v-else class="text-center py-20">
<AlertCircle class="w-16 h-16 text-gray-500 mx-auto mb-4" />
<h2 class="text-2xl font-bold text-white mb-2">Item Not Found</h2>
<p class="text-gray-400 mb-6">This item may have been sold or removed.</p>
<button @click="router.push('/market')" class="btn btn-primary">
Browse Market
</button>
</div>
</div>
</div>
</template>
<style scoped>
.item-details-page {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,624 @@
<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">
<h1 class="text-3xl font-bold text-white mb-2">Marketplace</h1>
<p class="text-text-secondary">
Browse and purchase CS2 and Rust skins
</p>
</div>
<div class="flex gap-6">
<!-- Permanent Sidebar Filters -->
<aside class="w-64 flex-shrink-0 space-y-6">
<!-- Search -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Search</h3>
<div class="relative">
<input
v-model="marketStore.filters.search"
@input="handleSearch"
type="text"
placeholder="Search items..."
class="w-full pl-10 pr-4 py-2 bg-surface rounded-lg border border-surface-lighter text-white placeholder-text-secondary focus:outline-none focus:border-primary transition-colors"
/>
<Search
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-text-secondary"
/>
</div>
</div>
<!-- Game Filter -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Game</h3>
<div class="space-y-2">
<button
v-for="game in gameOptions"
:key="game.value"
@click="handleGameChange(game.value)"
class="w-full px-4 py-2 rounded-lg text-sm font-medium transition-colors text-left"
:class="
marketStore.filters.game === game.value
? 'bg-primary text-surface-dark'
: 'bg-surface text-text-secondary hover:bg-surface-lighter hover:text-white'
"
>
{{ game.label }}
</button>
</div>
</div>
<!-- Wear Filter (CS2 only) -->
<div
v-if="marketStore.filters.game === 'cs2'"
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Wear</h3>
<div class="space-y-2">
<button
v-for="wear in wearOptions"
:key="wear.value"
@click="handleWearChange(wear.value)"
class="w-full px-4 py-2 rounded-lg text-sm font-medium transition-colors text-left flex items-center justify-between"
:class="
marketStore.filters.wear === wear.value
? 'bg-primary text-surface-dark'
: 'bg-surface text-text-secondary hover:bg-surface-lighter hover:text-white'
"
>
<span>{{ wear.label }}</span>
<span
v-if="marketStore.filters.wear === wear.value"
class="text-xs"
></span
>
</button>
</div>
</div>
<!-- Rarity Filter (Rust only) -->
<div
v-if="marketStore.filters.game === 'rust'"
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Rarity</h3>
<div class="space-y-2">
<button
v-for="rarity in rarityOptions"
:key="rarity.value"
@click="handleRarityChange(rarity.value)"
class="w-full px-4 py-2 rounded-lg text-sm font-medium transition-colors text-left flex items-center justify-between"
:class="
marketStore.filters.rarity === rarity.value
? 'bg-primary text-surface-dark'
: 'bg-surface text-text-secondary hover:bg-surface-lighter hover:text-white'
"
>
<span>{{ rarity.label }}</span>
<span
v-if="marketStore.filters.rarity === rarity.value"
class="text-xs"
></span
>
</button>
</div>
</div>
<!-- Price Range -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Price Range</h3>
<div class="space-y-3">
<div class="flex gap-2">
<div class="flex-1">
<input
v-model.number="marketStore.filters.minPrice"
type="number"
placeholder="Min"
min="0"
class="w-full px-3 py-2 bg-surface rounded-lg border border-surface-lighter text-white text-sm focus:outline-none focus:border-primary"
/>
</div>
<span class="text-text-secondary self-center">-</span>
<div class="flex-1">
<input
v-model.number="marketStore.filters.maxPrice"
type="number"
placeholder="Max"
min="0"
class="w-full px-3 py-2 bg-surface rounded-lg border border-surface-lighter text-white text-sm focus:outline-none focus:border-primary"
/>
</div>
</div>
<button
@click="applyPriceRange"
class="w-full px-4 py-2 bg-primary hover:bg-primary-dark text-surface-dark font-medium rounded-lg transition-colors text-sm"
>
Apply
</button>
</div>
</div>
<!-- Special Filters (CS2 only) -->
<div
v-if="marketStore.filters.game === 'cs2'"
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<h3 class="text-white font-semibold mb-3">Special</h3>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="marketStore.filters.statTrak"
type="checkbox"
class="w-4 h-4 rounded border-surface-lighter bg-surface text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm text-text-secondary">StatTrak</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
v-model="marketStore.filters.souvenir"
type="checkbox"
class="w-4 h-4 rounded border-surface-lighter bg-surface text-primary focus:ring-primary focus:ring-offset-0"
/>
<span class="text-sm text-text-secondary">Souvenir</span>
</label>
</div>
</div>
<!-- Clear Filters -->
<button
v-if="activeFiltersCount > 0"
@click="clearFilters"
class="w-full px-4 py-2 bg-surface-lighter hover:bg-surface text-text-secondary hover:text-white rounded-lg transition-colors text-sm font-medium flex items-center justify-center gap-2"
>
<X class="w-4 h-4" />
Clear All Filters
</button>
</aside>
<!-- Main Content -->
<div class="flex-1 min-w-0">
<!-- Top Bar -->
<div class="flex items-center justify-between mb-6">
<div class="text-sm text-text-secondary">
<span v-if="!marketStore.loading">
{{ marketStore.items.length }} items found
</span>
</div>
<div class="flex items-center gap-3">
<!-- Sort -->
<select
v-model="marketStore.filters.sort"
@change="handleSortChange"
class="px-4 py-2 bg-surface-light rounded-lg border border-surface-lighter text-white text-sm focus:outline-none focus:border-primary"
>
<option
v-for="option in sortOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<!-- View Mode -->
<div
class="flex items-center bg-surface-light rounded-lg border border-surface-lighter p-1"
>
<button
@click="viewMode = 'grid'"
class="p-2 rounded transition-colors"
:class="
viewMode === 'grid'
? 'bg-primary text-surface-dark'
: 'text-text-secondary hover:text-white'
"
title="Grid View"
>
<Grid3x3 class="w-4 h-4" />
</button>
<button
@click="viewMode = 'list'"
class="p-2 rounded transition-colors"
:class="
viewMode === 'list'
? 'bg-primary text-surface-dark'
: 'text-text-secondary hover:text-white'
"
title="List View"
>
<List class="w-4 h-4" />
</button>
</div>
</div>
</div>
<!-- Loading State -->
<div
v-if="marketStore.isLoading"
class="flex flex-col items-center justify-center py-20"
>
<Loader2 class="w-12 h-12 animate-spin text-primary mb-4" />
<p class="text-text-secondary">Loading items...</p>
</div>
<!-- Empty State -->
<div
v-else-if="marketStore.items.length === 0"
class="text-center py-20"
>
<div
class="inline-flex items-center justify-center w-16 h-16 bg-surface-light rounded-full mb-4"
>
<Search class="w-8 h-8 text-text-secondary" />
</div>
<h3 class="text-xl font-semibold text-white mb-2">
No items found
</h3>
<p class="text-text-secondary mb-6">
Try adjusting your filters or search terms
</p>
<button
@click="clearFilters"
class="px-6 py-3 bg-primary hover:bg-primary-dark text-surface-dark font-semibold rounded-lg transition-colors"
>
Clear Filters
</button>
</div>
<!-- Grid View -->
<div
v-else-if="viewMode === 'grid'"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
<div
v-for="item in marketStore.items"
:key="item._id"
@click="navigateToItem(item._id)"
class="bg-surface-light rounded-lg border border-surface-lighter overflow-hidden hover:border-primary/50 transition-all duration-300 cursor-pointer group"
>
<!-- Image -->
<div class="relative overflow-hidden">
<img
:src="item.image"
:alt="item.name"
class="w-full h-48 object-cover group-hover:scale-110 transition-transform duration-300"
@error="handleImageError"
/>
<div
class="absolute top-2 left-2 px-2 py-1 bg-black/75 rounded text-xs font-medium text-white"
>
{{ item.game.toUpperCase() }}
</div>
<div
v-if="item.featured"
class="absolute top-2 right-2 px-2 py-1 bg-primary/90 rounded text-xs font-bold text-surface-dark"
>
FEATURED
</div>
</div>
<!-- Content -->
<div class="p-4">
<h3
class="text-white font-semibold mb-2 truncate"
:title="item.name"
>
{{ item.name }}
</h3>
<!-- Item Details -->
<div class="flex items-center gap-2 mb-3 text-xs flex-wrap">
<!-- CS2: Show wear (capitalized) -->
<span
v-if="item.game === 'cs2' && item.exterior"
class="px-2 py-1 bg-surface rounded text-text-secondary uppercase font-medium"
>
{{ item.exterior }}
</span>
<!-- Rust: Show rarity -->
<span
v-if="item.game === 'rust' && item.rarity"
class="px-2 py-1 rounded capitalize font-medium"
:style="{
backgroundColor: getRarityColor(item.rarity) + '20',
color: getRarityColor(item.rarity),
}"
>
{{ item.rarity }}
</span>
<span
v-if="item.statTrak"
class="px-2 py-1 bg-primary/20 text-primary rounded font-medium"
>
ST
</span>
</div>
<!-- Price and Button -->
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-text-secondary mb-1">Price</p>
<p class="text-xl font-bold text-primary">
{{ formatPrice(item.price) }}
</p>
</div>
<button
class="px-4 py-2 bg-primary hover:bg-primary-dark text-surface-dark font-medium rounded-lg transition-colors"
>
Buy Now
</button>
</div>
</div>
</div>
</div>
<!-- List View -->
<div v-else-if="viewMode === 'list'" class="space-y-4">
<div
v-for="item in marketStore.items"
:key="item._id"
@click="navigateToItem(item._id)"
class="bg-surface-light rounded-lg border border-surface-lighter overflow-hidden hover:border-primary/50 transition-all duration-300 cursor-pointer flex"
>
<img
:src="item.image"
:alt="item.name"
class="w-48 h-32 object-cover flex-shrink-0"
@error="handleImageError"
/>
<div class="flex-1 p-4 flex items-center justify-between">
<div class="flex-1">
<h3 class="text-white font-semibold mb-1">
{{ item.name }}
</h3>
<div class="flex items-center gap-2 text-xs">
<span class="text-text-secondary">{{
item.game.toUpperCase()
}}</span>
<!-- CS2: Show wear (capitalized) -->
<span
v-if="item.game === 'cs2' && item.exterior"
class="text-text-secondary"
>
{{ item.exterior.toUpperCase() }}
</span>
<!-- Rust: Show rarity -->
<span
v-if="item.game === 'rust' && item.rarity"
class="capitalize"
:style="{ color: getRarityColor(item.rarity) }"
>
{{ item.rarity }}
</span>
<span v-if="item.statTrak" class="text-primary"
> StatTrak</span
>
</div>
</div>
<div class="flex items-center gap-4">
<div class="text-right">
<p class="text-xs text-text-secondary mb-1">Price</p>
<p class="text-xl font-bold text-primary">
{{ formatPrice(item.price) }}
</p>
</div>
<button
class="px-6 py-2 bg-primary hover:bg-primary-dark text-surface-dark font-medium rounded-lg transition-colors"
>
Buy Now
</button>
</div>
</div>
</div>
</div>
<!-- Load More -->
<div
v-if="marketStore.hasMore && !marketStore.loading"
class="mt-8 text-center"
>
<button
@click="marketStore.loadMore"
class="px-8 py-3 bg-surface-light hover:bg-surface-lighter border border-surface-lighter text-white font-medium rounded-lg transition-colors"
>
<Loader2
v-if="marketStore.loadingMore"
class="w-5 h-5 animate-spin inline-block mr-2"
/>
<span v-else>Load More</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useMarketStore } from "@/stores/market";
import { Search, X, Grid3x3, List, Loader2 } from "lucide-vue-next";
const route = useRoute();
const router = useRouter();
const marketStore = useMarketStore();
const viewMode = ref("grid");
const sortOptions = [
{ value: "price_asc", label: "Price: Low to High" },
{ value: "price_desc", label: "Price: High to Low" },
{ value: "name_asc", label: "Name: A-Z" },
{ value: "name_desc", label: "Name: Z-A" },
{ value: "date_new", label: "Newest First" },
{ value: "date_old", label: "Oldest First" },
];
const gameOptions = [
{ value: null, label: "All Games" },
{ value: "cs2", label: "Counter-Strike 2" },
{ value: "rust", label: "Rust" },
];
const wearOptions = [
{ value: null, label: "All Wear" },
{ value: "fn", label: "Factory New" },
{ value: "mw", label: "Minimal Wear" },
{ value: "ft", label: "Field-Tested" },
{ value: "ww", label: "Well-Worn" },
{ value: "bs", label: "Battle-Scarred" },
];
const rarityOptions = [
{ value: null, label: "All Rarities" },
{ value: "common", label: "Common" },
{ value: "uncommon", label: "Uncommon" },
{ value: "rare", label: "Rare" },
{ value: "mythical", label: "Mythical" },
{ value: "legendary", label: "Legendary" },
];
onMounted(async () => {
if (route.query.search) {
marketStore.updateFilter("search", route.query.search);
}
if (route.query.game) {
marketStore.updateFilter("game", route.query.game);
}
if (route.query.category) {
marketStore.updateFilter("category", route.query.category);
}
await marketStore.fetchItems();
});
watch(
() => route.query,
(newQuery) => {
if (newQuery.search) {
marketStore.updateFilter("search", newQuery.search);
}
},
{ deep: true }
);
watch(
() => marketStore.filters,
async () => {
await marketStore.fetchItems();
},
{ deep: true }
);
const activeFiltersCount = computed(() => {
let count = 0;
if (marketStore.filters.game) count++;
if (marketStore.filters.rarity) count++;
if (marketStore.filters.wear) count++;
if (marketStore.filters.category && marketStore.filters.category !== "all")
count++;
if (marketStore.filters.minPrice !== null) count++;
if (marketStore.filters.maxPrice !== null) count++;
if (marketStore.filters.statTrak) count++;
if (marketStore.filters.souvenir) count++;
return count;
});
const handleSearch = () => {
marketStore.fetchItems();
};
const handleRarityChange = (rarityId) => {
if (marketStore.filters.rarity === rarityId) {
marketStore.updateFilter("rarity", null);
} else {
marketStore.updateFilter("rarity", rarityId);
}
};
const handleWearChange = (wearId) => {
if (marketStore.filters.wear === wearId) {
marketStore.updateFilter("wear", null);
} else {
marketStore.updateFilter("wear", wearId);
}
};
const handleGameChange = (gameId) => {
marketStore.updateFilter("game", gameId);
// Clear game-specific filters when switching games
marketStore.updateFilter("wear", null);
marketStore.updateFilter("rarity", null);
marketStore.updateFilter("statTrak", false);
marketStore.updateFilter("souvenir", false);
};
const handleSortChange = () => {
marketStore.fetchItems();
};
const applyPriceRange = () => {
marketStore.fetchItems();
};
const clearFilters = () => {
marketStore.clearFilters();
};
const formatPrice = (price) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(price);
};
const getRarityColor = (rarity) => {
const colors = {
common: "#b0c3d9",
uncommon: "#5e98d9",
rare: "#4b69ff",
mythical: "#8847ff",
legendary: "#d32ce6",
ancient: "#eb4b4b",
exceedingly: "#e4ae39",
};
return colors[rarity?.toLowerCase()] || colors.common;
};
const navigateToItem = (itemId) => {
router.push(`/item/${itemId}`);
};
const handleImageError = (event) => {
event.target.src = "https://via.placeholder.com/400x300?text=No+Image";
};
</script>
<style scoped>
/* Custom scrollbar for sidebar */
aside::-webkit-scrollbar {
width: 6px;
}
aside::-webkit-scrollbar-track {
background: transparent;
}
aside::-webkit-scrollbar-thumb {
background: #1f2a3c;
border-radius: 3px;
}
aside::-webkit-scrollbar-thumb:hover {
background: #2d3748;
}
</style>

View File

@@ -0,0 +1,77 @@
<script setup>
import { useRouter } from 'vue-router'
import { Home, ArrowLeft } from 'lucide-vue-next'
const router = useRouter()
const goHome = () => {
router.push('/')
}
const goBack = () => {
router.back()
}
</script>
<template>
<div class="not-found-page min-h-screen flex items-center justify-center py-12">
<div class="container-custom">
<div class="max-w-2xl mx-auto text-center">
<!-- 404 Animation -->
<div class="relative mb-8">
<div class="text-9xl font-display font-bold text-transparent bg-clip-text bg-gradient-to-r from-primary-500 to-primary-700 animate-pulse-slow">
404
</div>
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-64 h-64 bg-primary-500/10 rounded-full blur-3xl animate-pulse"></div>
</div>
</div>
<!-- Content -->
<h1 class="text-3xl sm:text-4xl font-display font-bold text-white mb-4">
Page Not Found
</h1>
<p class="text-lg text-gray-400 mb-8 max-w-md mx-auto">
The page you're looking for doesn't exist or has been moved.
Let's get you back on track.
</p>
<!-- Actions -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
<button @click="goHome" class="btn btn-primary btn-lg group">
<Home class="w-5 h-5" />
Go to Homepage
</button>
<button @click="goBack" class="btn btn-secondary btn-lg group">
<ArrowLeft class="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
Go Back
</button>
</div>
<!-- Quick Links -->
<div class="mt-12 pt-12 border-t border-surface-lighter">
<p class="text-sm text-gray-500 mb-4">Or try these popular pages:</p>
<div class="flex flex-wrap items-center justify-center gap-4">
<router-link to="/market" class="text-sm text-primary-500 hover:text-primary-400 transition-colors">
Browse Market
</router-link>
<span class="text-gray-600"></span>
<router-link to="/faq" class="text-sm text-primary-500 hover:text-primary-400 transition-colors">
FAQ
</router-link>
<span class="text-gray-600"></span>
<router-link to="/support" class="text-sm text-primary-500 hover:text-primary-400 transition-colors">
Support
</router-link>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.not-found-page {
background: linear-gradient(135deg, rgba(15, 25, 35, 0.95) 0%, rgba(21, 29, 40, 0.98) 50%, rgba(26, 35, 50, 0.95) 100%);
}
</style>

View File

@@ -0,0 +1,48 @@
<script setup>
</script>
<template>
<div class="privacy-page min-h-screen py-8">
<div class="container-custom max-w-4xl">
<h1 class="text-3xl sm:text-4xl font-display font-bold text-white mb-8">Privacy Policy</h1>
<div class="card card-body prose prose-invert max-w-none">
<div class="space-y-6 text-gray-300">
<section>
<h2 class="text-2xl font-semibold text-white mb-4">1. Information We Collect</h2>
<p>We collect information you provide directly to us, including when you create an account, make a purchase, or contact us for support.</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">2. How We Use Your Information</h2>
<p>We use the information we collect to provide, maintain, and improve our services, process transactions, and communicate with you.</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">3. Information Sharing</h2>
<p>We do not sell, trade, or otherwise transfer your personal information to third parties without your consent, except as described in this policy.</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">4. Data Security</h2>
<p>We implement appropriate security measures to protect your personal information from unauthorized access, alteration, disclosure, or destruction.</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">5. Your Rights</h2>
<p>You have the right to access, update, or delete your personal information. Contact us to exercise these rights.</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">6. Contact Us</h2>
<p>If you have any questions about this Privacy Policy, please contact us at <a href="mailto:privacy@turbotrades.com" class="text-primary-500 hover:underline">privacy@turbotrades.com</a></p>
</section>
<div class="text-sm text-gray-500 mt-8 pt-8 border-t border-surface-lighter">
Last updated: {{ new Date().toLocaleDateString() }}
</div>
</div>
</div>
</div>
</div>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { User, Star, TrendingUp, Package } from 'lucide-vue-next'
const route = useRoute()
const user = ref(null)
const isLoading = ref(true)
onMounted(async () => {
// Fetch user profile by steamId
const steamId = route.params.steamId
// TODO: Implement API call
isLoading.value = false
})
</script>
<template>
<div class="public-profile-page min-h-screen py-8">
<div class="container-custom max-w-4xl">
<div class="text-center py-20">
<User class="w-16 h-16 text-gray-500 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-gray-400 mb-2">User Profile</h3>
<p class="text-gray-500">Profile view coming soon</p>
</div>
</div>
</div>
</template>

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>

View File

@@ -0,0 +1,28 @@
<script setup>
import { Mail, MessageCircle } from 'lucide-vue-next'
</script>
<template>
<div class="support-page min-h-screen py-8">
<div class="container-custom max-w-4xl">
<h1 class="text-3xl font-display font-bold text-white mb-4">Support Center</h1>
<p class="text-gray-400 mb-8">Need help? We're here to assist you.</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="card card-body">
<MessageCircle class="w-12 h-12 text-primary-500 mb-4" />
<h3 class="text-xl font-semibold text-white mb-2">Live Chat</h3>
<p class="text-gray-400 mb-4">Chat with our support team in real-time</p>
<button class="btn btn-primary">Start Chat</button>
</div>
<div class="card card-body">
<Mail class="w-12 h-12 text-accent-blue mb-4" />
<h3 class="text-xl font-semibold text-white mb-2">Email Support</h3>
<p class="text-gray-400 mb-4">Send us an email and we'll respond within 24 hours</p>
<a href="mailto:support@turbotrades.com" class="btn btn-secondary">Send Email</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<script setup>
</script>
<template>
<div class="terms-page min-h-screen py-8">
<div class="container-custom max-w-4xl">
<h1 class="text-4xl font-display font-bold text-white mb-8">Terms of Service</h1>
<div class="card card-body space-y-6 text-gray-300">
<section>
<h2 class="text-2xl font-semibold text-white mb-4">1. Acceptance of Terms</h2>
<p class="mb-4">
By accessing and using TurboTrades, you accept and agree to be bound by the terms and provision of this agreement.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">2. Use License</h2>
<p class="mb-4">
Permission is granted to temporarily use TurboTrades for personal, non-commercial transitory viewing only.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">3. User Accounts</h2>
<p class="mb-4">
You are responsible for maintaining the confidentiality of your account and password. You agree to accept responsibility for all activities that occur under your account.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">4. Trading and Transactions</h2>
<p class="mb-4">
All trades are final. We reserve the right to cancel any transaction that we deem suspicious or fraudulent.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">5. Prohibited Activities</h2>
<ul class="list-disc list-inside space-y-2 mb-4">
<li>Using the service for any illegal purpose</li>
<li>Attempting to interfere with the proper working of the service</li>
<li>Using bots or automated tools without permission</li>
<li>Engaging in fraudulent activities</li>
</ul>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">6. Limitation of Liability</h2>
<p class="mb-4">
TurboTrades shall not be liable for any indirect, incidental, special, consequential or punitive damages resulting from your use of the service.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">7. Changes to Terms</h2>
<p class="mb-4">
We reserve the right to modify these terms at any time. Your continued use of the service following any changes indicates your acceptance of the new terms.
</p>
</section>
<section>
<h2 class="text-2xl font-semibold text-white mb-4">8. Contact</h2>
<p>
If you have any questions about these Terms, please contact us at support@turbotrades.com
</p>
</section>
<div class="pt-6 border-t border-surface-lighter text-sm text-gray-500">
Last updated: {{ new Date().toLocaleDateString() }}
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,735 @@
<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">
<h1 class="text-3xl font-bold text-white mb-2">Transaction History</h1>
<p class="text-text-secondary">
View all your deposits, withdrawals, purchases, and sales
</p>
</div>
<!-- Filters -->
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-6 mb-6"
>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-text-secondary mb-2">
Type
</label>
<select
v-model="filters.type"
class="w-full px-4 py-2 bg-surface rounded-lg border border-surface-lighter text-text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">All Types</option>
<option value="deposit">Deposits</option>
<option value="withdrawal">Withdrawals</option>
<option value="purchase">Purchases</option>
<option value="sale">Sales</option>
<option value="trade">Trades</option>
<option value="bonus">Bonuses</option>
<option value="refund">Refunds</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-text-secondary mb-2">
Status
</label>
<select
v-model="filters.status"
class="w-full px-4 py-2 bg-surface rounded-lg border border-surface-lighter text-text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="">All Status</option>
<option value="completed">Completed</option>
<option value="pending">Pending</option>
<option value="processing">Processing</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-text-secondary mb-2">
Date Range
</label>
<select
v-model="filters.dateRange"
class="w-full px-4 py-2 bg-surface rounded-lg border border-surface-lighter text-text-primary focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
>
<option value="all">All Time</option>
<option value="today">Today</option>
<option value="week">Last 7 Days</option>
<option value="month">Last 30 Days</option>
<option value="year">Last Year</option>
</select>
</div>
<div class="flex items-end">
<button @click="resetFilters" class="btn-secondary w-full">
Reset Filters
</button>
</div>
</div>
</div>
<!-- Stats Summary -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<div class="text-sm text-text-secondary mb-1">Total Deposits</div>
<div class="text-2xl font-bold text-success">
{{ formatCurrency(stats.totalDeposits) }}
</div>
<div class="text-xs text-text-secondary mt-1">
{{ stats.depositCount }} transactions
</div>
</div>
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<div class="text-sm text-text-secondary mb-1">Total Withdrawals</div>
<div class="text-2xl font-bold text-danger">
{{ formatCurrency(stats.totalWithdrawals) }}
</div>
<div class="text-xs text-text-secondary mt-1">
{{ stats.withdrawalCount }} transactions
</div>
</div>
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<div class="text-sm text-text-secondary mb-1">Total Spent</div>
<div class="text-2xl font-bold text-warning">
{{ formatCurrency(stats.totalPurchases) }}
</div>
<div class="text-xs text-text-secondary mt-1">
{{ stats.purchaseCount }} purchases
</div>
</div>
<div
class="bg-surface-light rounded-lg border border-surface-lighter p-4"
>
<div class="text-sm text-text-secondary mb-1">Total Earned</div>
<div class="text-2xl font-bold text-success">
{{ formatCurrency(stats.totalSales) }}
</div>
<div class="text-xs text-text-secondary mt-1">
{{ stats.saleCount }} sales
</div>
</div>
</div>
<!-- Transactions List -->
<div class="bg-surface-light rounded-lg border border-surface-lighter">
<div class="p-6 border-b border-surface-lighter">
<h2 class="text-xl font-bold text-white flex items-center gap-2">
<History class="w-6 h-6 text-primary" />
Transactions
</h2>
</div>
<div v-if="loading" class="text-center py-12">
<Loader class="w-8 h-8 animate-spin mx-auto text-primary" />
<p class="text-text-secondary mt-4">Loading transactions...</p>
</div>
<div
v-else-if="filteredTransactions.length === 0"
class="text-center py-12"
>
<History class="w-16 h-16 text-text-secondary/50 mx-auto mb-4" />
<h3 class="text-xl font-semibold text-text-secondary mb-2">
No transactions found
</h3>
<p class="text-text-secondary">
{{
filters.type || filters.status
? "Try changing your filters"
: "Your transaction history will appear here"
}}
</p>
</div>
<div v-else class="divide-y divide-surface-lighter">
<div
v-for="transaction in paginatedTransactions"
:key="transaction.id"
class="p-6 hover:bg-surface/50 transition-colors"
>
<div class="flex items-start justify-between gap-4">
<!-- Left side: Icon/Image, Type, Description -->
<div class="flex items-start gap-4 flex-1 min-w-0">
<!-- Item Image (for purchases/sales) or Icon (for other types) -->
<div
v-if="
transaction.itemImage &&
(transaction.type === 'purchase' ||
transaction.type === 'sale')
"
class="flex-shrink-0"
>
<img
:src="transaction.itemImage"
:alt="transaction.itemName"
class="w-16 h-16 rounded-lg object-cover border-2 border-surface-lighter"
@error="$event.target.style.display = 'none'"
/>
</div>
<!-- Icon (for non-item transactions) -->
<div
v-else
:class="[
'p-3 rounded-lg flex-shrink-0',
getTransactionIconBg(transaction.type),
]"
>
<component
:is="getTransactionIcon(transaction.type)"
:class="[
'w-6 h-6',
getTransactionIconColor(transaction.type),
]"
/>
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<!-- Title and Status -->
<div class="flex items-center gap-2 flex-wrap mb-1">
<h3 class="text-white font-semibold">
{{ getTransactionTitle(transaction) }}
</h3>
<span
:class="[
'text-xs px-2 py-0.5 rounded-full font-medium',
getStatusClass(transaction.status),
]"
>
{{ transaction.status }}
</span>
</div>
<!-- Description -->
<p class="text-sm text-text-secondary mb-2">
{{
transaction.description ||
getTransactionDescription(transaction)
}}
</p>
<!-- Meta Information -->
<div
class="flex flex-wrap items-center gap-3 text-xs text-text-secondary"
>
<span class="flex items-center gap-1">
<Calendar class="w-3 h-3" />
{{ formatDate(transaction.createdAt) }}
</span>
<span
v-if="transaction.sessionIdShort"
class="flex items-center gap-1"
>
<Monitor class="w-3 h-3" />
Session:
<span
:style="{
backgroundColor: getSessionColor(
transaction.sessionIdShort
),
}"
class="px-2 py-0.5 rounded text-white font-mono text-[10px]"
:title="`Session ID: ${transaction.sessionIdShort}`"
>
{{ transaction.sessionIdShort }}
</span>
</span>
</div>
</div>
</div>
<!-- Right side: Amount -->
<div class="text-right flex-shrink-0">
<div
:class="[
'text-xl font-bold',
transaction.direction === '+'
? 'text-success'
: 'text-danger',
]"
>
{{ transaction.direction
}}{{ formatCurrency(transaction.amount) }}
</div>
<div
v-if="transaction.fee > 0"
class="text-xs text-text-secondary mt-1"
>
Fee: {{ formatCurrency(transaction.fee) }}
</div>
</div>
</div>
<!-- Additional Details (expandable) -->
<div
v-if="expandedTransaction === transaction.id"
class="mt-4 pt-4 border-t border-surface-lighter"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span class="text-text-secondary">Transaction ID:</span>
<span class="text-white ml-2 font-mono text-xs">{{
transaction.id
}}</span>
</div>
<div v-if="transaction.balanceBefore !== undefined">
<span class="text-text-secondary">Balance Before:</span>
<span class="text-white ml-2">{{
formatCurrency(transaction.balanceBefore)
}}</span>
</div>
<div v-if="transaction.balanceAfter !== undefined">
<span class="text-text-secondary">Balance After:</span>
<span class="text-white ml-2">{{
formatCurrency(transaction.balanceAfter)
}}</span>
</div>
<div v-if="transaction.paymentMethod">
<span class="text-text-secondary">Payment Method:</span>
<span class="text-white ml-2 capitalize">{{
transaction.paymentMethod
}}</span>
</div>
</div>
</div>
<!-- Toggle Details Button -->
<button
@click="toggleExpanded(transaction.id)"
class="mt-3 text-xs text-primary hover:text-primary-hover flex items-center gap-1"
>
<ChevronDown
:class="[
'w-4 h-4 transition-transform',
expandedTransaction === transaction.id ? 'rotate-180' : '',
]"
/>
{{ expandedTransaction === transaction.id ? "Hide" : "Show" }}
Details
</button>
</div>
</div>
<!-- Pagination -->
<div
v-if="filteredTransactions.length > perPage"
class="p-6 border-t border-surface-lighter"
>
<div class="flex items-center justify-between flex-wrap gap-4">
<!-- Page info -->
<div class="text-sm text-text-secondary">
Showing {{ (currentPage - 1) * perPage + 1 }} to
{{ Math.min(currentPage * perPage, filteredTransactions.length) }}
of {{ filteredTransactions.length }} transactions
</div>
<!-- Page controls -->
<div class="flex items-center gap-2">
<!-- Previous button -->
<button
@click="prevPage"
:disabled="!hasPrevPage"
class="px-3 py-2 rounded-lg border border-surface-lighter bg-surface text-text-primary hover:bg-surface-light disabled:opacity-50 disabled:cursor-not-allowed transition-all"
title="Previous page"
>
<ChevronLeft class="w-4 h-4" />
</button>
<!-- Page numbers -->
<div class="flex gap-1">
<!-- First page -->
<button
v-if="currentPage > 3"
@click="goToPage(1)"
class="px-3 py-2 rounded-lg border border-surface-lighter bg-surface text-text-primary hover:bg-surface-light transition-all"
>
1
</button>
<span
v-if="currentPage > 3"
class="px-2 py-2 text-text-secondary"
>
...
</span>
<!-- Nearby pages -->
<button
v-for="page in [
currentPage - 1,
currentPage,
currentPage + 1,
].filter((p) => p >= 1 && p <= totalPages)"
:key="page"
@click="goToPage(page)"
:class="[
'px-3 py-2 rounded-lg border transition-all',
page === currentPage
? 'bg-primary border-primary text-white font-semibold'
: 'border-surface-lighter bg-surface text-text-primary hover:bg-surface-light',
]"
>
{{ page }}
</button>
<!-- Last page -->
<span
v-if="currentPage < totalPages - 2"
class="px-2 py-2 text-text-secondary"
>
...
</span>
<button
v-if="currentPage < totalPages - 2"
@click="goToPage(totalPages)"
class="px-3 py-2 rounded-lg border border-surface-lighter bg-surface text-text-primary hover:bg-surface-light transition-all"
>
{{ totalPages }}
</button>
</div>
<!-- Next button -->
<button
@click="nextPage"
:disabled="!hasNextPage"
class="px-3 py-2 rounded-lg border border-surface-lighter bg-surface text-text-primary hover:bg-surface-light disabled:opacity-50 disabled:cursor-not-allowed transition-all"
title="Next page"
>
<ChevronRight class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useAuthStore } from "@/stores/auth";
import axios from "@/utils/axios";
import { useToast } from "vue-toastification";
import {
History,
Loader,
Calendar,
Monitor,
Smartphone,
Tablet,
Laptop,
ChevronDown,
ChevronLeft,
ChevronRight,
ArrowDownCircle,
ArrowUpCircle,
ShoppingCart,
Tag,
RefreshCw,
Gift,
DollarSign,
} from "lucide-vue-next";
const authStore = useAuthStore();
const toast = useToast();
// State
const transactions = ref([]);
const loading = ref(false);
const currentPage = ref(1);
const perPage = ref(10);
const totalTransactions = ref(0);
const expandedTransaction = ref(null);
const filters = ref({
type: "",
status: "",
dateRange: "all",
});
const stats = ref({
totalDeposits: 0,
totalWithdrawals: 0,
totalPurchases: 0,
totalSales: 0,
depositCount: 0,
withdrawalCount: 0,
purchaseCount: 0,
saleCount: 0,
});
// Computed
const filteredTransactions = computed(() => {
let filtered = [...transactions.value];
if (filters.value.type) {
filtered = filtered.filter((t) => t.type === filters.value.type);
}
if (filters.value.status) {
filtered = filtered.filter((t) => t.status === filters.value.status);
}
if (filters.value.dateRange !== "all") {
const now = new Date();
const filterDate = new Date();
switch (filters.value.dateRange) {
case "today":
filterDate.setHours(0, 0, 0, 0);
break;
case "week":
filterDate.setDate(now.getDate() - 7);
break;
case "month":
filterDate.setDate(now.getDate() - 30);
break;
case "year":
filterDate.setFullYear(now.getFullYear() - 1);
break;
}
filtered = filtered.filter((t) => new Date(t.createdAt) >= filterDate);
}
return filtered;
});
// Paginated transactions
const paginatedTransactions = computed(() => {
const start = (currentPage.value - 1) * perPage.value;
const end = start + perPage.value;
return filteredTransactions.value.slice(start, end);
});
const totalPages = computed(() => {
return Math.ceil(filteredTransactions.value.length / perPage.value);
});
const hasNextPage = computed(() => {
return currentPage.value < totalPages.value;
});
const hasPrevPage = computed(() => {
return currentPage.value > 1;
});
// Methods
const fetchTransactions = async () => {
loading.value = true;
try {
console.log("🔄 Fetching transactions...");
const response = await axios.get("/api/user/transactions", {
withCredentials: true,
params: {
limit: 1000, // Fetch all, we'll paginate on frontend
},
});
console.log("✅ Transaction response:", response.data);
if (response.data.success) {
transactions.value = response.data.transactions;
totalTransactions.value = response.data.transactions.length;
stats.value = response.data.stats || stats.value;
console.log(`📊 Loaded ${transactions.value.length} transactions`);
console.log("Stats:", stats.value);
} else {
console.warn("⚠️ Response success is false");
}
} catch (error) {
console.error("❌ Failed to fetch transactions:", error);
console.error("Response:", error.response?.data);
console.error("Status:", error.response?.status);
if (error.response?.status !== 404) {
toast.error("Failed to load transactions");
}
} finally {
loading.value = false;
}
};
const nextPage = () => {
if (hasNextPage.value) {
currentPage.value++;
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
const prevPage = () => {
if (hasPrevPage.value) {
currentPage.value--;
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page;
window.scrollTo({ top: 0, behavior: "smooth" });
}
};
const resetFilters = () => {
filters.value = {
type: "",
status: "",
dateRange: "all",
};
currentPage.value = 1;
};
// Watch for filter changes and reset to page 1
watch(
[
() => filters.value.type,
() => filters.value.status,
() => filters.value.dateRange,
],
() => {
currentPage.value = 1;
}
);
const toggleExpanded = (id) => {
expandedTransaction.value = expandedTransaction.value === id ? null : id;
};
// Helper functions
const formatCurrency = (amount) => {
if (amount === undefined || amount === null) return "$0.00";
return `$${Math.abs(amount).toFixed(2)}`;
};
const formatDate = (date) => {
if (!date) return "N/A";
const d = new Date(date);
const now = new Date();
const diff = now - d;
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (hours < 1) return "Just now";
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString();
};
const getTransactionIcon = (type) => {
const icons = {
deposit: ArrowDownCircle,
withdrawal: ArrowUpCircle,
purchase: ShoppingCart,
sale: Tag,
trade: RefreshCw,
bonus: Gift,
refund: DollarSign,
};
return icons[type] || DollarSign;
};
const getTransactionIconColor = (type) => {
const colors = {
deposit: "text-success",
withdrawal: "text-danger",
purchase: "text-primary",
sale: "text-warning",
trade: "text-info",
bonus: "text-success",
refund: "text-warning",
};
return colors[type] || "text-text-secondary";
};
const getTransactionIconBg = (type) => {
const backgrounds = {
deposit: "bg-success/20",
withdrawal: "bg-danger/20",
purchase: "bg-primary/20",
sale: "bg-warning/20",
trade: "bg-info/20",
bonus: "bg-success/20",
refund: "bg-warning/20",
};
return backgrounds[type] || "bg-surface-lighter";
};
const getTransactionTitle = (transaction) => {
const titles = {
deposit: "Deposit",
withdrawal: "Withdrawal",
purchase: "Item Purchase",
sale: "Item Sale",
trade: "Trade",
bonus: "Bonus",
refund: "Refund",
};
return titles[transaction.type] || "Transaction";
};
const getTransactionDescription = (transaction) => {
if (transaction.itemName) {
return `${transaction.type === "purchase" ? "Purchased" : "Sold"} ${
transaction.itemName
}`;
}
return `${
transaction.type.charAt(0).toUpperCase() + transaction.type.slice(1)
} of ${formatCurrency(transaction.amount)}`;
};
const getStatusClass = (status) => {
const classes = {
completed: "bg-success/20 text-success",
pending: "bg-warning/20 text-warning",
processing: "bg-info/20 text-info",
failed: "bg-danger/20 text-danger",
cancelled: "bg-text-secondary/20 text-text-secondary",
};
return classes[status] || "bg-surface-lighter text-text-secondary";
};
const getDeviceIcon = (device) => {
const icons = {
Mobile: Smartphone,
Tablet: Tablet,
Desktop: Laptop,
};
return icons[device] || Laptop;
};
const getSessionColor = (sessionIdShort) => {
if (!sessionIdShort) return "#64748b";
let hash = 0;
for (let i = 0; i < sessionIdShort.length; i++) {
hash = sessionIdShort.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
const saturation = 60 + (Math.abs(hash) % 20);
const lightness = 45 + (Math.abs(hash) % 15);
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
};
// Lifecycle
onMounted(() => {
fetchTransactions();
});
</script>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { DollarSign, AlertCircle } from 'lucide-vue-next'
const authStore = useAuthStore()
const amount = ref(0)
const method = ref('paypal')
</script>
<template>
<div class="withdraw-page min-h-screen py-8">
<div class="container-custom max-w-2xl">
<h1 class="text-3xl font-display font-bold text-white mb-2">Withdraw Funds</h1>
<p class="text-gray-400 mb-8">Withdraw your balance to your preferred payment method</p>
<div class="card card-body space-y-6">
<!-- Available Balance -->
<div class="p-4 bg-surface-light rounded-lg">
<div class="text-sm text-gray-400 mb-1">Available Balance</div>
<div class="text-3xl font-bold text-primary-500">
{{ new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(authStore.balance) }}
</div>
</div>
<!-- Withdrawal Amount -->
<div class="input-group">
<label class="input-label">Withdrawal Amount</label>
<div class="relative">
<DollarSign class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-500" />
<input
v-model.number="amount"
type="number"
placeholder="0.00"
class="input pl-10"
min="5"
:max="authStore.balance"
step="0.01"
/>
</div>
<p class="input-hint">Minimum withdrawal: $5.00</p>
</div>
<!-- Payment Method -->
<div class="input-group">
<label class="input-label">Payment Method</label>
<select v-model="method" class="input">
<option value="paypal">PayPal</option>
<option value="bank">Bank Transfer</option>
<option value="crypto">Cryptocurrency</option>
</select>
</div>
<!-- Notice -->
<div class="p-4 bg-accent-blue/10 border border-accent-blue/30 rounded-lg">
<div class="flex gap-3">
<AlertCircle class="w-5 h-5 text-accent-blue flex-shrink-0 mt-0.5" />
<div class="text-sm">
<div class="font-medium text-white mb-1">Processing Time</div>
<p class="text-gray-400">
Withdrawals are typically processed within 24-48 hours. You'll receive an email confirmation once your withdrawal is complete.
</p>
</div>
</div>
</div>
<!-- Submit Button -->
<button
class="btn btn-primary w-full btn-lg"
:disabled="amount < 5 || amount > authStore.balance"
>
Request Withdrawal
</button>
</div>
</div>
</div>
</template>