Compare commits
19 Commits
bfa028c630
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ae3fbe0261 | |||
| 5da4c0f34f | |||
| d736e9c69f | |||
| 15e3eb316a | |||
| b05594fe3d | |||
| 0f7f1ae5dd | |||
| 87b290032c | |||
| af5c32561a | |||
| cbf50b0641 | |||
| 5848323140 | |||
| 2aff879291 | |||
| d794c5ad48 | |||
| 5846541329 | |||
| aca0aca310 | |||
| 02d9727a72 | |||
| 7a32454b83 | |||
| b686acee8f | |||
| 1f62e148e5 | |||
| ac72c6ad27 |
9
.env
9
.env
@@ -23,6 +23,7 @@ STEAM_RETURN_URL=https://api.turbotrades.dev/auth/steam/return
|
||||
|
||||
#Steam apis key - Loading the inventory
|
||||
STEAM_APIS_KEY=DONTABUSEORPEPZWILLNAGASAKI
|
||||
ENABLE_PRICE_UPDATES="true"
|
||||
|
||||
# Cookie Settings
|
||||
COOKIE_DOMAIN=.turbotrades.dev
|
||||
@@ -41,11 +42,11 @@ RATE_LIMIT_TIMEWINDOW=60000
|
||||
BYPASS_BOT_REQUIREMENT=true
|
||||
|
||||
# Email Configuration (for future implementation)
|
||||
SMTP_HOST=smtp.example.com
|
||||
SMTP_HOST=mail.privateemail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@example.com
|
||||
SMTP_PASS=your-email-password
|
||||
EMAIL_FROM=noreply@turbotrades.com
|
||||
SMTP_USER=spam@turbotrades.gg
|
||||
SMTP_PASS=20yBBj!0
|
||||
EMAIL_FROM=noreply@turbotrades.gg
|
||||
|
||||
# WebSocket
|
||||
WS_PING_INTERVAL=30000
|
||||
|
||||
277
BAN_NOTIFICATION_FIX.md
Normal file
277
BAN_NOTIFICATION_FIX.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Ban Notification Feature - Deployment Guide
|
||||
|
||||
## Overview
|
||||
Fixed the issue where banned users only saw a toast notification instead of being redirected to the banned page. Now when a user is banned from the admin panel, they are **immediately redirected** via WebSocket notification.
|
||||
|
||||
## Problem
|
||||
When an admin banned a user:
|
||||
1. User's account was updated in database ✅
|
||||
2. User saw a toast notification ❌ (not helpful)
|
||||
3. User could continue using the site until page refresh ❌
|
||||
4. `/api/auth/me` returned 403, preventing frontend from getting ban info ❌
|
||||
|
||||
## Solution
|
||||
### 1. Real-time WebSocket Notifications
|
||||
- Backend sends `account_banned` event to user's active WebSocket connection
|
||||
- Frontend receives event and refreshes auth state
|
||||
- User is immediately redirected to `/banned` page
|
||||
|
||||
### 2. Allow Banned Users to Access `/api/auth/me`
|
||||
- Modified `middleware/auth.js` to allow banned users to access `/api/auth/me`
|
||||
- This lets the frontend fetch ban details and redirect properly
|
||||
- All other endpoints remain blocked for banned users
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Backend (`routes/admin-management.js`)
|
||||
```javascript
|
||||
// Added WebSocket import
|
||||
import wsManager from "../utils/websocket.js";
|
||||
|
||||
// In ban handler, after saving user:
|
||||
if (wsManager.isUserConnected(user.steamId)) {
|
||||
if (banned) {
|
||||
wsManager.sendToUser(user.steamId, {
|
||||
type: "account_banned",
|
||||
data: {
|
||||
banned: true,
|
||||
reason: user.ban.reason,
|
||||
bannedAt: new Date(),
|
||||
bannedUntil: user.ban.expires,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} else {
|
||||
wsManager.sendToUser(user.steamId, {
|
||||
type: "account_unbanned",
|
||||
data: { banned: false },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Backend (`middleware/auth.js`)
|
||||
```javascript
|
||||
// Allow banned users to access /api/auth/me
|
||||
const url = request.url || "";
|
||||
const routeUrl = request.routeOptions?.url || "";
|
||||
const isAuthMeEndpoint =
|
||||
url.includes("/auth/me") ||
|
||||
routeUrl === "/me" ||
|
||||
routeUrl.endsWith("/me");
|
||||
|
||||
if (!isAuthMeEndpoint) {
|
||||
// Block access to all other endpoints
|
||||
return reply.status(403).send({ /* ban error */ });
|
||||
}
|
||||
// If it's /api/auth/me, continue and attach user with ban info
|
||||
```
|
||||
|
||||
### Frontend (`frontend/src/stores/websocket.js`)
|
||||
```javascript
|
||||
case "account_banned":
|
||||
console.log("🔨 User account has been banned:", payload);
|
||||
// Update the auth store - router guard will handle redirect
|
||||
authStore.fetchUser().then(() => {
|
||||
// Disconnect WebSocket since user is banned
|
||||
disconnect();
|
||||
// The router guard will automatically redirect to /banned page
|
||||
window.location.href = "/banned";
|
||||
});
|
||||
break;
|
||||
|
||||
case "account_unbanned":
|
||||
console.log("✅ User account has been unbanned:", payload);
|
||||
// Update the auth store to reflect unbanned status
|
||||
authStore.fetchUser().then(() => {
|
||||
window.location.href = "/";
|
||||
toast.success("Your account has been unbanned. Welcome back!");
|
||||
});
|
||||
break;
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### Ban Flow
|
||||
1. **Admin bans user** → Clicks "Ban" button in admin panel
|
||||
2. **Backend saves ban** → Updates User record in MongoDB
|
||||
3. **WebSocket notification** → Server sends `account_banned` event
|
||||
4. **Frontend receives** → WebSocket store handles the event
|
||||
5. **Auth refresh** → Calls `/api/auth/me` (now allowed for banned users)
|
||||
6. **Router guard** → Sees `authStore.isBanned = true`
|
||||
7. **Redirect** → User sent to `/banned` page immediately
|
||||
|
||||
### Unban Flow
|
||||
1. **Admin unbans user** → Clicks "Unban" button
|
||||
2. **Backend clears ban** → Removes ban from User record
|
||||
3. **WebSocket notification** → Server sends `account_unbanned` event
|
||||
4. **Frontend receives** → Refreshes auth and redirects to home
|
||||
5. **Success toast** → "Your account has been unbanned. Welcome back!"
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Ban Notification
|
||||
1. Open browser window as User A (logged in)
|
||||
2. Open another window as Admin
|
||||
3. Admin bans User A from admin panel
|
||||
4. User A should **immediately** be redirected to `/banned` page
|
||||
5. `/banned` page shows:
|
||||
- Account suspended message
|
||||
- Ban reason
|
||||
- Ban duration (or "permanent")
|
||||
- Contact support button
|
||||
- Logout button
|
||||
|
||||
### Test Unban Notification
|
||||
1. User A is on `/banned` page
|
||||
2. Admin unbans User A
|
||||
3. User A should **immediately** be redirected to home page
|
||||
4. Success toast appears
|
||||
|
||||
### Test Without WebSocket
|
||||
1. User A is logged in but WebSocket disconnected
|
||||
2. Admin bans User A
|
||||
3. User A continues using site temporarily
|
||||
4. On next page navigation, router guard catches ban
|
||||
5. User A redirected to `/banned` page
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Deploy Backend
|
||||
```bash
|
||||
# SSH to production server
|
||||
ssh user@api.turbotrades.dev
|
||||
|
||||
# Navigate to project
|
||||
cd /path/to/TurboTrades
|
||||
|
||||
# Pull latest changes (if using git)
|
||||
git pull
|
||||
|
||||
# Or manually upload files:
|
||||
# - routes/admin-management.js
|
||||
# - middleware/auth.js
|
||||
|
||||
# Restart backend
|
||||
pm2 restart turbotrades-backend
|
||||
|
||||
# Verify backend is running
|
||||
pm2 logs turbotrades-backend --lines 50
|
||||
```
|
||||
|
||||
### 2. Deploy Frontend
|
||||
```bash
|
||||
# On your local machine (Windows)
|
||||
cd C:\Users\dg-ho\Documents\projects\TurboTrades\frontend
|
||||
npm run build
|
||||
|
||||
# On production server
|
||||
cd /path/to/TurboTrades/frontend
|
||||
npm run build
|
||||
sudo cp -r dist/* /var/www/html/turbotrades/
|
||||
|
||||
# Verify files copied
|
||||
ls -la /var/www/html/turbotrades/
|
||||
```
|
||||
|
||||
### 3. Test in Production
|
||||
```bash
|
||||
# Check backend logs for WebSocket connections
|
||||
pm2 logs turbotrades-backend --lines 100 | grep WebSocket
|
||||
|
||||
# Test ban flow
|
||||
curl -X POST https://api.turbotrades.dev/api/admin/users/{userId}/ban \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: accessToken=YOUR_ADMIN_TOKEN" \
|
||||
-d '{"banned": true, "reason": "Test ban", "duration": 1}'
|
||||
```
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Backend
|
||||
- ✅ `routes/admin-management.js` - Added WebSocket ban notifications
|
||||
- ✅ `middleware/auth.js` - Allow banned users to access `/api/auth/me`
|
||||
|
||||
### Frontend
|
||||
- ✅ `frontend/src/stores/websocket.js` - Handle ban/unban events
|
||||
- ✅ `frontend/src/views/BannedPage.vue` - Already exists (no changes needed)
|
||||
- ✅ `frontend/src/router/index.js` - Router guard already exists (no changes needed)
|
||||
|
||||
## Logs to Watch
|
||||
|
||||
### Backend
|
||||
```
|
||||
📡 Sent ban notification to user {username}
|
||||
📡 Sent unban notification to user {username}
|
||||
🔨 Admin {admin} banned user {user} (Reason: {reason})
|
||||
```
|
||||
|
||||
### Frontend Console
|
||||
```
|
||||
🔨 User account has been banned: {banInfo}
|
||||
✅ User account has been unbanned: {unbanInfo}
|
||||
🔵 Calling authStore.fetchUser() from WebSocket connected handler
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### User not redirected after ban
|
||||
1. Check if WebSocket is connected: `wsManager.isUserConnected(steamId)`
|
||||
2. Check browser console for WebSocket messages
|
||||
3. Verify `/api/auth/me` returns 200 with ban info (not 403)
|
||||
|
||||
### 403 Error on `/api/auth/me`
|
||||
1. Check `middleware/auth.js` deployed correctly
|
||||
2. Verify URL matching logic: `url.includes("/auth/me")`
|
||||
3. Backend logs should show: `✓ User authenticated: {username}`
|
||||
|
||||
### Ban page not showing
|
||||
1. Verify `authStore.isBanned` is true: Check console
|
||||
2. Check router guard in `frontend/src/router/index.js`
|
||||
3. Clear browser cache / hard refresh (Ctrl+Shift+R)
|
||||
|
||||
### WebSocket not connected
|
||||
1. Check WebSocket URL: `wss://api.turbotrades.dev/ws`
|
||||
2. Verify Nginx WebSocket proxy configuration
|
||||
3. Check backend WebSocket server is running
|
||||
|
||||
## Security Notes
|
||||
|
||||
✅ **Banned users can only access `/api/auth/me`**
|
||||
- All other endpoints return 403
|
||||
- Prevents banned users from trading, depositing, etc.
|
||||
|
||||
✅ **Admins cannot ban other admins**
|
||||
- Check in ban handler: `user.staffLevel >= 3`
|
||||
|
||||
✅ **WebSocket notifications only sent to connected users**
|
||||
- Offline users will see ban on next page load via router guard
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [ ] Add ban notification to email (optional)
|
||||
- [ ] Log ban actions for audit trail (already logged to console)
|
||||
- [ ] Add ban appeal form on `/banned` page (optional)
|
||||
- [ ] Add bulk ban WebSocket notifications (if implementing bulk ban)
|
||||
|
||||
## Related Files
|
||||
- `frontend/src/views/BannedPage.vue` - The banned page UI
|
||||
- `frontend/src/router/index.js` - Router guard for ban redirect
|
||||
- `frontend/src/stores/auth.js` - Auth store with `isBanned` computed
|
||||
- `routes/admin-management.js` - Admin ban/unban endpoints
|
||||
- `middleware/auth.js` - Authentication middleware
|
||||
- `utils/websocket.js` - WebSocket manager
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] Backend code updated
|
||||
- [ ] Frontend code updated
|
||||
- [ ] Frontend built (`npm run build`)
|
||||
- [ ] Backend restarted (`pm2 restart`)
|
||||
- [ ] Frontend deployed to web root
|
||||
- [ ] Tested ban flow (admin bans user → user redirected)
|
||||
- [ ] Tested unban flow (admin unbans → user redirected)
|
||||
- [ ] Verified `/api/auth/me` works for banned users
|
||||
- [ ] Checked browser console for errors
|
||||
- [ ] Checked backend logs for WebSocket messages
|
||||
288
FINAL_DEPLOY_STEPS.md
Normal file
288
FINAL_DEPLOY_STEPS.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 🚀 Final Deployment Steps - Steam Login & Navbar Fix
|
||||
|
||||
## 📋 What Was Fixed
|
||||
|
||||
1. **WebSocket Auth Sync**: WebSocket now triggers auth state refresh when connected
|
||||
2. **Axios Configuration**: Fixed to use correct custom axios instance with proper baseURL
|
||||
3. **API URL Detection**: Smart detection of API domain in production vs development
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Deploy to Production Server
|
||||
|
||||
### Step 1: SSH into Your Server
|
||||
|
||||
```bash
|
||||
ssh your-user@your-server
|
||||
```
|
||||
|
||||
### Step 2: Navigate to Project & Pull Changes
|
||||
|
||||
```bash
|
||||
cd /path/to/TurboTrades
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
### Step 3: Build Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install # only if package.json changed
|
||||
npm run build
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
✓ 1588 modules transformed.
|
||||
✓ built in 2.84s
|
||||
```
|
||||
|
||||
### Step 4: Deploy to Web Root
|
||||
|
||||
```bash
|
||||
# Copy built files to your web server directory
|
||||
sudo cp -r dist/* /var/www/html/turbotrades/
|
||||
|
||||
# Verify files were copied
|
||||
ls -la /var/www/html/turbotrades/
|
||||
```
|
||||
|
||||
### Step 5: Set Yourself as Admin
|
||||
|
||||
```bash
|
||||
# Go back to project root
|
||||
cd /path/to/TurboTrades
|
||||
|
||||
# Run the make-admin script
|
||||
node make-admin.js
|
||||
```
|
||||
|
||||
**Expected output:**
|
||||
```
|
||||
✅ Connected to MongoDB
|
||||
✅ Found user: ✅ Ashley アシュリー
|
||||
Current staff level: 0
|
||||
✅ Successfully updated user to admin (staffLevel: 3)
|
||||
🎉 Done! Please restart your backend server.
|
||||
```
|
||||
|
||||
### Step 6: Restart Backend (if needed)
|
||||
|
||||
```bash
|
||||
pm2 restart turbotrades-backend
|
||||
|
||||
# Check logs
|
||||
pm2 logs turbotrades-backend --lines 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test the Deployment
|
||||
|
||||
### 1. Clear Browser Cache
|
||||
|
||||
- **Chrome/Edge**: `Ctrl + Shift + Delete` → Clear all browsing data
|
||||
- **Firefox**: `Ctrl + Shift + Delete` → Clear everything
|
||||
- **Or use Incognito/Private mode**
|
||||
|
||||
### 2. Open Site & Console
|
||||
|
||||
1. Navigate to `https://turbotrades.dev`
|
||||
2. Press `F12` to open DevTools
|
||||
3. Go to **Console** tab
|
||||
|
||||
### 3. Clear Site Data
|
||||
|
||||
In DevTools:
|
||||
- Go to **Application** tab
|
||||
- Click **Clear storage**
|
||||
- Click **Clear site data**
|
||||
|
||||
### 4. Check API Base URL
|
||||
|
||||
In the console, you should see:
|
||||
```
|
||||
🔵 Production API baseURL: https://api.turbotrades.dev
|
||||
```
|
||||
|
||||
This confirms it's using the correct API domain.
|
||||
|
||||
### 5. Test Login Flow
|
||||
|
||||
1. Click **"Login to Steam"** button
|
||||
2. Complete Steam authentication
|
||||
3. You'll be redirected back to the site
|
||||
|
||||
### 6. Watch Console Logs
|
||||
|
||||
You should see this sequence:
|
||||
|
||||
```
|
||||
🔵 Auth store initialize called - isInitialized: false
|
||||
🔵 fetchUser called - fetching user from /api/auth/me
|
||||
✅ fetchUser response: { success: true, user: {...} }
|
||||
✅ Setting user in auth store: { steamId: "...", username: "...", ... }
|
||||
🔵 fetchUser complete - isAuthenticated: true
|
||||
|
||||
Connecting to WebSocket: wss://api.turbotrades.dev/ws
|
||||
WebSocket connected
|
||||
🟢 WebSocket received 'connected' message: { ... }
|
||||
🔵 Calling authStore.fetchUser() from WebSocket connected handler
|
||||
🔵 fetchUser called - fetching user from /api/auth/me
|
||||
✅ fetchUser response: { success: true, user: {...} }
|
||||
✅ Setting user in auth store: { ... }
|
||||
🔵 fetchUser complete - isAuthenticated: true
|
||||
```
|
||||
|
||||
### 7. Verify Navbar Updates
|
||||
|
||||
The navbar should now show:
|
||||
- ✅ Your Steam avatar
|
||||
- ✅ Your username
|
||||
- ✅ Your balance with green deposit button
|
||||
- ✅ User dropdown menu with Profile, Inventory, Transactions, Withdraw
|
||||
- ✅ **Admin** option (with yellow background) since you're admin
|
||||
|
||||
### 8. Test User API Endpoints
|
||||
|
||||
Click on **Profile** and verify:
|
||||
- User stats load correctly
|
||||
- No console errors about `/user/stats` 404
|
||||
|
||||
### 9. Test Admin Access
|
||||
|
||||
Click on **Admin** in the dropdown menu:
|
||||
- You should be able to access the admin dashboard
|
||||
- No permission errors
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Site loads without errors
|
||||
- [ ] Console shows: `🔵 Production API baseURL: https://api.turbotrades.dev`
|
||||
- [ ] Login button works
|
||||
- [ ] After login, navbar shows logged-in state
|
||||
- [ ] Avatar, username, and balance display correctly
|
||||
- [ ] User dropdown menu works
|
||||
- [ ] Profile page loads user stats
|
||||
- [ ] Admin panel is accessible
|
||||
- [ ] WebSocket connects successfully
|
||||
- [ ] No 404 errors for `/api/user/*` routes
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Navbar Not Updating After Login
|
||||
|
||||
**Check console for errors:**
|
||||
```
|
||||
# If you see wrong API URL:
|
||||
🔵 Development API baseURL: /api
|
||||
```
|
||||
|
||||
**Solution:** Hard refresh with `Ctrl + Shift + R` to clear cached JS
|
||||
|
||||
---
|
||||
|
||||
### 404 Errors on /api/user/stats
|
||||
|
||||
**Check console:**
|
||||
```
|
||||
Failed to fetch user stats: 404
|
||||
```
|
||||
|
||||
**Solution:** This should be fixed now, but if you still see it:
|
||||
1. Verify backend is running: `pm2 status`
|
||||
2. Check backend logs: `pm2 logs turbotrades-backend`
|
||||
3. Verify routes are registered: `curl https://api.turbotrades.dev/api/health`
|
||||
|
||||
---
|
||||
|
||||
### WebSocket Not Connecting
|
||||
|
||||
**Check console:**
|
||||
```
|
||||
WebSocket error: ...
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check Nginx config
|
||||
sudo cat /etc/nginx/sites-available/turbotrades-api | grep -A 5 "location /ws"
|
||||
|
||||
# Should have:
|
||||
location /ws {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_pass http://127.0.0.1:3000/ws;
|
||||
}
|
||||
|
||||
# Reload Nginx if changed
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Admin Panel Shows 403 Forbidden
|
||||
|
||||
**Check if admin was set correctly:**
|
||||
```bash
|
||||
# In your project directory
|
||||
node -e "
|
||||
const mongoose = require('mongoose');
|
||||
mongoose.connect('mongodb://localhost:27017/turbotrades').then(async () => {
|
||||
const User = mongoose.model('User', new mongoose.Schema({steamId: String, username: String, staffLevel: Number}));
|
||||
const user = await User.findOne({steamId: '76561198027608071'});
|
||||
console.log('User:', user.username, 'Staff Level:', user.staffLevel);
|
||||
process.exit();
|
||||
});
|
||||
"
|
||||
```
|
||||
|
||||
**Should output:**
|
||||
```
|
||||
User: ✅ Ashley アシュリー Staff Level: 3
|
||||
```
|
||||
|
||||
If not 3, run `node make-admin.js` again.
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files Changed
|
||||
|
||||
- `frontend/src/stores/websocket.js` - Added auth fetch on WebSocket connect
|
||||
- `frontend/src/stores/auth.js` - Use custom axios instance, correct paths
|
||||
- `frontend/src/utils/axios.js` - Smart API URL detection for production
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Rollback (If Needed)
|
||||
|
||||
```bash
|
||||
cd /path/to/TurboTrades
|
||||
|
||||
# Revert the last 3 commits
|
||||
git revert HEAD~3..HEAD
|
||||
git push origin main
|
||||
|
||||
# Rebuild and redeploy
|
||||
cd frontend
|
||||
npm run build
|
||||
sudo cp -r dist/* /var/www/html/turbotrades/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 You're Done!
|
||||
|
||||
After completing all steps:
|
||||
1. ✅ Steam login works
|
||||
2. ✅ Navbar updates after login
|
||||
3. ✅ User API routes work
|
||||
4. ✅ WebSocket connects and syncs auth
|
||||
5. ✅ Admin panel is accessible
|
||||
|
||||
**Enjoy your fully functional TurboTrades platform!** 🚀
|
||||
395
PRICING_SYSTEM_COMPLETE.md
Normal file
395
PRICING_SYSTEM_COMPLETE.md
Normal file
@@ -0,0 +1,395 @@
|
||||
# TurboTrades Pricing & Payout System
|
||||
|
||||
## 📊 System Overview
|
||||
|
||||
TurboTrades has **two separate pricing systems** for different purposes:
|
||||
|
||||
### 1. **Marketplace** (User-to-User)
|
||||
- Users list items at their own prices
|
||||
- Other users buy from listings
|
||||
- Site takes a commission (configurable in admin panel)
|
||||
- Prices set by sellers
|
||||
|
||||
### 2. **Instant Sell** (User-to-Site)
|
||||
- Site **buys items directly** from users
|
||||
- Instant payout to user balance
|
||||
- Price = Market Price × Payout Rate (e.g., 60%)
|
||||
- Admin configures payout percentage
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Database Structure
|
||||
|
||||
### MarketPrice Collection (Reference Database)
|
||||
- **Purpose**: Reference prices for ALL Steam market items
|
||||
- **Updated**: Every 1 hour automatically
|
||||
- **Source**: SteamAPIs.com market data
|
||||
- **Used for**: Instant sell page pricing
|
||||
- **Contents**: ~30,000+ items (CS2 + Rust)
|
||||
|
||||
### Item Collection (Marketplace Listings)
|
||||
- **Purpose**: User-listed items for sale
|
||||
- **Updated**: Every 1 hour (optional, for listed items)
|
||||
- **Source**: User-set prices or suggested from MarketPrice
|
||||
- **Used for**: Marketplace page
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ How Instant Sell Works
|
||||
|
||||
### User Experience:
|
||||
1. User goes to `/sell` page
|
||||
2. Selects items from their Steam inventory
|
||||
3. Sees **instant buy price** = Market Price × Payout Rate
|
||||
4. Accepts offer
|
||||
5. Site buys items, credits balance immediately
|
||||
|
||||
### Backend Flow:
|
||||
1. **Fetch Inventory** (`GET /api/inventory/steam`)
|
||||
- Gets user's Steam inventory
|
||||
- Looks up each item in `MarketPrice` database
|
||||
- Applies payout rate (e.g., 60%)
|
||||
- Returns items with `marketPrice` (already discounted)
|
||||
|
||||
2. **Create Trade** (`POST /api/inventory/trade`)
|
||||
- Validates items and prices
|
||||
- Creates trade offer via Steam bot
|
||||
- User accepts in Steam
|
||||
- Balance credited on completion
|
||||
|
||||
### Example Calculation:
|
||||
```
|
||||
Market Price (MarketPrice DB): $10.00
|
||||
Payout Rate (Admin Config): 60%
|
||||
User Receives: $6.00
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Automatic Price Updates
|
||||
|
||||
### On Server Startup:
|
||||
```javascript
|
||||
// Runs immediately when server starts
|
||||
pricingService.updateAllPrices()
|
||||
→ Updates MarketPrice database (CS2 + Rust)
|
||||
→ Updates Item prices (marketplace listings)
|
||||
```
|
||||
|
||||
### Every Hour:
|
||||
```javascript
|
||||
// Scheduled via setInterval (60 minutes)
|
||||
pricingService.scheduleUpdates(60 * 60 * 1000)
|
||||
→ updateMarketPriceDatabase('cs2') // ~20,000 items
|
||||
→ updateMarketPriceDatabase('rust') // ~10,000 items
|
||||
→ updateDatabasePrices('cs2') // Marketplace items
|
||||
→ updateDatabasePrices('rust') // Marketplace items
|
||||
```
|
||||
|
||||
### What Gets Updated:
|
||||
- ✅ **MarketPrice**: ALL Steam market items
|
||||
- ✅ **Item**: Only items listed on marketplace
|
||||
- ⏱️ **Duration**: ~2-5 minutes per update
|
||||
- 🔑 **Requires**: `STEAM_APIS_KEY` in `.env`
|
||||
|
||||
---
|
||||
|
||||
## 🎛️ Admin Configuration
|
||||
|
||||
### Instant Sell Settings
|
||||
|
||||
**Endpoint:** `PATCH /api/admin/config/instantsell`
|
||||
|
||||
**Settings Available:**
|
||||
```json
|
||||
{
|
||||
"enabled": true, // Enable/disable instant sell
|
||||
"payoutRate": 0.6, // 60% default payout
|
||||
"minItemValue": 0.1, // Min $0.10
|
||||
"maxItemValue": 10000, // Max $10,000
|
||||
"cs2": {
|
||||
"enabled": true,
|
||||
"payoutRate": 0.65 // CS2-specific override (65%)
|
||||
},
|
||||
"rust": {
|
||||
"enabled": true,
|
||||
"payoutRate": 0.55 // Rust-specific override (55%)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setting Payout Rates
|
||||
|
||||
**Example: Set 65% payout for CS2, 55% for Rust**
|
||||
```bash
|
||||
curl -X PATCH https://api.turbotrades.dev/api/admin/config/instantsell \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: accessToken=YOUR_TOKEN" \
|
||||
-d '{
|
||||
"cs2": {
|
||||
"payoutRate": 0.65
|
||||
},
|
||||
"rust": {
|
||||
"payoutRate": 0.55
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Example: Disable instant sell for Rust**
|
||||
```bash
|
||||
curl -X PATCH https://api.turbotrades.dev/api/admin/config/instantsell \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Cookie: accessToken=YOUR_TOKEN" \
|
||||
-d '{
|
||||
"rust": {
|
||||
"enabled": false
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Initial Setup
|
||||
|
||||
### 1. Set API Key
|
||||
```bash
|
||||
# In your .env file
|
||||
STEAM_APIS_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
Get API key from: https://steamapis.com/
|
||||
|
||||
### 2. Import Initial Prices (One-Time)
|
||||
```bash
|
||||
# Populates MarketPrice database
|
||||
node import-market-prices.js
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
📡 Fetching CS2 market data...
|
||||
✅ Received 20,147 items from API
|
||||
💾 Importing CS2 items to database...
|
||||
✅ CS2 import complete: 20,147 inserted
|
||||
|
||||
📡 Fetching Rust market data...
|
||||
✅ Received 9,823 items from API
|
||||
💾 Importing Rust items to database...
|
||||
✅ Rust import complete: 9,823 inserted
|
||||
|
||||
🎉 Total: 29,970 items imported
|
||||
```
|
||||
|
||||
### 3. Configure Payout Rate
|
||||
Use admin panel or API to set your desired payout percentage.
|
||||
|
||||
### 4. Start Server
|
||||
```bash
|
||||
pm2 start ecosystem.config.js --env production
|
||||
```
|
||||
|
||||
Server will automatically:
|
||||
- ✅ Update prices on startup
|
||||
- ✅ Schedule hourly updates
|
||||
- ✅ Keep prices fresh
|
||||
|
||||
---
|
||||
|
||||
## 📈 Price Update Logs
|
||||
|
||||
### Successful Update:
|
||||
```
|
||||
🔄 Starting price update for all games...
|
||||
|
||||
🔄 Updating MarketPrice reference database for CS2...
|
||||
📡 Fetching market data from Steam API...
|
||||
✅ Received 20,147 items from API
|
||||
📦 Batch: 10,000 inserted, 10,147 updated
|
||||
✅ MarketPrice update complete for CS2:
|
||||
📥 Inserted: 234
|
||||
🔄 Updated: 19,913
|
||||
⏭️ Skipped: 0
|
||||
|
||||
🔄 Updating MarketPrice reference database for RUST...
|
||||
✅ Received 9,823 items from API
|
||||
✅ MarketPrice update complete for RUST:
|
||||
📥 Inserted: 89
|
||||
🔄 Updated: 9,734
|
||||
|
||||
✅ All price updates complete!
|
||||
```
|
||||
|
||||
### View Logs:
|
||||
```bash
|
||||
pm2 logs turbotrades-backend --lines 100 | grep "price update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Manual Price Updates
|
||||
|
||||
### Update All Prices Now:
|
||||
```bash
|
||||
node update-prices-now.js
|
||||
```
|
||||
|
||||
### Update Specific Game:
|
||||
```bash
|
||||
node update-prices-now.js cs2
|
||||
node update-prices-now.js rust
|
||||
```
|
||||
|
||||
### Check Price Coverage:
|
||||
```bash
|
||||
node check-prices.js
|
||||
```
|
||||
|
||||
**Output:**
|
||||
```
|
||||
📊 Price Coverage Report
|
||||
|
||||
🎮 Counter-Strike 2:
|
||||
Active Items: 1,245
|
||||
With Prices: 1,198
|
||||
Coverage: 96.2%
|
||||
|
||||
🔧 Rust:
|
||||
Active Items: 387
|
||||
With Prices: 375
|
||||
Coverage: 96.9%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Items Showing "Price unavailable" on Sell Page
|
||||
|
||||
**Cause:** MarketPrice database is empty or outdated
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Import prices
|
||||
node import-market-prices.js
|
||||
|
||||
# OR restart server (auto-updates on startup)
|
||||
pm2 restart turbotrades-backend
|
||||
```
|
||||
|
||||
### Prices Not Updating
|
||||
|
||||
**Check 1:** Verify API key
|
||||
```bash
|
||||
# Check if set
|
||||
echo $STEAM_APIS_KEY
|
||||
|
||||
# Test API key
|
||||
curl "https://api.steamapis.com/market/items/730?api_key=$STEAM_APIS_KEY" | head
|
||||
```
|
||||
|
||||
**Check 2:** Check logs
|
||||
```bash
|
||||
pm2 logs turbotrades-backend --lines 200 | grep -E "price|update"
|
||||
```
|
||||
|
||||
**Check 3:** Verify scheduler is running
|
||||
```bash
|
||||
# Should see this on startup:
|
||||
⏰ Starting automatic price update scheduler...
|
||||
🔄 Running initial price update on startup...
|
||||
```
|
||||
|
||||
### Wrong Payout Rate Applied
|
||||
|
||||
**Check config:**
|
||||
```bash
|
||||
# Via API
|
||||
curl https://api.turbotrades.dev/api/config/public | jq '.config.instantSell'
|
||||
```
|
||||
|
||||
**Update config:**
|
||||
```bash
|
||||
# Via admin panel at /admin
|
||||
# Or via API (requires admin auth)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Database Queries
|
||||
|
||||
### Check MarketPrice Count:
|
||||
```javascript
|
||||
db.marketprices.countDocuments({ game: "cs2" })
|
||||
db.marketprices.countDocuments({ game: "rust" })
|
||||
```
|
||||
|
||||
### Find Item Price:
|
||||
```javascript
|
||||
db.marketprices.findOne({
|
||||
marketHashName: "AK-47 | Redline (Field-Tested)",
|
||||
game: "cs2"
|
||||
})
|
||||
```
|
||||
|
||||
### Most Expensive Items:
|
||||
```javascript
|
||||
db.marketprices.find({ game: "cs2" })
|
||||
.sort({ price: -1 })
|
||||
.limit(10)
|
||||
```
|
||||
|
||||
### Last Update Time:
|
||||
```javascript
|
||||
db.marketprices.findOne({ game: "cs2" })
|
||||
.sort({ lastUpdated: -1 })
|
||||
.limit(1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
### Payout Rates:
|
||||
- **Too High** (>80%): Site loses money
|
||||
- **Too Low** (<40%): Users won't sell
|
||||
- **Recommended**: 55-65% depending on competition
|
||||
|
||||
### Update Frequency:
|
||||
- **Every Hour**: Good for active trading
|
||||
- **Every 2-4 Hours**: Sufficient for most sites
|
||||
- **Daily**: Only for low-volume sites
|
||||
|
||||
### Monitoring:
|
||||
- Set up alerts for failed price updates
|
||||
- Monitor price coverage percentage
|
||||
- Track user complaints about prices
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security Notes
|
||||
|
||||
- ✅ API key stored in `.env` (never committed)
|
||||
- ✅ Payout rate changes logged with admin username
|
||||
- ✅ Rate limits on price update endpoints
|
||||
- ✅ Validate prices before accepting trades
|
||||
- ✅ Maximum item value limits prevent abuse
|
||||
|
||||
---
|
||||
|
||||
## 📝 Summary
|
||||
|
||||
| Feature | Status | Update Frequency |
|
||||
|---------|--------|------------------|
|
||||
| MarketPrice DB | ✅ Automatic | Every 1 hour |
|
||||
| Item Prices | ✅ Automatic | Every 1 hour |
|
||||
| Payout Rate | ⚙️ Admin Config | Manual |
|
||||
| Initial Import | 🔧 Manual | One-time |
|
||||
| Sell Page | ✅ Live | Real-time |
|
||||
|
||||
**Your instant sell page will now:**
|
||||
- ✅ Show accurate prices from your database
|
||||
- ✅ Apply your configured payout rate
|
||||
- ✅ Update prices automatically every hour
|
||||
- ✅ Allow per-game payout customization
|
||||
|
||||
**No more manual price updates needed!** 🎉
|
||||
176
deploy-ban-fix.sh
Normal file
176
deploy-ban-fix.sh
Normal file
@@ -0,0 +1,176 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Deploy Ban Notification Fix
|
||||
# This script deploys the ban notification feature to production
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
echo "🚀 Deploying Ban Notification Fix..."
|
||||
echo ""
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
PROJECT_DIR="/path/to/TurboTrades"
|
||||
FRONTEND_DIR="$PROJECT_DIR/frontend"
|
||||
WEB_ROOT="/var/www/html/turbotrades"
|
||||
PM2_APP_NAME="turbotrades-backend"
|
||||
|
||||
# Check if running on production server
|
||||
if [[ ! -d "$PROJECT_DIR" ]]; then
|
||||
echo -e "${RED}❌ Error: Project directory not found at $PROJECT_DIR${NC}"
|
||||
echo "Please update PROJECT_DIR in this script to match your server setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 1: Backup current deployment
|
||||
echo -e "${YELLOW}📦 Creating backup...${NC}"
|
||||
BACKUP_DIR="$HOME/turbotrades-backup-$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Backup backend files
|
||||
cp "$PROJECT_DIR/routes/admin-management.js" "$BACKUP_DIR/admin-management.js.bak" 2>/dev/null || true
|
||||
cp "$PROJECT_DIR/middleware/auth.js" "$BACKUP_DIR/auth.js.bak" 2>/dev/null || true
|
||||
|
||||
# Backup frontend
|
||||
if [[ -d "$WEB_ROOT" ]]; then
|
||||
cp -r "$WEB_ROOT" "$BACKUP_DIR/frontend" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Backup created at $BACKUP_DIR${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 2: Pull latest changes (if using git)
|
||||
cd "$PROJECT_DIR"
|
||||
if [[ -d ".git" ]]; then
|
||||
echo -e "${YELLOW}📥 Pulling latest changes from git...${NC}"
|
||||
git pull
|
||||
echo -e "${GREEN}✓ Git pull complete${NC}"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ Not a git repository, skipping git pull${NC}"
|
||||
echo -e "${YELLOW} Make sure you've manually uploaded the updated files:${NC}"
|
||||
echo " - routes/admin-management.js"
|
||||
echo " - middleware/auth.js"
|
||||
echo " - frontend/src/stores/websocket.js"
|
||||
echo ""
|
||||
read -p "Press Enter to continue after uploading files..."
|
||||
fi
|
||||
|
||||
# Step 3: Install/update dependencies (if needed)
|
||||
echo -e "${YELLOW}📦 Checking dependencies...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
if [[ -f "package.json" ]]; then
|
||||
npm install --production 2>/dev/null || echo "Dependencies already up to date"
|
||||
fi
|
||||
echo -e "${GREEN}✓ Dependencies checked${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 4: Build frontend
|
||||
echo -e "${YELLOW}🔨 Building frontend...${NC}"
|
||||
cd "$FRONTEND_DIR"
|
||||
npm run build
|
||||
|
||||
if [[ ! -d "dist" ]]; then
|
||||
echo -e "${RED}❌ Error: Frontend build failed (dist directory not found)${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✓ Frontend built successfully${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 5: Deploy frontend
|
||||
echo -e "${YELLOW}🚀 Deploying frontend...${NC}"
|
||||
if [[ ! -d "$WEB_ROOT" ]]; then
|
||||
echo "Creating web root directory..."
|
||||
sudo mkdir -p "$WEB_ROOT"
|
||||
fi
|
||||
|
||||
sudo cp -r dist/* "$WEB_ROOT/"
|
||||
echo -e "${GREEN}✓ Frontend deployed to $WEB_ROOT${NC}"
|
||||
echo ""
|
||||
|
||||
# Step 6: Restart backend
|
||||
echo -e "${YELLOW}🔄 Restarting backend...${NC}"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
# Check if PM2 is available
|
||||
if command -v pm2 &> /dev/null; then
|
||||
pm2 restart "$PM2_APP_NAME"
|
||||
|
||||
# Wait a moment for restart
|
||||
sleep 2
|
||||
|
||||
# Check if process is running
|
||||
if pm2 list | grep -q "$PM2_APP_NAME"; then
|
||||
echo -e "${GREEN}✓ Backend restarted successfully${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Warning: Backend may not be running${NC}"
|
||||
echo "Check logs with: pm2 logs $PM2_APP_NAME"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ PM2 not found, please restart backend manually${NC}"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 7: Verify deployment
|
||||
echo -e "${YELLOW}🔍 Verifying deployment...${NC}"
|
||||
|
||||
# Check if backend is responding
|
||||
BACKEND_URL="http://localhost:3000/api/health"
|
||||
if curl -s "$BACKEND_URL" > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ Backend is responding${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Warning: Backend health check failed${NC}"
|
||||
fi
|
||||
|
||||
# Check if frontend files exist
|
||||
if [[ -f "$WEB_ROOT/index.html" ]]; then
|
||||
echo -e "${GREEN}✓ Frontend files deployed${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Warning: Frontend index.html not found${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Step 8: Display logs
|
||||
echo -e "${YELLOW}📋 Recent backend logs:${NC}"
|
||||
if command -v pm2 &> /dev/null; then
|
||||
pm2 logs "$PM2_APP_NAME" --lines 20 --nostream
|
||||
else
|
||||
echo "PM2 not available, skipping logs"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Success message
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo -e "${GREEN}✅ Ban Notification Fix Deployed Successfully!${NC}"
|
||||
echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next Steps:${NC}"
|
||||
echo "1. Test the ban flow:"
|
||||
echo " - Login as a user in one browser"
|
||||
echo " - Login as admin in another browser"
|
||||
echo " - Ban the user from admin panel"
|
||||
echo " - User should be immediately redirected to /banned page"
|
||||
echo ""
|
||||
echo "2. Check the logs:"
|
||||
echo " pm2 logs $PM2_APP_NAME --lines 50"
|
||||
echo ""
|
||||
echo "3. Monitor WebSocket connections:"
|
||||
echo " pm2 logs $PM2_APP_NAME | grep WebSocket"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Backup Location:${NC} $BACKUP_DIR"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Rollback Instructions:${NC}"
|
||||
echo "If something goes wrong, restore from backup:"
|
||||
echo " cp $BACKUP_DIR/admin-management.js.bak $PROJECT_DIR/routes/admin-management.js"
|
||||
echo " cp $BACKUP_DIR/auth.js.bak $PROJECT_DIR/middleware/auth.js"
|
||||
echo " sudo cp -r $BACKUP_DIR/frontend/* $WEB_ROOT/"
|
||||
echo " pm2 restart $PM2_APP_NAME"
|
||||
echo ""
|
||||
echo -e "${GREEN}Happy banning! 🔨${NC}"
|
||||
@@ -496,6 +496,80 @@
|
||||
<p class="form-help">How often to auto-update market prices</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<h3>💵 Instant Sell Payout</h3>
|
||||
<div class="form-group">
|
||||
<label>Global Payout Rate (%)</label>
|
||||
<input
|
||||
v-model.number="instantSellForm.payoutRate"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
class="form-input"
|
||||
placeholder="70.0"
|
||||
/>
|
||||
<p class="form-help">
|
||||
Default percentage of market price paid to users (e.g., 70 = pay
|
||||
70% of item value)
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>CS2 Payout Rate (%)</label>
|
||||
<input
|
||||
v-model.number="instantSellForm.cs2.payoutRate"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
class="form-input"
|
||||
placeholder="70.0"
|
||||
/>
|
||||
<p class="form-help">
|
||||
Payout rate specifically for CS2 items (overrides global)
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Rust Payout Rate (%)</label>
|
||||
<input
|
||||
v-model.number="instantSellForm.rust.payoutRate"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="100"
|
||||
class="form-input"
|
||||
placeholder="70.0"
|
||||
/>
|
||||
<p class="form-help">
|
||||
Payout rate specifically for Rust items (overrides global)
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Min Item Value ($)</label>
|
||||
<input
|
||||
v-model.number="instantSellForm.minItemValue"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="form-input"
|
||||
placeholder="0.01"
|
||||
/>
|
||||
<p class="form-help">Minimum item value for instant sell</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Max Item Value ($)</label>
|
||||
<input
|
||||
v-model.number="instantSellForm.maxItemValue"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
class="form-input"
|
||||
placeholder="10000"
|
||||
/>
|
||||
<p class="form-help">Maximum item value for instant sell</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions-centered">
|
||||
@@ -970,6 +1044,22 @@ const promotionForm = ref({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Instant Sell form
|
||||
const instantSellForm = ref({
|
||||
enabled: true,
|
||||
payoutRate: 70.0, // Percentage (70 = 70%)
|
||||
minItemValue: 0.01,
|
||||
maxItemValue: 10000,
|
||||
cs2: {
|
||||
enabled: true,
|
||||
payoutRate: 70.0,
|
||||
},
|
||||
rust: {
|
||||
enabled: true,
|
||||
payoutRate: 70.0,
|
||||
},
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadConfig = async () => {
|
||||
loading.value = true;
|
||||
@@ -1016,6 +1106,25 @@ const loadConfig = async () => {
|
||||
commission: config.value.market.commission * 100,
|
||||
};
|
||||
}
|
||||
|
||||
if (config.value.instantSell) {
|
||||
instantSellForm.value = {
|
||||
enabled: config.value.instantSell.enabled ?? true,
|
||||
// Convert decimal to percentage for display (0.7 -> 70)
|
||||
payoutRate: (config.value.instantSell.payoutRate ?? 0.7) * 100,
|
||||
minItemValue: config.value.instantSell.minItemValue ?? 0.01,
|
||||
maxItemValue: config.value.instantSell.maxItemValue ?? 10000,
|
||||
cs2: {
|
||||
enabled: config.value.instantSell.cs2?.enabled ?? true,
|
||||
payoutRate: (config.value.instantSell.cs2?.payoutRate ?? 0.7) * 100,
|
||||
},
|
||||
rust: {
|
||||
enabled: config.value.instantSell.rust?.enabled ?? true,
|
||||
payoutRate:
|
||||
(config.value.instantSell.rust?.payoutRate ?? 0.7) * 100,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load config:", error);
|
||||
@@ -1060,25 +1169,46 @@ const saveAllSettings = async () => {
|
||||
commission: marketForm.value.commission / 100, // Convert percentage to decimal
|
||||
};
|
||||
|
||||
const instantSellData = {
|
||||
enabled: instantSellForm.value.enabled,
|
||||
payoutRate: instantSellForm.value.payoutRate / 100, // Convert percentage to decimal
|
||||
minItemValue: instantSellForm.value.minItemValue,
|
||||
maxItemValue: instantSellForm.value.maxItemValue,
|
||||
cs2: {
|
||||
enabled: instantSellForm.value.cs2.enabled,
|
||||
payoutRate: instantSellForm.value.cs2.payoutRate / 100,
|
||||
},
|
||||
rust: {
|
||||
enabled: instantSellForm.value.rust.enabled,
|
||||
payoutRate: instantSellForm.value.rust.payoutRate / 100,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("💾 Saving all settings...");
|
||||
console.log("Trading data:", tradingData);
|
||||
console.log("Market data:", marketData);
|
||||
console.log("Instant Sell data:", instantSellData);
|
||||
|
||||
// Save both in parallel
|
||||
const [tradingResponse, marketResponse] = await Promise.all([
|
||||
// Save all in parallel
|
||||
const [tradingResponse, marketResponse, instantSellResponse] =
|
||||
await Promise.all([
|
||||
axios.patch("/api/admin/config/trading", tradingData),
|
||||
axios.patch("/api/admin/config/market", marketData),
|
||||
axios.patch("/api/admin/config/instantsell", instantSellData),
|
||||
]);
|
||||
|
||||
if (tradingResponse.data.success && marketResponse.data.success) {
|
||||
toast.success("✅ All settings saved successfully!");
|
||||
await loadConfig();
|
||||
if (
|
||||
tradingResponse.data.success &&
|
||||
marketResponse.data.success &&
|
||||
instantSellResponse.data.success
|
||||
) {
|
||||
toast.success("Settings saved successfully");
|
||||
await loadConfig(); // Reload to get updated values
|
||||
} else {
|
||||
throw new Error("One or more settings failed to save");
|
||||
toast.error("Failed to save some settings");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to save settings:", error);
|
||||
console.error("Error response:", error.response?.data);
|
||||
console.error("Failed to save settings:", error);
|
||||
toast.error(error.response?.data?.message || "Failed to save settings");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -1885,7 +2015,7 @@ onMounted(() => {
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -1986,6 +2116,12 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
523
frontend/src/components/AdminItemsPanel.vue
Normal file
523
frontend/src/components/AdminItemsPanel.vue
Normal file
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header with Stats -->
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<Package class="w-6 h-6 text-blue-400" />
|
||||
Market Price Database
|
||||
</h2>
|
||||
<p class="text-gray-400 text-sm mt-1">
|
||||
Manage reference prices for instant sell
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshPrices"
|
||||
:disabled="isRefreshing"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': isRefreshing }" />
|
||||
{{ isRefreshing ? 'Updating...' : 'Update All Prices' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Total Items</div>
|
||||
<div class="text-2xl font-bold">{{ stats.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">CS2 Items</div>
|
||||
<div class="text-2xl font-bold text-blue-400">{{ stats.cs2 || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Rust Items</div>
|
||||
<div class="text-2xl font-bold text-orange-400">{{ stats.rust || 0 }}</div>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded-lg p-4">
|
||||
<div class="text-gray-400 text-sm mb-1">Last Updated</div>
|
||||
<div class="text-lg font-semibold">
|
||||
{{ stats.lastUpdated ? formatDate(stats.lastUpdated) : 'Never' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-gray-800 rounded-lg p-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Search -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Search Items
|
||||
</label>
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
v-model="filters.search"
|
||||
@input="debouncedSearch"
|
||||
type="text"
|
||||
placeholder="Search by name..."
|
||||
class="w-full pl-10 pr-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Game Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Game
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.game"
|
||||
@change="loadItems"
|
||||
class="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||
>
|
||||
<option value="">All Games</option>
|
||||
<option value="cs2">CS2</option>
|
||||
<option value="rust">Rust</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Sort -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Sort By
|
||||
</label>
|
||||
<select
|
||||
v-model="filters.sort"
|
||||
@change="loadItems"
|
||||
class="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded-lg focus:outline-none focus:border-blue-500 text-white"
|
||||
>
|
||||
<option value="name-asc">Name (A-Z)</option>
|
||||
<option value="name-desc">Name (Z-A)</option>
|
||||
<option value="price-desc">Price (High to Low)</option>
|
||||
<option value="price-asc">Price (Low to High)</option>
|
||||
<option value="updated-desc">Recently Updated</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div class="bg-gray-800 rounded-lg overflow-hidden">
|
||||
<div v-if="isLoading" class="p-8 text-center">
|
||||
<RefreshCw class="w-8 h-8 animate-spin mx-auto mb-2 text-blue-400" />
|
||||
<p class="text-gray-400">Loading items...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="p-8 text-center">
|
||||
<Package class="w-12 h-12 mx-auto mb-2 text-gray-600" />
|
||||
<p class="text-gray-400">No items found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-900 border-b border-gray-700">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Item
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Game
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Price
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Last Updated
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-400 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-700">
|
||||
<tr
|
||||
v-for="item in items"
|
||||
:key="item._id"
|
||||
class="hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="item.image"
|
||||
:src="item.image"
|
||||
:alt="item.name"
|
||||
class="w-12 h-12 rounded"
|
||||
@error="(e) => (e.target.src = '/placeholder.png')"
|
||||
/>
|
||||
<div class="w-12 h-12 bg-gray-700 rounded flex items-center justify-center" v-else>
|
||||
<Package class="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<div class="max-w-md">
|
||||
<div class="font-medium text-white truncate">{{ item.name }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ item.marketHashName }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="px-2 py-1 text-xs font-semibold rounded"
|
||||
:class="
|
||||
item.game === 'cs2'
|
||||
? 'bg-blue-900/50 text-blue-400'
|
||||
: 'bg-orange-900/50 text-orange-400'
|
||||
"
|
||||
>
|
||||
{{ item.game.toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div v-if="editingItem?._id === item._id" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model.number="editingItem.price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
class="w-24 px-2 py-1 bg-gray-900 border border-gray-700 rounded text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="font-semibold text-green-400">
|
||||
{{ formatCurrency(item.price) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm text-gray-400">{{ item.priceType || 'safe' }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ formatDate(item.lastUpdated) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div v-if="editingItem?._id === item._id" class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@click="saveItem"
|
||||
class="p-2 bg-green-600 hover:bg-green-700 rounded transition-colors"
|
||||
title="Save"
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="cancelEdit"
|
||||
class="p-2 bg-gray-600 hover:bg-gray-700 rounded transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-end gap-2">
|
||||
<button
|
||||
@click="editItem(item)"
|
||||
class="p-2 bg-blue-600 hover:bg-blue-700 rounded transition-colors"
|
||||
title="Edit Price"
|
||||
>
|
||||
<Edit class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteItem(item)"
|
||||
class="p-2 bg-red-600 hover:bg-red-700 rounded transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="px-6 py-4 bg-gray-900 border-t border-gray-700 flex items-center justify-between">
|
||||
<div class="text-sm text-gray-400">
|
||||
Showing {{ (pagination.page - 1) * pagination.limit + 1 }} to
|
||||
{{ Math.min(pagination.page * pagination.limit, pagination.total) }} of
|
||||
{{ pagination.total }} items
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="goToPage(1)"
|
||||
:disabled="pagination.page === 1"
|
||||
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||
>
|
||||
<ChevronsLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="goToPage(pagination.page - 1)"
|
||||
:disabled="pagination.page === 1"
|
||||
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<!-- Page Numbers -->
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
v-for="page in visiblePages"
|
||||
:key="page"
|
||||
@click="goToPage(page)"
|
||||
class="px-3 py-1 rounded transition-colors"
|
||||
:class="
|
||||
page === pagination.page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-800 hover:bg-gray-700'
|
||||
"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="goToPage(pagination.page + 1)"
|
||||
:disabled="pagination.page >= pagination.pages"
|
||||
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="goToPage(pagination.pages)"
|
||||
:disabled="pagination.page >= pagination.pages"
|
||||
class="px-3 py-1 bg-gray-800 hover:bg-gray-700 disabled:bg-gray-800 disabled:text-gray-600 disabled:cursor-not-allowed rounded transition-colors"
|
||||
>
|
||||
<ChevronsRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import axios from '@/utils/axios';
|
||||
import { useToast } from 'vue-toastification';
|
||||
import {
|
||||
Package,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from 'lucide-vue-next';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// State
|
||||
const items = ref([]);
|
||||
const stats = ref({
|
||||
total: 0,
|
||||
cs2: 0,
|
||||
rust: 0,
|
||||
lastUpdated: null,
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const isRefreshing = ref(false);
|
||||
const editingItem = ref(null);
|
||||
|
||||
// Filters
|
||||
const filters = ref({
|
||||
search: '',
|
||||
game: '',
|
||||
sort: 'name-asc',
|
||||
});
|
||||
|
||||
// Pagination
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
pages: 0,
|
||||
});
|
||||
|
||||
// Computed
|
||||
const visiblePages = computed(() => {
|
||||
const current = pagination.value.page;
|
||||
const total = pagination.value.pages;
|
||||
const delta = 2;
|
||||
const pages = [];
|
||||
|
||||
for (let i = Math.max(1, current - delta); i <= Math.min(total, current + delta); i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
return pages;
|
||||
});
|
||||
|
||||
// Methods
|
||||
const loadItems = async () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit,
|
||||
game: filters.value.game,
|
||||
search: filters.value.search,
|
||||
sort: filters.value.sort,
|
||||
};
|
||||
|
||||
const response = await axios.get('/api/admin/marketprices', { params });
|
||||
|
||||
if (response.data.success) {
|
||||
items.value = response.data.items || [];
|
||||
pagination.value = {
|
||||
page: response.data.page || 1,
|
||||
limit: response.data.limit || 50,
|
||||
total: response.data.total || 0,
|
||||
pages: response.data.pages || 0,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load items:', error);
|
||||
toast.error('Failed to load items');
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/admin/marketprices/stats');
|
||||
if (response.data.success) {
|
||||
stats.value = response.data.stats;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshPrices = async () => {
|
||||
if (isRefreshing.value) return;
|
||||
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
const response = await axios.post('/api/admin/prices/update', {
|
||||
game: 'all',
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Price update started! This may take a few minutes.');
|
||||
// Reload after a delay
|
||||
setTimeout(() => {
|
||||
loadItems();
|
||||
loadStats();
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh prices:', error);
|
||||
toast.error('Failed to start price update');
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const editItem = (item) => {
|
||||
editingItem.value = { ...item };
|
||||
};
|
||||
|
||||
const cancelEdit = () => {
|
||||
editingItem.value = null;
|
||||
};
|
||||
|
||||
const saveItem = async () => {
|
||||
if (!editingItem.value) return;
|
||||
|
||||
try {
|
||||
const response = await axios.patch(
|
||||
`/api/admin/marketprices/${editingItem.value._id}`,
|
||||
{
|
||||
price: editingItem.value.price,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Price updated successfully');
|
||||
// Update item in list
|
||||
const index = items.value.findIndex((i) => i._id === editingItem.value._id);
|
||||
if (index !== -1) {
|
||||
items.value[index] = { ...editingItem.value };
|
||||
}
|
||||
editingItem.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update item:', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to update item');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteItem = async (item) => {
|
||||
if (!confirm(`Delete "${item.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.delete(`/api/admin/marketprices/${item._id}`);
|
||||
|
||||
if (response.data.success) {
|
||||
toast.success('Item deleted successfully');
|
||||
loadItems();
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete item:', error);
|
||||
toast.error(error.response?.data?.message || 'Failed to delete item');
|
||||
}
|
||||
};
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page < 1 || page > pagination.value.pages) return;
|
||||
pagination.value.page = page;
|
||||
loadItems();
|
||||
};
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout;
|
||||
const debouncedSearch = () => {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.value.page = 1; // Reset to first page
|
||||
loadItems();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Formatting helpers
|
||||
const formatCurrency = (value) => {
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return 'N/A';
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadItems();
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Add any custom styles here */
|
||||
</style>
|
||||
@@ -104,6 +104,12 @@ const routes = [
|
||||
component: () => import("@/views/MaintenancePage.vue"),
|
||||
meta: { title: "Maintenance Mode" },
|
||||
},
|
||||
{
|
||||
path: "/verify-email/:token",
|
||||
name: "MailVerify",
|
||||
component: () => import("@/views/MailVerify.vue"),
|
||||
meta: { title: "Verify Email" },
|
||||
},
|
||||
{
|
||||
path: "/banned",
|
||||
name: "Banned",
|
||||
|
||||
@@ -47,7 +47,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
console.log("🔵 fetchUser called - fetching user from /api/auth/me");
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.get("/auth/me", {
|
||||
const response = await axios.get("/api/auth/me", {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await axios.post(
|
||||
"/auth/logout",
|
||||
"/api/auth/logout",
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
@@ -111,7 +111,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const refreshToken = async () => {
|
||||
try {
|
||||
await axios.post(
|
||||
"/auth/refresh",
|
||||
"/api/auth/refresh",
|
||||
{},
|
||||
{
|
||||
withCredentials: true,
|
||||
@@ -129,7 +129,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.patch(
|
||||
"/user/trade-url",
|
||||
"/api/user/trade-url",
|
||||
{ tradeUrl },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
@@ -155,7 +155,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.patch(
|
||||
"/user/email",
|
||||
"/api/user/email",
|
||||
{ email },
|
||||
{ withCredentials: true }
|
||||
);
|
||||
@@ -178,7 +178,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
const verifyEmail = async (token) => {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const response = await axios.get(`/user/verify-email/${token}`, {
|
||||
const response = await axios.get(`/api/user/verify-email/${token}`, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -199,7 +199,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
const getUserStats = async () => {
|
||||
try {
|
||||
const response = await axios.get("/user/stats", {
|
||||
const response = await axios.get("/api/user/stats", {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
@@ -215,7 +215,7 @@ export const useAuthStore = defineStore("auth", () => {
|
||||
|
||||
const getBalance = async () => {
|
||||
try {
|
||||
const response = await axios.get("/user/balance", {
|
||||
const response = await axios.get("/api/user/balance", {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -220,6 +220,28 @@ export const useWebSocketStore = defineStore("websocket", () => {
|
||||
authStore.fetchUser();
|
||||
break;
|
||||
|
||||
case "account_banned":
|
||||
console.log("🔨 User account has been banned:", payload);
|
||||
// Update the auth store - router guard will handle redirect
|
||||
authStore.fetchUser().then(() => {
|
||||
// Disconnect WebSocket since user is banned
|
||||
disconnect();
|
||||
// The router guard will automatically redirect to /banned page
|
||||
// because authStore.isBanned will now be true
|
||||
window.location.href = "/banned";
|
||||
});
|
||||
break;
|
||||
|
||||
case "account_unbanned":
|
||||
console.log("✅ User account has been unbanned:", payload);
|
||||
// Update the auth store to reflect unbanned status
|
||||
authStore.fetchUser().then(() => {
|
||||
// Redirect to home page
|
||||
window.location.href = "/";
|
||||
toast.success("Your account has been unbanned. Welcome back!");
|
||||
});
|
||||
break;
|
||||
|
||||
case "pong":
|
||||
// Heartbeat response
|
||||
break;
|
||||
|
||||
@@ -10,7 +10,7 @@ const getApiBaseUrl = () => {
|
||||
return import.meta.env.VITE_API_URL;
|
||||
}
|
||||
|
||||
// In production, use the api subdomain
|
||||
// In production, use the api subdomain (WITHOUT /api suffix - routes already have it)
|
||||
if (import.meta.env.PROD) {
|
||||
const currentHost = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
@@ -20,13 +20,13 @@ const getApiBaseUrl = () => {
|
||||
currentHost === "turbotrades.dev" ||
|
||||
currentHost === "www.turbotrades.dev"
|
||||
) {
|
||||
const baseUrl = `${protocol}//api.turbotrades.dev/api`;
|
||||
const baseUrl = `${protocol}//api.turbotrades.dev`;
|
||||
console.log("🔵 Production API baseURL:", baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
// For other domains, try api subdomain
|
||||
const baseUrl = `${protocol}//api.${currentHost}/api`;
|
||||
const baseUrl = `${protocol}//api.${currentHost}`;
|
||||
console.log("🔵 Production API baseURL (custom domain):", baseUrl);
|
||||
return baseUrl;
|
||||
}
|
||||
@@ -37,8 +37,16 @@ const getApiBaseUrl = () => {
|
||||
};
|
||||
|
||||
// Create axios instance
|
||||
const baseURL = getApiBaseUrl();
|
||||
console.log("🔧 Creating axios instance with baseURL:", baseURL);
|
||||
console.log(
|
||||
"🔧 Environment:",
|
||||
import.meta.env.PROD ? "PRODUCTION" : "DEVELOPMENT"
|
||||
);
|
||||
console.log("🔧 Current hostname:", window.location.hostname);
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: getApiBaseUrl(),
|
||||
baseURL: baseURL,
|
||||
timeout: 15000,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
@@ -46,17 +54,22 @@ const axiosInstance = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
console.log("✅ Axios instance created with config:", {
|
||||
baseURL: axiosInstance.defaults.baseURL,
|
||||
withCredentials: axiosInstance.defaults.withCredentials,
|
||||
timeout: axiosInstance.defaults.timeout,
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
// You can add auth token to headers here if needed
|
||||
// const token = localStorage.getItem('token')
|
||||
// if (token) {
|
||||
// config.headers.Authorization = `Bearer ${token}`
|
||||
// }
|
||||
// Log every request for debugging
|
||||
const fullUrl = `${config.baseURL}${config.url}`;
|
||||
console.log(`📤 Axios Request: ${config.method.toUpperCase()} ${fullUrl}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error("❌ Axios Request Error:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -477,7 +477,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Items Tab -->
|
||||
<div v-if="activeTab === 'items'" class="space-y-6">
|
||||
<div v-if="activeTab === 'items'">
|
||||
<AdminItemsPanel />
|
||||
</div>
|
||||
|
||||
<!-- Old Items Tab (keeping for reference, remove if not needed) -->
|
||||
<div v-if="activeTab === 'items-old'" class="space-y-6">
|
||||
<!-- Game Filter -->
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
@@ -753,6 +758,7 @@ import axios from "../utils/axios";
|
||||
import AdminUsersPanel from "../components/AdminUsersPanel.vue";
|
||||
import AdminConfigPanel from "../components/AdminConfigPanel.vue";
|
||||
import AdminDebugPanel from "../components/AdminDebugPanel.vue";
|
||||
import AdminItemsPanel from "../components/AdminItemsPanel.vue";
|
||||
import {
|
||||
Shield,
|
||||
RefreshCw,
|
||||
|
||||
@@ -9,13 +9,8 @@
|
||||
<!-- Title -->
|
||||
<h1 class="banned-title">Account Suspended</h1>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="banned-message">
|
||||
Your account has been suspended due to a violation of our Terms of
|
||||
Service.
|
||||
</p>
|
||||
|
||||
<!-- Ban Details -->
|
||||
<!-- Ban Details and Appeal Section -->
|
||||
<div class="ban-details-container">
|
||||
<div v-if="banInfo" class="ban-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Reason:</span>
|
||||
@@ -24,37 +19,14 @@
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="banInfo.bannedAt" class="detail-item">
|
||||
<span class="detail-label">Banned on:</span>
|
||||
<span class="detail-value">{{ formatDate(banInfo.bannedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="banInfo.bannedUntil" class="detail-item">
|
||||
<span class="detail-label">Ban expires:</span>
|
||||
<span class="detail-value">{{
|
||||
formatDate(banInfo.bannedUntil)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="detail-item permanent-ban">
|
||||
<AlertCircle :size="18" />
|
||||
<span>This is a permanent ban</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="info-box">
|
||||
<Info :size="20" class="info-icon" />
|
||||
<div class="info-content">
|
||||
<p class="info-title">What does this mean?</p>
|
||||
<p class="info-text">
|
||||
You will not be able to access your account, make trades, or use the
|
||||
marketplace while your account is suspended.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Appeal Section -->
|
||||
<div class="appeal-section">
|
||||
<p class="appeal-text">
|
||||
If you believe this ban was made in error, you can submit an appeal.
|
||||
@@ -64,6 +36,7 @@
|
||||
<span>Contact Support</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<button @click="handleLogout" class="logout-btn">
|
||||
@@ -134,10 +107,7 @@ const banInfo = computed(() => {
|
||||
|
||||
return {
|
||||
reason: authStore.user.ban?.reason,
|
||||
bannedAt: authStore.user.ban?.bannedAt,
|
||||
bannedUntil: authStore.user.ban?.bannedUntil,
|
||||
isPermanent:
|
||||
authStore.user.ban?.permanent || !authStore.user.ban?.bannedUntil,
|
||||
bannedUntil: authStore.user.ban?.expires,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -293,15 +263,6 @@ onMounted(() => {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.ban-details {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -329,14 +290,6 @@ onMounted(() => {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.permanent-ban {
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -372,23 +325,40 @@ onMounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.appeal-section {
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.ban-details-container {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.ban-details {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.appeal-section {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.appeal-text {
|
||||
font-size: 0.9375rem;
|
||||
color: #d1d5db;
|
||||
margin: 0 0 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.appeal-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 2rem;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
@@ -400,6 +370,8 @@ onMounted(() => {
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
|
||||
border: 1px solid rgba(59, 130, 246, 0.5);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.appeal-btn:hover {
|
||||
@@ -524,8 +496,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.appeal-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
113
frontend/src/views/MailVerify.vue
Normal file
113
frontend/src/views/MailVerify.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import axios from "axios";
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const loading = ref(true);
|
||||
const success = ref(false);
|
||||
const error = ref(false);
|
||||
const message = ref("Verifying your email...");
|
||||
const countdown = ref(5); // countdown in seconds
|
||||
|
||||
const goHome = () => router.push("/");
|
||||
const goBack = () => router.back();
|
||||
|
||||
let countdownInterval = null;
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.params.token;
|
||||
if (!token) {
|
||||
error.value = true;
|
||||
loading.value = false;
|
||||
message.value = "No verification token provided.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.get(
|
||||
`https://api.turbotrades.dev/api/user/verify-email/${token}`
|
||||
);
|
||||
if (res.data.success) {
|
||||
success.value = true;
|
||||
message.value = "Email verified successfully! Redirecting to homepage...";
|
||||
loading.value = false;
|
||||
|
||||
// Start countdown
|
||||
countdownInterval = setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(countdownInterval);
|
||||
router.push("/");
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
error.value = true;
|
||||
loading.value = false;
|
||||
message.value = res.data.message || "Failed to verify email.";
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = true;
|
||||
loading.value = false;
|
||||
message.value =
|
||||
err.response?.data?.message ||
|
||||
"An error occurred while verifying your email.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="verify-page min-h-screen flex items-center justify-center py-12">
|
||||
<div class="container-custom">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<!-- Big pulsing text -->
|
||||
<div class="relative mb-8">
|
||||
<div
|
||||
class="text-9xl font-display font-bold text-transparent bg-clip-text animate-pulse-slow"
|
||||
:class="{
|
||||
'bg-gradient-to-r from-green-500 to-green-700': success,
|
||||
'bg-gradient-to-r from-red-500 to-red-700': error,
|
||||
'bg-gradient-to-r from-primary-500 to-primary-700': loading,
|
||||
}"
|
||||
>
|
||||
{{ success ? "SUCCESS" : error ? "ERROR" : "VERIFY" }}
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
class="w-64 h-64 rounded-full blur-3xl animate-pulse"
|
||||
:class="{
|
||||
'bg-green-500/10': success,
|
||||
'bg-red-500/10': error,
|
||||
'bg-primary-500/10': loading,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<p class="text-lg text-gray-400 mb-2 max-w-md mx-auto">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
<!-- Countdown -->
|
||||
<p v-if="success" class="text-gray-400 mb-8">
|
||||
Redirecting in {{ countdown }} second{{ countdown > 1 ? "s" : "" }}...
|
||||
</p>
|
||||
<p v-else class="mb-8"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.verify-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>
|
||||
@@ -720,7 +720,7 @@
|
||||
<form @submit.prevent="disable2FA">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-text-secondary mb-2">
|
||||
Enter your 6-digit 2FA code or recovery code
|
||||
Please enter your recovery code
|
||||
</label>
|
||||
<input
|
||||
v-model="disable2FAForm.code"
|
||||
|
||||
20
index.js
20
index.js
@@ -601,8 +601,24 @@ const start = async () => {
|
||||
.updateAllPrices()
|
||||
.then((result) => {
|
||||
console.log("✅ Initial price update completed successfully");
|
||||
console.log(` CS2: ${result.cs2.updated || 0} items updated`);
|
||||
console.log(` Rust: ${result.rust.updated || 0} items updated`);
|
||||
console.log(" 📊 MarketPrice Reference Database:");
|
||||
console.log(
|
||||
` CS2: ${
|
||||
result.marketPrices.cs2.updated || 0
|
||||
} prices updated, ${result.marketPrices.cs2.inserted || 0} new`
|
||||
);
|
||||
console.log(
|
||||
` Rust: ${
|
||||
result.marketPrices.rust.updated || 0
|
||||
} prices updated, ${result.marketPrices.rust.inserted || 0} new`
|
||||
);
|
||||
console.log(" 📦 Marketplace Items:");
|
||||
console.log(
|
||||
` CS2: ${result.itemPrices.cs2.updated || 0} items updated`
|
||||
);
|
||||
console.log(
|
||||
` Rust: ${result.itemPrices.rust.updated || 0} items updated`
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("❌ Initial price update failed:", error.message);
|
||||
|
||||
@@ -105,25 +105,41 @@ export const authenticate = async (request, reply) => {
|
||||
|
||||
// Check if user is banned
|
||||
if (user.ban && user.ban.banned) {
|
||||
if (user.ban.expires && new Date(user.ban.expires) > new Date()) {
|
||||
// Check if ban has expired
|
||||
if (user.ban.expires && new Date(user.ban.expires) <= new Date()) {
|
||||
// Ban expired, clear it
|
||||
user.ban.banned = false;
|
||||
user.ban.reason = null;
|
||||
user.ban.expires = null;
|
||||
await user.save();
|
||||
} else {
|
||||
// User is currently banned
|
||||
// Allow access to /api/auth/me so frontend can get ban info and redirect
|
||||
const url = request.url || "";
|
||||
const routeUrl = request.routeOptions?.url || "";
|
||||
const isAuthMeEndpoint =
|
||||
url.includes("/auth/me") ||
|
||||
routeUrl === "/me" ||
|
||||
routeUrl.endsWith("/me");
|
||||
|
||||
if (!isAuthMeEndpoint) {
|
||||
// Block access to all other endpoints
|
||||
if (user.ban.expires) {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "Your account is banned",
|
||||
reason: user.ban.reason,
|
||||
expires: user.ban.expires,
|
||||
});
|
||||
} else if (!user.ban.expires) {
|
||||
} else {
|
||||
return reply.status(403).send({
|
||||
error: "Forbidden",
|
||||
message: "Your account is permanently banned",
|
||||
reason: user.ban.reason,
|
||||
});
|
||||
} else {
|
||||
// Ban expired, clear it
|
||||
user.ban.banned = false;
|
||||
user.ban.reason = null;
|
||||
user.ban.expires = null;
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
// If it's /api/auth/me, continue and attach user with ban info
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,22 @@ const SiteConfigSchema = new mongoose.Schema(
|
||||
priceUpdateInterval: { type: Number, default: 3600000 }, // 1 hour in ms
|
||||
},
|
||||
|
||||
// Instant sell / buyback settings (site buying items from users)
|
||||
instantSell: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
payoutRate: { type: Number, default: 0.6 }, // 60% of market price
|
||||
minItemValue: { type: Number, default: 0.1 }, // Min $0.10
|
||||
maxItemValue: { type: Number, default: 10000 }, // Max $10,000
|
||||
cs2: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
payoutRate: { type: Number, default: 0.6 }, // Game-specific override
|
||||
},
|
||||
rust: {
|
||||
enabled: { type: Boolean, default: true },
|
||||
payoutRate: { type: Number, default: 0.6 }, // Game-specific override
|
||||
},
|
||||
},
|
||||
|
||||
// Features toggles
|
||||
features: {
|
||||
twoFactorAuth: { type: Boolean, default: true },
|
||||
|
||||
@@ -4,6 +4,7 @@ import Transaction from "../models/Transaction.js";
|
||||
import SiteConfig from "../models/SiteConfig.js";
|
||||
import PromoUsage from "../models/PromoUsage.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import wsManager from "../utils/websocket.js";
|
||||
|
||||
/**
|
||||
* Admin management routes for user administration and site configuration
|
||||
@@ -414,6 +415,32 @@ export default async function adminManagementRoutes(fastify, options) {
|
||||
}`
|
||||
);
|
||||
|
||||
// Send WebSocket notification to the user if they're connected
|
||||
if (wsManager.isUserConnected(user.steamId)) {
|
||||
if (banned) {
|
||||
wsManager.sendToUser(user.steamId, {
|
||||
type: "account_banned",
|
||||
data: {
|
||||
banned: true,
|
||||
reason: user.ban.reason,
|
||||
bannedAt: new Date(),
|
||||
bannedUntil: user.ban.expires,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
console.log(`📡 Sent ban notification to user ${user.username}`);
|
||||
} else {
|
||||
wsManager.sendToUser(user.steamId, {
|
||||
type: "account_unbanned",
|
||||
data: {
|
||||
banned: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
console.log(`📡 Sent unban notification to user ${user.username}`);
|
||||
}
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: banned
|
||||
@@ -786,6 +813,98 @@ export default async function adminManagementRoutes(fastify, options) {
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /admin/config/instantsell - Update instant sell settings
|
||||
fastify.patch(
|
||||
"/config/instantsell",
|
||||
{
|
||||
preHandler: [authenticate, isAdmin],
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
payoutRate: { type: "number", minimum: 0, maximum: 1 },
|
||||
minItemValue: { type: "number", minimum: 0 },
|
||||
maxItemValue: { type: "number", minimum: 0 },
|
||||
cs2: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
payoutRate: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
rust: {
|
||||
type: "object",
|
||||
properties: {
|
||||
enabled: { type: "boolean" },
|
||||
payoutRate: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const config = await SiteConfig.getConfig();
|
||||
|
||||
// Update top-level instant sell settings
|
||||
["enabled", "payoutRate", "minItemValue", "maxItemValue"].forEach(
|
||||
(key) => {
|
||||
if (request.body[key] !== undefined) {
|
||||
config.instantSell[key] = request.body[key];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update CS2 settings
|
||||
if (request.body.cs2) {
|
||||
Object.keys(request.body.cs2).forEach((key) => {
|
||||
if (request.body.cs2[key] !== undefined) {
|
||||
config.instantSell.cs2[key] = request.body.cs2[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update Rust settings
|
||||
if (request.body.rust) {
|
||||
Object.keys(request.body.rust).forEach((key) => {
|
||||
if (request.body.rust[key] !== undefined) {
|
||||
config.instantSell.rust[key] = request.body.rust[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
config.lastUpdatedBy = request.user.username;
|
||||
config.lastUpdatedAt = new Date();
|
||||
|
||||
await config.save();
|
||||
|
||||
console.log(
|
||||
`⚙️ Admin ${request.user.username} updated instant sell settings:`,
|
||||
{
|
||||
payoutRate: config.instantSell.payoutRate,
|
||||
cs2PayoutRate: config.instantSell.cs2?.payoutRate,
|
||||
rustPayoutRate: config.instantSell.rust?.payoutRate,
|
||||
}
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Instant sell settings updated",
|
||||
instantSell: config.instantSell,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to update instant sell settings:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to update instant sell settings",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// ANNOUNCEMENTS
|
||||
// ============================================
|
||||
|
||||
251
routes/admin.js
251
routes/admin.js
@@ -1,6 +1,7 @@
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import pricingService from "../services/pricing.js";
|
||||
import Item from "../models/Item.js";
|
||||
import MarketPrice from "../models/MarketPrice.js";
|
||||
import Transaction from "../models/Transaction.js";
|
||||
import User from "../models/User.js";
|
||||
|
||||
@@ -19,17 +20,13 @@ export default async function adminRoutes(fastify, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin (you can customize this check)
|
||||
// For now, checking if user has admin role or specific steamId
|
||||
const adminSteamIds = process.env.ADMIN_STEAM_IDS?.split(",") || [];
|
||||
|
||||
if (
|
||||
!request.user.isAdmin &&
|
||||
!adminSteamIds.includes(request.user.steamId)
|
||||
) {
|
||||
// Check if user has admin staff level (3 or higher)
|
||||
if (!request.user.staffLevel || request.user.staffLevel < 3) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
message: "Admin access required",
|
||||
requiredLevel: 3,
|
||||
currentLevel: request.user.staffLevel || 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1115,4 +1112,242 @@ export default async function adminRoutes(fastify, options) {
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// MARKETPRICE MANAGEMENT
|
||||
// ============================================
|
||||
|
||||
// GET /admin/marketprices - Get paginated list of MarketPrice items
|
||||
fastify.get(
|
||||
"/marketprices",
|
||||
{
|
||||
preHandler: [authenticate, isAdmin],
|
||||
schema: {
|
||||
querystring: {
|
||||
type: "object",
|
||||
properties: {
|
||||
page: { type: "integer", minimum: 1, default: 1 },
|
||||
limit: { type: "integer", minimum: 1, maximum: 100, default: 50 },
|
||||
game: { type: "string", enum: ["cs2", "rust", ""] },
|
||||
search: { type: "string" },
|
||||
sort: { type: "string", default: "name-asc" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 50,
|
||||
game = "",
|
||||
search = "",
|
||||
sort = "name-asc",
|
||||
} = request.query;
|
||||
|
||||
// Build query
|
||||
const query = {};
|
||||
if (game) {
|
||||
query.game = game;
|
||||
}
|
||||
if (search) {
|
||||
query.$or = [
|
||||
{ name: { $regex: search, $options: "i" } },
|
||||
{ marketHashName: { $regex: search, $options: "i" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build sort
|
||||
let sortObj = {};
|
||||
switch (sort) {
|
||||
case "name-asc":
|
||||
sortObj = { name: 1 };
|
||||
break;
|
||||
case "name-desc":
|
||||
sortObj = { name: -1 };
|
||||
break;
|
||||
case "price-asc":
|
||||
sortObj = { price: 1 };
|
||||
break;
|
||||
case "price-desc":
|
||||
sortObj = { price: -1 };
|
||||
break;
|
||||
case "updated-desc":
|
||||
sortObj = { lastUpdated: -1 };
|
||||
break;
|
||||
default:
|
||||
sortObj = { name: 1 };
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await MarketPrice.countDocuments(query);
|
||||
|
||||
// Get items
|
||||
const items = await MarketPrice.find(query)
|
||||
.sort(sortObj)
|
||||
.limit(limit)
|
||||
.skip((page - 1) * limit)
|
||||
.lean();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
items,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get market prices:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to get market prices",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// GET /admin/marketprices/stats - Get MarketPrice statistics
|
||||
fastify.get(
|
||||
"/marketprices/stats",
|
||||
{
|
||||
preHandler: [authenticate, isAdmin],
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const total = await MarketPrice.countDocuments();
|
||||
const cs2 = await MarketPrice.countDocuments({ game: "cs2" });
|
||||
const rust = await MarketPrice.countDocuments({ game: "rust" });
|
||||
|
||||
// Get most recent update
|
||||
const lastItem = await MarketPrice.findOne()
|
||||
.sort({ lastUpdated: -1 })
|
||||
.select("lastUpdated")
|
||||
.lean();
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
stats: {
|
||||
total,
|
||||
cs2,
|
||||
rust,
|
||||
lastUpdated: lastItem?.lastUpdated || null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to get market price stats:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to get stats",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// PATCH /admin/marketprices/:id - Update a MarketPrice item
|
||||
fastify.patch(
|
||||
"/marketprices/:id",
|
||||
{
|
||||
preHandler: [authenticate, isAdmin],
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
price: { type: "number", minimum: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
const { price } = request.body;
|
||||
|
||||
const item = await MarketPrice.findById(id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: "Item not found",
|
||||
});
|
||||
}
|
||||
|
||||
item.price = price;
|
||||
item.priceType = "manual"; // Mark as manually updated
|
||||
item.lastUpdated = new Date();
|
||||
await item.save();
|
||||
|
||||
console.log(
|
||||
`⚙️ Admin ${request.user.username} updated price for ${item.name}: $${price}`
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Price updated successfully",
|
||||
item,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to update market price:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to update price",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// DELETE /admin/marketprices/:id - Delete a MarketPrice item
|
||||
fastify.delete(
|
||||
"/marketprices/:id",
|
||||
{
|
||||
preHandler: [authenticate, isAdmin],
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["id"],
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const { id } = request.params;
|
||||
|
||||
const item = await MarketPrice.findByIdAndDelete(id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
message: "Item not found",
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`⚙️ Admin ${request.user.username} deleted market price for ${item.name}`
|
||||
);
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
message: "Item deleted successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ Failed to delete market price:", error);
|
||||
return reply.status(500).send({
|
||||
success: false,
|
||||
message: "Failed to delete item",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authenticate } from "../middleware/auth.js";
|
||||
import Item from "../models/Item.js";
|
||||
import Trade from "../models/Trade.js";
|
||||
import Transaction from "../models/Transaction.js";
|
||||
import SiteConfig from "../models/SiteConfig.js";
|
||||
import { config } from "../config/index.js";
|
||||
import pricingService from "../services/pricing.js";
|
||||
import marketPriceService from "../services/marketPrice.js";
|
||||
@@ -184,6 +185,23 @@ export default async function inventoryRoutes(fastify, options) {
|
||||
// Enrich items with market prices (fast database lookup)
|
||||
console.log(`💰 Adding market prices...`);
|
||||
|
||||
// Get site config for payout rate
|
||||
const siteConfig = await SiteConfig.getConfig();
|
||||
let payoutRate = siteConfig.instantSell.payoutRate || 0.6;
|
||||
|
||||
// Check for game-specific payout rate
|
||||
if (game === "cs2" && siteConfig.instantSell.cs2?.payoutRate) {
|
||||
payoutRate = siteConfig.instantSell.cs2.payoutRate;
|
||||
} else if (game === "rust" && siteConfig.instantSell.rust?.payoutRate) {
|
||||
payoutRate = siteConfig.instantSell.rust.payoutRate;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`💵 Instant sell payout rate: ${(payoutRate * 100).toFixed(
|
||||
0
|
||||
)}% of market price`
|
||||
);
|
||||
|
||||
// Get all item names for batch lookup
|
||||
const itemNames = items.map((item) => item.name);
|
||||
console.log(`📋 Looking up prices for ${itemNames.length} items`);
|
||||
@@ -196,12 +214,19 @@ export default async function inventoryRoutes(fastify, options) {
|
||||
`💰 Found prices for ${foundPrices}/${itemNames.length} items`
|
||||
);
|
||||
|
||||
// Add prices to items
|
||||
const enrichedItems = items.map((item) => ({
|
||||
// Add prices to items (applying payout rate for instant sell)
|
||||
const enrichedItems = items.map((item) => {
|
||||
const marketPrice = priceMap[item.name] || null;
|
||||
return {
|
||||
...item,
|
||||
marketPrice: priceMap[item.name] || null,
|
||||
hasPriceData: !!priceMap[item.name],
|
||||
}));
|
||||
marketPrice: marketPrice
|
||||
? parseFloat((marketPrice * payoutRate).toFixed(2))
|
||||
: null,
|
||||
fullMarketPrice: marketPrice, // Keep original for reference
|
||||
payoutRate: payoutRate,
|
||||
hasPriceData: !!marketPrice,
|
||||
};
|
||||
});
|
||||
|
||||
// Log items without prices
|
||||
const itemsWithoutPrices = enrichedItems.filter(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from "axios";
|
||||
import Item from "../models/Item.js";
|
||||
import MarketPrice from "../models/MarketPrice.js";
|
||||
|
||||
/**
|
||||
* Pricing Service
|
||||
@@ -275,15 +276,177 @@ class PricingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prices for all games
|
||||
* Update MarketPrice reference database for a specific game
|
||||
* Fetches all items from Steam market and updates the reference database
|
||||
* @param {string} game - Game identifier ('cs2' or 'rust')
|
||||
* @returns {Promise<Object>} - Update statistics
|
||||
*/
|
||||
async updateMarketPriceDatabase(game) {
|
||||
console.log(
|
||||
`\n🔄 Updating MarketPrice reference database for ${game.toUpperCase()}...`
|
||||
);
|
||||
|
||||
try {
|
||||
const appId = this.appIds[game];
|
||||
if (!appId) {
|
||||
throw new Error(`Invalid game: ${game}`);
|
||||
}
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("Steam API key not configured");
|
||||
}
|
||||
|
||||
// Fetch all market items from Steam API
|
||||
console.log(`📡 Fetching market data from Steam API...`);
|
||||
const response = await axios.get(
|
||||
`${this.baseUrl}/market/items/${appId}`,
|
||||
{
|
||||
params: { api_key: this.apiKey },
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data || !response.data.data) {
|
||||
throw new Error("No data returned from Steam API");
|
||||
}
|
||||
|
||||
const items = response.data.data;
|
||||
const itemCount = Object.keys(items).length;
|
||||
console.log(`✅ Received ${itemCount} items from API`);
|
||||
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
// Process items in batches
|
||||
const bulkOps = [];
|
||||
|
||||
for (const item of Object.values(items)) {
|
||||
try {
|
||||
// Get the best available price
|
||||
const price =
|
||||
item.prices?.safe ||
|
||||
item.prices?.median ||
|
||||
item.prices?.mean ||
|
||||
item.prices?.avg ||
|
||||
item.prices?.latest;
|
||||
|
||||
if (!price || price <= 0) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const marketHashName = item.market_hash_name || item.market_name;
|
||||
const marketName = item.market_name || item.market_hash_name;
|
||||
|
||||
if (!marketHashName || !marketName) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine which price type was used
|
||||
let priceType = "safe";
|
||||
if (item.prices?.safe) priceType = "safe";
|
||||
else if (item.prices?.median) priceType = "median";
|
||||
else if (item.prices?.mean) priceType = "mean";
|
||||
else if (item.prices?.avg) priceType = "avg";
|
||||
else if (item.prices?.latest) priceType = "latest";
|
||||
|
||||
bulkOps.push({
|
||||
updateOne: {
|
||||
filter: { marketHashName: marketHashName },
|
||||
update: {
|
||||
$set: {
|
||||
name: marketName,
|
||||
game: game,
|
||||
appId: appId,
|
||||
marketHashName: marketHashName,
|
||||
price: price,
|
||||
priceType: priceType,
|
||||
image: item.image || null,
|
||||
borderColor: item.border_color || null,
|
||||
nameId: item.nameID || null,
|
||||
lastUpdated: new Date(),
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Execute in batches of 1000
|
||||
if (bulkOps.length >= 1000) {
|
||||
const result = await MarketPrice.bulkWrite(bulkOps);
|
||||
inserted += result.upsertedCount;
|
||||
updated += result.modifiedCount;
|
||||
console.log(
|
||||
` 📦 Batch: ${inserted} inserted, ${updated} updated`
|
||||
);
|
||||
bulkOps.length = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute remaining items
|
||||
if (bulkOps.length > 0) {
|
||||
const result = await MarketPrice.bulkWrite(bulkOps);
|
||||
inserted += result.upsertedCount;
|
||||
updated += result.modifiedCount;
|
||||
}
|
||||
|
||||
console.log(`✅ MarketPrice update complete for ${game.toUpperCase()}:`);
|
||||
console.log(` 📥 Inserted: ${inserted}`);
|
||||
console.log(` 🔄 Updated: ${updated}`);
|
||||
console.log(` ⏭️ Skipped: ${skipped}`);
|
||||
if (errors > 0) {
|
||||
console.log(` ❌ Errors: ${errors}`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
game,
|
||||
total: itemCount,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
errors,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`❌ Error updating MarketPrice for ${game}:`,
|
||||
error.message
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
game,
|
||||
error: error.message,
|
||||
total: 0,
|
||||
inserted: 0,
|
||||
updated: 0,
|
||||
skipped: 0,
|
||||
errors: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update prices for all games (both Item and MarketPrice databases)
|
||||
* @returns {Promise<Object>} - Combined update statistics
|
||||
*/
|
||||
async updateAllPrices() {
|
||||
console.log("🔄 Starting price update for all games...");
|
||||
|
||||
const results = {
|
||||
marketPrices: {
|
||||
cs2: await this.updateMarketPriceDatabase("cs2"),
|
||||
rust: await this.updateMarketPriceDatabase("rust"),
|
||||
},
|
||||
itemPrices: {
|
||||
cs2: await this.updateDatabasePrices("cs2"),
|
||||
rust: await this.updateDatabasePrices("rust"),
|
||||
},
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user