first commit
This commit is contained in:
260
frontend/src/stores/auth.js
Normal file
260
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,260 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// State
|
||||
const user = ref(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const isInitialized = ref(false)
|
||||
|
||||
// Computed
|
||||
const username = computed(() => user.value?.username || 'Guest')
|
||||
const steamId = computed(() => user.value?.steamId || null)
|
||||
const avatar = computed(() => user.value?.avatar || null)
|
||||
const balance = computed(() => user.value?.balance || 0)
|
||||
const staffLevel = computed(() => user.value?.staffLevel || 0)
|
||||
const isStaff = computed(() => staffLevel.value > 0)
|
||||
const isModerator = computed(() => staffLevel.value >= 2)
|
||||
const isAdmin = computed(() => staffLevel.value >= 3)
|
||||
const tradeUrl = computed(() => user.value?.tradeUrl || null)
|
||||
const email = computed(() => user.value?.email?.address || null)
|
||||
const emailVerified = computed(() => user.value?.email?.verified || false)
|
||||
const isBanned = computed(() => user.value?.ban?.banned || false)
|
||||
const banReason = computed(() => user.value?.ban?.reason || null)
|
||||
const twoFactorEnabled = computed(() => user.value?.twoFactor?.enabled || false)
|
||||
|
||||
// Actions
|
||||
const setUser = (userData) => {
|
||||
user.value = userData
|
||||
isAuthenticated.value = !!userData
|
||||
}
|
||||
|
||||
const clearUser = () => {
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
}
|
||||
|
||||
const fetchUser = async () => {
|
||||
if (isLoading.value) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.get('/api/auth/me', {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
if (response.data.success && response.data.user) {
|
||||
setUser(response.data.user)
|
||||
return response.data.user
|
||||
} else {
|
||||
clearUser()
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user:', error)
|
||||
clearUser()
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isInitialized.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const login = () => {
|
||||
// Redirect to Steam login
|
||||
window.location.href = '/api/auth/steam'
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
await axios.post('/api/auth/logout', {}, {
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
clearUser()
|
||||
toast.success('Successfully logged out')
|
||||
|
||||
// Redirect to home page
|
||||
if (window.location.pathname !== '/') {
|
||||
window.location.href = '/'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
toast.error('Failed to logout')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshToken = async () => {
|
||||
try {
|
||||
await axios.post('/api/auth/refresh', {}, {
|
||||
withCredentials: true,
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error)
|
||||
clearUser()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const updateTradeUrl = async (tradeUrl) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.patch('/api/user/trade-url',
|
||||
{ tradeUrl },
|
||||
{ withCredentials: true }
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
user.value.tradeUrl = tradeUrl
|
||||
toast.success('Trade URL updated successfully')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to update trade URL:', error)
|
||||
toast.error(error.response?.data?.message || 'Failed to update trade URL')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateEmail = async (email) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.patch('/api/user/email',
|
||||
{ email },
|
||||
{ withCredentials: true }
|
||||
)
|
||||
|
||||
if (response.data.success) {
|
||||
user.value.email = { address: email, verified: false }
|
||||
toast.success('Email updated! Check your inbox for verification link')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to update email:', error)
|
||||
toast.error(error.response?.data?.message || 'Failed to update email')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const verifyEmail = async (token) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const response = await axios.get(`/api/user/verify-email/${token}`, {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Email verified successfully!')
|
||||
await fetchUser() // Refresh user data
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to verify email:', error)
|
||||
toast.error(error.response?.data?.message || 'Failed to verify email')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getUserStats = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/user/stats', {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.stats
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getBalance = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/user/balance', {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
user.value.balance = response.data.balance
|
||||
return response.data.balance
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch balance:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const updateBalance = (newBalance) => {
|
||||
if (user.value) {
|
||||
user.value.balance = newBalance
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on store creation
|
||||
const initialize = async () => {
|
||||
if (!isInitialized.value) {
|
||||
await fetchUser()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
|
||||
// Computed
|
||||
username,
|
||||
steamId,
|
||||
avatar,
|
||||
balance,
|
||||
staffLevel,
|
||||
isStaff,
|
||||
isModerator,
|
||||
isAdmin,
|
||||
tradeUrl,
|
||||
email,
|
||||
emailVerified,
|
||||
isBanned,
|
||||
banReason,
|
||||
twoFactorEnabled,
|
||||
|
||||
// Actions
|
||||
setUser,
|
||||
clearUser,
|
||||
fetchUser,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
updateTradeUrl,
|
||||
updateEmail,
|
||||
verifyEmail,
|
||||
getUserStats,
|
||||
getBalance,
|
||||
updateBalance,
|
||||
initialize,
|
||||
}
|
||||
})
|
||||
452
frontend/src/stores/market.js
Normal file
452
frontend/src/stores/market.js
Normal file
@@ -0,0 +1,452 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import axios from 'axios'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useWebSocketStore } from './websocket'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
export const useMarketStore = defineStore('market', () => {
|
||||
// State
|
||||
const items = ref([])
|
||||
const featuredItems = ref([])
|
||||
const recentSales = ref([])
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const totalItems = ref(0)
|
||||
const itemsPerPage = ref(24)
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
search: '',
|
||||
game: null, // 'cs2', 'rust', null for all
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
rarity: null,
|
||||
wear: null,
|
||||
category: null,
|
||||
sortBy: 'price_asc', // price_asc, price_desc, name_asc, name_desc, date_new, date_old
|
||||
statTrak: null,
|
||||
souvenir: null,
|
||||
})
|
||||
|
||||
// Categories
|
||||
const categories = ref([
|
||||
{ id: 'all', name: 'All Items', icon: 'Grid' },
|
||||
{ id: 'rifles', name: 'Rifles', icon: 'Crosshair' },
|
||||
{ id: 'pistols', name: 'Pistols', icon: 'Target' },
|
||||
{ id: 'knives', name: 'Knives', icon: 'Sword' },
|
||||
{ id: 'gloves', name: 'Gloves', icon: 'Hand' },
|
||||
{ id: 'stickers', name: 'Stickers', icon: 'Sticker' },
|
||||
{ id: 'cases', name: 'Cases', icon: 'Package' },
|
||||
])
|
||||
|
||||
// Rarities
|
||||
const rarities = ref([
|
||||
{ id: 'common', name: 'Consumer Grade', color: '#b0c3d9' },
|
||||
{ id: 'uncommon', name: 'Industrial Grade', color: '#5e98d9' },
|
||||
{ id: 'rare', name: 'Mil-Spec', color: '#4b69ff' },
|
||||
{ id: 'mythical', name: 'Restricted', color: '#8847ff' },
|
||||
{ id: 'legendary', name: 'Classified', color: '#d32ce6' },
|
||||
{ id: 'ancient', name: 'Covert', color: '#eb4b4b' },
|
||||
{ id: 'exceedingly', name: 'Contraband', color: '#e4ae39' },
|
||||
])
|
||||
|
||||
// Wear conditions
|
||||
const wearConditions = ref([
|
||||
{ id: 'fn', name: 'Factory New', abbr: 'FN' },
|
||||
{ id: 'mw', name: 'Minimal Wear', abbr: 'MW' },
|
||||
{ id: 'ft', name: 'Field-Tested', abbr: 'FT' },
|
||||
{ id: 'ww', name: 'Well-Worn', abbr: 'WW' },
|
||||
{ id: 'bs', name: 'Battle-Scarred', abbr: 'BS' },
|
||||
])
|
||||
|
||||
// Computed
|
||||
const filteredItems = computed(() => {
|
||||
let result = [...items.value]
|
||||
|
||||
if (filters.value.search) {
|
||||
const searchTerm = filters.value.search.toLowerCase()
|
||||
result = result.filter(item =>
|
||||
item.name.toLowerCase().includes(searchTerm) ||
|
||||
item.description?.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.value.game) {
|
||||
result = result.filter(item => item.game === filters.value.game)
|
||||
}
|
||||
|
||||
if (filters.value.minPrice !== null) {
|
||||
result = result.filter(item => item.price >= filters.value.minPrice)
|
||||
}
|
||||
|
||||
if (filters.value.maxPrice !== null) {
|
||||
result = result.filter(item => item.price <= filters.value.maxPrice)
|
||||
}
|
||||
|
||||
if (filters.value.rarity) {
|
||||
result = result.filter(item => item.rarity === filters.value.rarity)
|
||||
}
|
||||
|
||||
if (filters.value.wear) {
|
||||
result = result.filter(item => item.wear === filters.value.wear)
|
||||
}
|
||||
|
||||
if (filters.value.category && filters.value.category !== 'all') {
|
||||
result = result.filter(item => item.category === filters.value.category)
|
||||
}
|
||||
|
||||
if (filters.value.statTrak !== null) {
|
||||
result = result.filter(item => item.statTrak === filters.value.statTrak)
|
||||
}
|
||||
|
||||
if (filters.value.souvenir !== null) {
|
||||
result = result.filter(item => item.souvenir === filters.value.souvenir)
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
switch (filters.value.sortBy) {
|
||||
case 'price_asc':
|
||||
result.sort((a, b) => a.price - b.price)
|
||||
break
|
||||
case 'price_desc':
|
||||
result.sort((a, b) => b.price - a.price)
|
||||
break
|
||||
case 'name_asc':
|
||||
result.sort((a, b) => a.name.localeCompare(b.name))
|
||||
break
|
||||
case 'name_desc':
|
||||
result.sort((a, b) => b.name.localeCompare(a.name))
|
||||
break
|
||||
case 'date_new':
|
||||
result.sort((a, b) => new Date(b.listedAt) - new Date(a.listedAt))
|
||||
break
|
||||
case 'date_old':
|
||||
result.sort((a, b) => new Date(a.listedAt) - new Date(b.listedAt))
|
||||
break
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const hasMore = computed(() => currentPage.value < totalPages.value)
|
||||
|
||||
// Actions
|
||||
const fetchItems = async (page = 1, append = false) => {
|
||||
if (!append) {
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page,
|
||||
limit: itemsPerPage.value,
|
||||
...filters.value,
|
||||
}
|
||||
|
||||
const response = await axios.get('/api/market/items', { params })
|
||||
|
||||
if (response.data.success) {
|
||||
const newItems = response.data.items || []
|
||||
|
||||
if (append) {
|
||||
items.value = [...items.value, ...newItems]
|
||||
} else {
|
||||
items.value = newItems
|
||||
}
|
||||
|
||||
currentPage.value = response.data.page || page
|
||||
totalPages.value = response.data.totalPages || 1
|
||||
totalItems.value = response.data.total || 0
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch items:', error)
|
||||
toast.error('Failed to load marketplace items')
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = async () => {
|
||||
if (hasMore.value && !isLoadingMore.value) {
|
||||
await fetchItems(currentPage.value + 1, true)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchFeaturedItems = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/market/featured')
|
||||
|
||||
if (response.data.success) {
|
||||
featuredItems.value = response.data.items || []
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch featured items:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRecentSales = async (limit = 10) => {
|
||||
try {
|
||||
const response = await axios.get('/api/market/recent-sales', {
|
||||
params: { limit }
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
recentSales.value = response.data.sales || []
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recent sales:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const getItemById = async (itemId) => {
|
||||
try {
|
||||
const response = await axios.get(`/api/market/items/${itemId}`)
|
||||
|
||||
if (response.data.success) {
|
||||
return response.data.item
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch item:', error)
|
||||
toast.error('Failed to load item details')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseItem = async (itemId) => {
|
||||
try {
|
||||
const response = await axios.post(`/api/market/purchase/${itemId}`, {}, {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Item purchased successfully!')
|
||||
|
||||
// Remove item from local state
|
||||
items.value = items.value.filter(item => item.id !== itemId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to purchase item:', error)
|
||||
const message = error.response?.data?.message || 'Failed to purchase item'
|
||||
toast.error(message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const listItem = async (itemData) => {
|
||||
try {
|
||||
const response = await axios.post('/api/market/list', itemData, {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Item listed successfully!')
|
||||
|
||||
// Add item to local state
|
||||
if (response.data.item) {
|
||||
items.value.unshift(response.data.item)
|
||||
}
|
||||
|
||||
return response.data.item
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Failed to list item:', error)
|
||||
const message = error.response?.data?.message || 'Failed to list item'
|
||||
toast.error(message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const updateListing = async (itemId, updates) => {
|
||||
try {
|
||||
const response = await axios.patch(`/api/market/listing/${itemId}`, updates, {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Listing updated successfully!')
|
||||
|
||||
// Update item in local state
|
||||
const index = items.value.findIndex(item => item.id === itemId)
|
||||
if (index !== -1 && response.data.item) {
|
||||
items.value[index] = response.data.item
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to update listing:', error)
|
||||
const message = error.response?.data?.message || 'Failed to update listing'
|
||||
toast.error(message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const removeListing = async (itemId) => {
|
||||
try {
|
||||
const response = await axios.delete(`/api/market/listing/${itemId}`, {
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Listing removed successfully!')
|
||||
|
||||
// Remove item from local state
|
||||
items.value = items.value.filter(item => item.id !== itemId)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Failed to remove listing:', error)
|
||||
const message = error.response?.data?.message || 'Failed to remove listing'
|
||||
toast.error(message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const updateFilter = (key, value) => {
|
||||
filters.value[key] = value
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
filters.value = {
|
||||
search: '',
|
||||
game: null,
|
||||
minPrice: null,
|
||||
maxPrice: null,
|
||||
rarity: null,
|
||||
wear: null,
|
||||
category: null,
|
||||
sortBy: 'price_asc',
|
||||
statTrak: null,
|
||||
souvenir: null,
|
||||
}
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
const updateItemPrice = (itemId, newPrice) => {
|
||||
const item = items.value.find(i => i.id === itemId)
|
||||
if (item) {
|
||||
item.price = newPrice
|
||||
}
|
||||
|
||||
const featuredItem = featuredItems.value.find(i => i.id === itemId)
|
||||
if (featuredItem) {
|
||||
featuredItem.price = newPrice
|
||||
}
|
||||
}
|
||||
|
||||
const removeItem = (itemId) => {
|
||||
items.value = items.value.filter(item => item.id !== itemId)
|
||||
featuredItems.value = featuredItems.value.filter(item => item.id !== itemId)
|
||||
}
|
||||
|
||||
const addItem = (item) => {
|
||||
items.value.unshift(item)
|
||||
totalItems.value++
|
||||
}
|
||||
|
||||
// WebSocket integration
|
||||
const setupWebSocketListeners = () => {
|
||||
const wsStore = useWebSocketStore()
|
||||
|
||||
wsStore.on('listing_update', (data) => {
|
||||
if (data?.itemId && data?.price) {
|
||||
updateItemPrice(data.itemId, data.price)
|
||||
}
|
||||
})
|
||||
|
||||
wsStore.on('listing_removed', (data) => {
|
||||
if (data?.itemId) {
|
||||
removeItem(data.itemId)
|
||||
}
|
||||
})
|
||||
|
||||
wsStore.on('listing_added', (data) => {
|
||||
if (data?.item) {
|
||||
addItem(data.item)
|
||||
}
|
||||
})
|
||||
|
||||
wsStore.on('price_update', (data) => {
|
||||
if (data?.itemId && data?.newPrice) {
|
||||
updateItemPrice(data.itemId, data.newPrice)
|
||||
}
|
||||
})
|
||||
|
||||
wsStore.on('market_update', (data) => {
|
||||
// Handle bulk market updates
|
||||
console.log('Market update received:', data)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
featuredItems,
|
||||
recentSales,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
filters,
|
||||
categories,
|
||||
rarities,
|
||||
wearConditions,
|
||||
|
||||
// Computed
|
||||
filteredItems,
|
||||
hasMore,
|
||||
|
||||
// Actions
|
||||
fetchItems,
|
||||
loadMore,
|
||||
fetchFeaturedItems,
|
||||
fetchRecentSales,
|
||||
getItemById,
|
||||
purchaseItem,
|
||||
listItem,
|
||||
updateListing,
|
||||
removeListing,
|
||||
updateFilter,
|
||||
resetFilters,
|
||||
updateItemPrice,
|
||||
removeItem,
|
||||
addItem,
|
||||
setupWebSocketListeners,
|
||||
}
|
||||
})
|
||||
341
frontend/src/stores/websocket.js
Normal file
341
frontend/src/stores/websocket.js
Normal file
@@ -0,0 +1,341 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAuthStore } from './auth'
|
||||
import { useToast } from 'vue-toastification'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
export const useWebSocketStore = defineStore('websocket', () => {
|
||||
// State
|
||||
const ws = ref(null)
|
||||
const isConnected = ref(false)
|
||||
const isConnecting = ref(false)
|
||||
const reconnectAttempts = ref(0)
|
||||
const maxReconnectAttempts = ref(5)
|
||||
const reconnectDelay = ref(1000)
|
||||
const heartbeatInterval = ref(null)
|
||||
const reconnectTimeout = ref(null)
|
||||
const messageQueue = ref([])
|
||||
const listeners = ref(new Map())
|
||||
|
||||
// Computed
|
||||
const connectionStatus = computed(() => {
|
||||
if (isConnected.value) return 'connected'
|
||||
if (isConnecting.value) return 'connecting'
|
||||
return 'disconnected'
|
||||
})
|
||||
|
||||
const canReconnect = computed(() => {
|
||||
return reconnectAttempts.value < maxReconnectAttempts.value
|
||||
})
|
||||
|
||||
// Helper functions
|
||||
const getWebSocketUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const host = window.location.host
|
||||
|
||||
// In development, use the proxy
|
||||
if (import.meta.env.DEV) {
|
||||
return `ws://localhost:3000/ws`
|
||||
}
|
||||
|
||||
return `${protocol}//${host}/ws`
|
||||
}
|
||||
|
||||
const clearHeartbeat = () => {
|
||||
if (heartbeatInterval.value) {
|
||||
clearInterval(heartbeatInterval.value)
|
||||
heartbeatInterval.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearReconnectTimeout = () => {
|
||||
if (reconnectTimeout.value) {
|
||||
clearTimeout(reconnectTimeout.value)
|
||||
reconnectTimeout.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const startHeartbeat = () => {
|
||||
clearHeartbeat()
|
||||
|
||||
// Send ping every 30 seconds
|
||||
heartbeatInterval.value = setInterval(() => {
|
||||
if (isConnected.value && ws.value?.readyState === WebSocket.OPEN) {
|
||||
send({ type: 'ping' })
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
// Actions
|
||||
const connect = () => {
|
||||
if (ws.value?.readyState === WebSocket.OPEN || isConnecting.value) {
|
||||
console.log('WebSocket already connected or connecting')
|
||||
return
|
||||
}
|
||||
|
||||
isConnecting.value = true
|
||||
clearReconnectTimeout()
|
||||
|
||||
try {
|
||||
const wsUrl = getWebSocketUrl()
|
||||
console.log('Connecting to WebSocket:', wsUrl)
|
||||
|
||||
ws.value = new WebSocket(wsUrl)
|
||||
|
||||
ws.value.onopen = () => {
|
||||
console.log('WebSocket connected')
|
||||
isConnected.value = true
|
||||
isConnecting.value = false
|
||||
reconnectAttempts.value = 0
|
||||
|
||||
startHeartbeat()
|
||||
|
||||
// Send queued messages
|
||||
while (messageQueue.value.length > 0) {
|
||||
const message = messageQueue.value.shift()
|
||||
send(message)
|
||||
}
|
||||
|
||||
// Emit connected event
|
||||
emit('connected', { timestamp: Date.now() })
|
||||
}
|
||||
|
||||
ws.value.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('WebSocket message received:', data)
|
||||
|
||||
handleMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.value.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
ws.value.onclose = (event) => {
|
||||
console.log('WebSocket closed:', event.code, event.reason)
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
clearHeartbeat()
|
||||
|
||||
// Emit disconnected event
|
||||
emit('disconnected', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Attempt to reconnect
|
||||
if (!event.wasClean && canReconnect.value) {
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create WebSocket connection:', error)
|
||||
isConnecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
clearHeartbeat()
|
||||
clearReconnectTimeout()
|
||||
reconnectAttempts.value = maxReconnectAttempts.value // Prevent auto-reconnect
|
||||
|
||||
if (ws.value) {
|
||||
ws.value.close(1000, 'Client disconnect')
|
||||
ws.value = null
|
||||
}
|
||||
|
||||
isConnected.value = false
|
||||
isConnecting.value = false
|
||||
}
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (!canReconnect.value) {
|
||||
console.log('Max reconnect attempts reached')
|
||||
toast.error('Lost connection to server. Please refresh the page.')
|
||||
return
|
||||
}
|
||||
|
||||
reconnectAttempts.value++
|
||||
const delay = reconnectDelay.value * Math.pow(2, reconnectAttempts.value - 1)
|
||||
|
||||
console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttempts.value}/${maxReconnectAttempts.value})`)
|
||||
|
||||
clearReconnectTimeout()
|
||||
reconnectTimeout.value = setTimeout(() => {
|
||||
connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
const send = (message) => {
|
||||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WebSocket not connected, queueing message:', message)
|
||||
messageQueue.value.push(message)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = typeof message === 'string' ? message : JSON.stringify(message)
|
||||
ws.value.send(payload)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to send WebSocket message:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleMessage = (data) => {
|
||||
const { type, data: payload, timestamp } = data
|
||||
|
||||
switch (type) {
|
||||
case 'connected':
|
||||
console.log('Server confirmed connection:', payload)
|
||||
break
|
||||
|
||||
case 'pong':
|
||||
// Heartbeat response
|
||||
break
|
||||
|
||||
case 'notification':
|
||||
if (payload?.message) {
|
||||
toast.info(payload.message)
|
||||
}
|
||||
break
|
||||
|
||||
case 'balance_update':
|
||||
// Update user balance
|
||||
const authStore = useAuthStore()
|
||||
if (payload?.balance !== undefined) {
|
||||
authStore.updateBalance(payload.balance)
|
||||
}
|
||||
break
|
||||
|
||||
case 'item_sold':
|
||||
toast.success(`Your item "${payload?.itemName || 'item'}" has been sold!`)
|
||||
break
|
||||
|
||||
case 'item_purchased':
|
||||
toast.success(`Successfully purchased "${payload?.itemName || 'item'}"!`)
|
||||
break
|
||||
|
||||
case 'trade_status':
|
||||
if (payload?.status === 'completed') {
|
||||
toast.success('Trade completed successfully!')
|
||||
} else if (payload?.status === 'failed') {
|
||||
toast.error(`Trade failed: ${payload?.reason || 'Unknown error'}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'price_update':
|
||||
case 'listing_update':
|
||||
case 'market_update':
|
||||
// These will be handled by listeners
|
||||
break
|
||||
|
||||
case 'announcement':
|
||||
if (payload?.message) {
|
||||
toast.warning(payload.message, { timeout: 10000 })
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
console.error('Server error:', payload)
|
||||
if (payload?.message) {
|
||||
toast.error(payload.message)
|
||||
}
|
||||
break
|
||||
|
||||
default:
|
||||
console.log('Unhandled message type:', type)
|
||||
}
|
||||
|
||||
// Emit to listeners
|
||||
emit(type, payload)
|
||||
}
|
||||
|
||||
const on = (event, callback) => {
|
||||
if (!listeners.value.has(event)) {
|
||||
listeners.value.set(event, [])
|
||||
}
|
||||
listeners.value.get(event).push(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => off(event, callback)
|
||||
}
|
||||
|
||||
const off = (event, callback) => {
|
||||
if (!listeners.value.has(event)) return
|
||||
|
||||
const callbacks = listeners.value.get(event)
|
||||
const index = callbacks.indexOf(callback)
|
||||
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1)
|
||||
}
|
||||
|
||||
if (callbacks.length === 0) {
|
||||
listeners.value.delete(event)
|
||||
}
|
||||
}
|
||||
|
||||
const emit = (event, data) => {
|
||||
if (!listeners.value.has(event)) return
|
||||
|
||||
const callbacks = listeners.value.get(event)
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data)
|
||||
} catch (error) {
|
||||
console.error(`Error in event listener for "${event}":`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const once = (event, callback) => {
|
||||
const wrappedCallback = (data) => {
|
||||
callback(data)
|
||||
off(event, wrappedCallback)
|
||||
}
|
||||
return on(event, wrappedCallback)
|
||||
}
|
||||
|
||||
const clearListeners = () => {
|
||||
listeners.value.clear()
|
||||
}
|
||||
|
||||
// Ping the server
|
||||
const ping = () => {
|
||||
send({ type: 'ping' })
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
ws,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
reconnectAttempts,
|
||||
maxReconnectAttempts,
|
||||
messageQueue,
|
||||
|
||||
// Computed
|
||||
connectionStatus,
|
||||
canReconnect,
|
||||
|
||||
// Actions
|
||||
connect,
|
||||
disconnect,
|
||||
send,
|
||||
on,
|
||||
off,
|
||||
once,
|
||||
emit,
|
||||
clearListeners,
|
||||
ping,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user