first commit
This commit is contained in:
1115
frontend/src/views/AdminPage.vue
Normal file
1115
frontend/src/views/AdminPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
118
frontend/src/views/DepositPage.vue
Normal file
118
frontend/src/views/DepositPage.vue
Normal 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>
|
||||
457
frontend/src/views/DiagnosticPage.vue
Normal file
457
frontend/src/views/DiagnosticPage.vue
Normal 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>
|
||||
127
frontend/src/views/FAQPage.vue
Normal file
127
frontend/src/views/FAQPage.vue
Normal 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>
|
||||
419
frontend/src/views/HomePage.vue
Normal file
419
frontend/src/views/HomePage.vue
Normal 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>
|
||||
21
frontend/src/views/InventoryPage.vue
Normal file
21
frontend/src/views/InventoryPage.vue
Normal 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>
|
||||
304
frontend/src/views/ItemDetailsPage.vue
Normal file
304
frontend/src/views/ItemDetailsPage.vue
Normal 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>
|
||||
624
frontend/src/views/MarketPage.vue
Normal file
624
frontend/src/views/MarketPage.vue
Normal 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>
|
||||
77
frontend/src/views/NotFoundPage.vue
Normal file
77
frontend/src/views/NotFoundPage.vue
Normal 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>
|
||||
48
frontend/src/views/PrivacyPage.vue
Normal file
48
frontend/src/views/PrivacyPage.vue
Normal 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>
|
||||
1250
frontend/src/views/ProfilePage.vue
Normal file
1250
frontend/src/views/ProfilePage.vue
Normal file
File diff suppressed because it is too large
Load Diff
28
frontend/src/views/PublicProfilePage.vue
Normal file
28
frontend/src/views/PublicProfilePage.vue
Normal 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>
|
||||
727
frontend/src/views/SellPage.vue
Normal file
727
frontend/src/views/SellPage.vue
Normal 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>
|
||||
28
frontend/src/views/SupportPage.vue
Normal file
28
frontend/src/views/SupportPage.vue
Normal 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>
|
||||
75
frontend/src/views/TermsPage.vue
Normal file
75
frontend/src/views/TermsPage.vue
Normal 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>
|
||||
735
frontend/src/views/TransactionsPage.vue
Normal file
735
frontend/src/views/TransactionsPage.vue
Normal 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>
|
||||
77
frontend/src/views/WithdrawPage.vue
Normal file
77
frontend/src/views/WithdrawPage.vue
Normal 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>
|
||||
Reference in New Issue
Block a user