Compare commits

..
Author SHA1 Message Date
AfonsoCMSousa 1299becf9a Merge pull request 'hotfix/websockets' (#104) from hotfix/websockets into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/104
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-03 18:05:41 +00:00
FernandoJVideira 13205c6e80 fix: rm .env files 2026-01-03 17:57:04 +00:00
FernandoJVideira 2cd1d53616 fix: prod ready websockets 2026-01-03 17:55:45 +00:00
FernandoJVideira 5b504d10bd merge 2026-01-03 17:22:09 +00:00
FernandoJVideira 5ea8c0df23 Merge pull request 'fixing game history, leaderboard, stats and removing more comments' (#103) from hot-fix/fixing-games-history into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/103
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 17:08:27 +00:00
Edd b6845ee0ea fixing game history and removing comments 2026-01-03 16:59:14 +00:00
FernandoJVideira 3ba3487f81 Merge pull request 'Version 1.0' (#102) from develop into master
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/102
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-03 14:49:00 +00:00
FernandoJVideira 55399ab06a Merge pull request 'removed comments' (#101) from na/comment-removal into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/101
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 14:46:58 +00:00
KZix f7328d59eb removed comments 2026-01-03 14:45:14 +00:00
FernandoJVideira b046a85806 Merge pull request 'fix/single-player' (#100) from fix/single-player into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/100
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 14:26:49 +00:00
AfonsoCMSousa a3da9af90d hotfix: Singler player now plays fine 2026-01-03 14:02:58 +00:00
AfonsoCMSousa 83832aaaa9 hotfix: Finnal fix 2026-01-03 14:01:38 +00:00
FernandoJVideira 4cfab79611 Merge pull request 'feature/bestof-series-interface' (#99) from feature/bestof-series-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/99
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 13:26:18 +00:00
AfonsoCMSousa 00cb5ba966 SyntaxSquad/DADProject#16 fix: merge conflics fixed 2026-01-03 13:25:21 +00:00
AfonsoCMSousa ceba5178f3 SyntaxSquad/DADProject#16 fix: i forgot this 2026-01-03 13:14:59 +00:00
AfonsoCMSousa 0fb64ff30c SyntaxSquad/DADProject#16 feat: MATCHES AND STUFF 2026-01-03 13:14:37 +00:00
AfonsoCMSousa e0ca8e51a6 SyntaxSquad/DADProject#16 feat: Can now start matches, just missing the actuall gameplay 2026-01-03 04:52:02 +00:00
FernandoJVideira cd25915d92 Merge pull request 'fix:admin & user match table sort' (#98) from fix/frontend-admin-match-sort into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/98
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-03 02:35:07 +00:00
FernandoJVideira bcfa5c06f7 fix:user profile match table sort 2026-01-03 02:33:22 +00:00
AfonsoCMSousa 3dcd8df4c7 SyntaxSquad/DADProject#16 fix: Cancle and Return is working and some other fixes involving game duration 2026-01-03 02:19:00 +00:00
FernandoJVideira 46e3f0eef6 fix:admin match table sort 2026-01-03 02:11:41 +00:00
AfonsoCMSousa 7e6766c726 SyntaxSquad/DADProject#16 merge: fix merge conflics 2026-01-03 00:53:51 +00:00
AfonsoCMSousa 1088749386 SyntaxSquad/DADProject#16 fix: Surrender if leave 2026-01-03 00:51:42 +00:00
KZix 52cb893bd0 Merge pull request 'Added matches scrollable to home page' (#96) from na/matches-home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/96
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-03 00:43:38 +00:00
Edd 59fea64df5 Merge branch 'develop' into na/matches-home-page 2026-01-03 00:43:07 +00:00
Edd a09639abcf Merge pull request 'feature/frontend-add-match-profile-tabs' (#97) from feature/frontend-add-match-profile-tabs into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/97
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-03 00:43:03 +00:00
Edd 2d2d0149d8 little date adjustements 2026-01-03 00:42:04 +00:00
KZix 894a38bb27 Added corrections to user profile and admin match and game history 2026-01-03 00:41:24 +00:00
Edd 432d3c0b88 Added matches scrollable to home page 2026-01-03 00:39:08 +00:00
KZix 1a7916a836 Added list for games and matches in the user profile 2026-01-03 00:06:26 +00:00
Edd 030b00059e Merge pull request 'feature/websockets-matches' (#95) from feature/websockets-matches into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/95
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-02 22:23:20 +00:00
KZix a804f41956 Final touches 2026-01-02 22:11:47 +00:00
KZix 25d21f7cf8 Merge branch 'develop' into feature/websockets-matches 2026-01-02 21:47:30 +00:00
KZix c3f8c3fff0 Matches almost done still refining 2026-01-02 21:45:37 +00:00
AfonsoCMSousa 23d0ea1343 SyntaxSquad/DADProject#16 fix: Timer issues and Forfeight button 2026-01-02 20:12:18 +00:00
KZix 1dc2d25a41 Merge pull request 'feature/multiplayer-game-interface' (#94) from feature/multiplayer-game-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/94
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-02 19:18:04 +00:00
AfonsoCMSousa 7c8fdd6ea2 SyntaxSquad/DADProject#15 fix: Merge Conflicts Fixed. 2026-01-02 19:12:35 +00:00
AfonsoCMSousa 5e90de812b SyntaxSquad/DADProject#15 feat: Added Multiplayer View and Stuff 2026-01-02 19:07:27 +00:00
AfonsoCMSousa ee28333da8 Merge pull request 'Admin platform, with users, transactions, games and admin creation' (#93) from feature/admin-platform into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/93
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 16:20:26 +00:00
Edd e89b60c844 Admin platform, with users, transactions, games and admin creation 2026-01-02 16:17:55 +00:00
AfonsoCMSousa b9df0a4d4a Merge pull request 'fix:websocket errors & polict change' (#92) from fix/websocket-errors into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/92
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 14:29:39 +00:00
FernandoJVideira 3a7b1b9794 fix:websocket errors & polict change 2026-01-02 14:26:54 +00:00
AfonsoCMSousa 5880960c42 Merge remote-tracking branch 'origin/develop' into feature/multiplayer-game-interface 2026-01-02 13:34:53 +00:00
AfonsoCMSousa ef277a3cfd Merge pull request 'Issues Websockets #48 #49 #51 e #54' (#91) from feature/websockets-game-continue into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/91
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 13:33:21 +00:00
KZix e23ca3d1ff Issues Websockets #48 #49 #51 e #54 2026-01-02 13:24:20 +00:00
AfonsoCMSousa e84dd993af SyntaxSquad/DADProject#15 feat: Initial Idea 2026-01-02 00:39:39 +00:00
FernandoJVideira bcdf542606 Merge pull request 'Created statistics personal and general' (#90) from feature/statistics into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/90
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-01 12:34:40 +00:00
Edd 16f8d3bdfc Created statistics personal and general 2025-12-31 23:57:54 +00:00
AfonsoCMSousa 45d8aa0035 Merge pull request 'Implemented coin purchase-flow' (#89) from feature/coin-purchase into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/89
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-31 22:41:09 +00:00
Edd c0af76abd3 Implemented coin purchase-flow 2025-12-31 18:28:29 +00:00
FernandoJVideira ff16a57a97 Merge pull request 'Bugfix: Added api logic with transactions when match is finished, started and joined' (#88) from bugfix/matches-transactions into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/88
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-31 15:54:20 +00:00
KZix cf238d5451 Added api logic with transactions when match is finished, started and joined 2025-12-31 15:48:24 +00:00
Edd 50ae1b7f1f Merge pull request 'Added endpoint to create tranasactions' (#87) from feature/api-coinpurchase into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/87
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-31 11:07:11 +00:00
KZix 5975208877 Added endpoint to create tranasactions 2025-12-31 01:13:27 +00:00
KZix f3e8ffc400 Merge pull request 'matches and transactions history fully operational' (#86) from na/fix-histories into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/86
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-31 00:31:59 +00:00
Edd 1b03558f93 matches and transactions history fully operational 2025-12-31 00:16:14 +00:00
AfonsoCMSousa 881a60a388 Merge pull request 'feature/api-adminstats-transactions' (#85) from feature/api-adminstats-transactions into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/85
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-30 19:24:49 +00:00
72 changed files with 5441 additions and 4802 deletions
+2 -1
View File
@@ -44,7 +44,8 @@ dist
frontend/.env frontend/.env
frontend/.env.local frontend/.env.local
frontend/.env.*.local frontend/.env.*.local
frontend/.env.production
websockets/.env.production
# Misc frontend files that should be ignored # Misc frontend files that should be ignored
/frontend/public/hot /frontend/public/hot
/frontend/public/storage /frontend/public/storage
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\CoinPurchase;
use App\Models\CoinTransaction;
use App\Models\CoinTransactionType;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class CoinPurchaseController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'euros' => 'required|numeric|min:1|max:100',
'payment_type' => 'required|in:MBWAY,MB,VISA,PAYPAL',
'payment_reference' => [
'required',
function ($attribute, $value, $fail) use ($request) {
$type = $request->payment_type;
$regex = null;
switch ($type) {
case 'MBWAY': $regex = '/^9[0-9]{8}$/'; break;
case 'MB': $regex = '/^[0-9]{5}-[0-9]{9}$/'; break;
case 'VISA': $regex = '/^4[0-9]{15}$/'; break;
case 'PAYPAL':
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
$fail("Invalid PayPal email.");
}
return;
}
if ($regex && !preg_match($regex, $value)) {
$fail("Invalid format for $type.");
}
},
],
]);
$user = $request->user();
$coinsAmount = $validated['euros'] * 10;
return DB::transaction(function () use ($user, $validated, $coinsAmount) {
$type = CoinTransactionType::firstOrCreate(['name' => 'Coin purchase'], ['type' => 'C']);
$transaction = CoinTransaction::create([
'user_id' => $user->id,
'coin_transaction_type_id' => $type->id,
'transaction_datetime' => now(),
'coins' => $coinsAmount,
]);
$purchase = CoinPurchase::create([
'purchase_datetime' => now(),
'user_id' => $user->id,
'coin_transaction_id' => $transaction->id,
'euros' => $validated['euros'],
'payment_type' => $validated['payment_type'],
'payment_reference' => $validated['payment_reference'],
]);
$user->increment('coins_balance', $coinsAmount);
return response()->json([
'message' => 'Purchase successful',
'balance' => $user->coins_balance,
'transaction_id' => $transaction->id,
'purchase' => $purchase,
], 201);
});
}
}
+100 -30
View File
@@ -4,9 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StoreGameRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use App\Models\MatchGame;
class GameController extends Controller class GameController extends Controller
{ {
@@ -19,15 +17,12 @@ class GameController extends Controller
$user = Auth::user(); $user = Auth::user();
$query = Game::query()->with(["winner", "player1", "player2"]); $query = Game::query()->with(["winner", "player1", "player2"]);
// Filter games based on user type
if ($user->type !== 'A') { if ($user->type !== 'A') {
// Players can only see games they participate in
$query->where(function ($q) use ($user) { $query->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id); ->orWhere('player2_user_id', $user->id);
}); });
} }
// Admins see all games (no filter needed)
if ($request->has("type") && in_array($request->type, ["3", "9"])) { if ($request->has("type") && in_array($request->type, ["3", "9"])) {
$query->where("type", $request->type); $query->where("type", $request->type);
@@ -47,19 +42,70 @@ class GameController extends Controller
if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) { if ($request->has("sort_direction") && in_array($request->sort_direction, ["asc", "desc"])) {
$query->orderBy("began_at", $request->sort_direction); $query->orderBy("began_at", $request->sort_direction);
}else{ } else {
$query->orderBy("began_at", "desc"); $query->orderBy("began_at", "desc");
} }
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
public function host(Request $request)
{
$request->validate([
'type' => 'required|in:3,9'
]);
$user = $request->user();
$activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeGame) {
return response()->json(["message" => "You already have an active game."], 400);
}
$game = Game::create([
'type' => $request->type,
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => self::GHOST_ID,
'began_at' => now(),
'player1_points' => 0,
'player2_points' => 0,
]);
return response()->json($game, 201);
}
public function open(Request $request)
{
$type = $request->query('type', '9');
$GHOST_ID = 1;
$games = Game::where('status', 'Pending')
->where('player2_user_id', $GHOST_ID)
->where('type', $type)
->with('player1')
->orderBy('began_at', 'asc')
->paginate(10);
return response()->json($games);
}
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize('create', Game::class); $this->authorize('create', Game::class);
$validated = $request->validate([ $validated = $request->validate([
"type" => "required|in:3,9", "type" => "required|in:3,9",
"player2_user_id" => "nullable|integer|exists:users,id",
"match_id" => "nullable|integer|exists:matches,id",
"status" => "nullable|in:Pending,Playing",
"began_at" => "nullable|date"
]); ]);
$user = Auth::user(); $user = Auth::user();
@@ -75,51 +121,61 @@ class GameController extends Controller
return response()->json(["message" => "You already have an active game."], 400); return response()->json(["message" => "You already have an active game."], 400);
} }
$player2Id = $request->input('player2_user_id', self::GHOST_ID);
$initialStatus = $request->input('status', 'Pending');
if ($player2Id !== self::GHOST_ID && !$request->has('status')) {
$initialStatus = 'Playing';
}
$game = Game::create([ $game = Game::create([
"type" => $validated["type"], "type" => $validated["type"],
"status" => "Pending", "status" => $initialStatus,
"player1_user_id" => $user->id, "player1_user_id" => $user->id,
"player2_user_id" => self::GHOST_ID, "player2_user_id" => $player2Id,
"winner_user_id" => self::GHOST_ID, "winner_user_id" => null,
"loser_user_id" => self::GHOST_ID, "loser_user_id" => null,
"began_at" => now(), "match_id" => $request->input('match_id', null),
"began_at" => $request->input('began_at', now()),
"player1_points" => 0, "player1_points" => 0,
"player2_points" => 0, "player2_points" => 0,
"custom" => null, "custom" => null,
]); ]);
return response()->json([ return response()->json([
"message" => "Multiplayer game room created", "message" => "Game created",
"game" => $game, "game" => $game,
], 201); ], 201);
} }
public function join(Game $game) public function join(Request $request, Game $game)
{ {
$this->authorize('join', $game); if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) {
return response()->json(['message' => 'Game is not available'], 400);
$user = Auth::user();
if ($game->player1_user_id == $user->id) {
return response()->json([
"message" => "You are the host. Waiting for opponent...",
"game" => $game
], 200);
} }
if ($game->player2_user_id !== self::GHOST_ID) { if ($game->player1_user_id === $request->user()->id) {
return response()->json(["message" => "Game is full"], 400); return response()->json(['message' => 'Cannot join your own game'], 400);
}
$activeGame = Game::where(function ($q) use ($request) {
$q->where("player1_user_id", $request->user()->id)
->orWhere("player2_user_id", $request->user()->id);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeGame) {
return response()->json(["message" => "You already have an active game."], 400);
} }
$game->update([ $game->update([
"status" => "Playing", 'player2_user_id' => $request->user()->id,
"player2_user_id" => $user->id, 'status' => 'Playing',
]); ]);
return response()->json([ return response()->json($game);
"message" => "Joined successfully!",
"game" => $game,
], 200);
} }
public function show(Game $game) public function show(Game $game)
@@ -177,4 +233,18 @@ class GameController extends Controller
'game' => $game->fresh(), 'game' => $game->fresh(),
]); ]);
} }
public function destroy(Game $game)
{
if ($game->player1_user_id !== auth()->id()) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($game->status !== 'Pending') {
return response()->json(['message' => 'Cannot delete a game in progress'], 400);
}
$game->delete();
return response()->noContent();
}
} }
+294 -61
View File
@@ -3,26 +3,36 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MatchGame; use App\Models\MatchGame;
use App\Models\Game; use App\Models\CoinTransaction;
use App\Models\CoinTransactionType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StoreMatchRequest; use App\Http\Requests\StoreMatchRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB;
class MatchController extends Controller class MatchController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$query = MatchGame::query()->with(['winner', 'player1', 'player2']); $query = MatchGame::query()->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$query->orderBy('began_at', 'desc'); if ($request->has("pot") && in_array($request->pot, ["asc", "desc"])) {
$query->orderBy("stake", $request->pot);
} elseif (
$request->has("sort_direction") &&
in_array($request->sort_direction, ["asc", "desc"])
) {
$query->orderBy("began_at", $request->sort_direction);
} else {
$query->orderBy("began_at", "desc");
}
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
@@ -30,105 +40,328 @@ class MatchController extends Controller
public function store(StoreMatchRequest $request) public function store(StoreMatchRequest $request)
{ {
$validated = $request->validated(); $validated = $request->validated();
$user = Auth::user(); $user = $request->user();
$stake = $validated['stake'];
$activeMatch = MatchGame::where(function ($q) use ($user) { if ($user->coins_balance < $stake) {
$q->where('player1_user_id', $user->id) return response()->json(
->orWhere('player2_user_id', $user->id); ["message" => "Insufficient coin balance"],
})->whereIn('status', ['Pending', 'Playing'])->exists(); 400,
);
$activeGame = Game::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})->whereIn('status', ['Pending', 'Playing'])->exists();
if ($activeMatch || $activeGame) {
return response()->json(['message' => 'You already have an active game or match.'], 400);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) {
return response()->json(['message' => 'You already have an active match.'], 400);
}
return DB::transaction(function () use ($validated, $user, $stake) {
$GHOST_ID = 1;
$user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"],
["type" => "D"],
);
$match = MatchGame::create([ $match = MatchGame::create([
'type' => $validated['type'], "type" => $validated["type"],
'status' => 'Pending', "status" => "Pending",
'player1_user_id' => $user->id, "player1_user_id" => $user->id,
'player2_user_id' => $user->id, "player2_user_id" => $GHOST_ID,
'winner_user_id' => $user->id, "winner_user_id" => $GHOST_ID,
'loser_user_id' => $user->id, "loser_user_id" => $GHOST_ID,
'stake' => $validated['stake'], "stake" => $stake,
'began_at' => now(), "began_at" => now(),
'player1_marks' => 0, "player1_marks" => 0,
'player2_marks' => 0, "player2_marks" => 0,
'player1_points' => 0, "player1_points" => 0,
'player2_points' => 0, "player2_points" => 0,
'custom' => null
]); ]);
return response()->json([ CoinTransaction::create([
'message' => 'Match created successfully', "user_id" => $user->id,
'match' => $match "match_id" => $match->id,
], 201); "coin_transaction_type_id" => $stakeType->id,
"transaction_datetime" => now(),
"coins" => -$stake,
]);
return response()->json(
[
"message" => "Match created successfully",
"match" => $match,
"balance" => $user->coins_balance,
],
201,
);
});
} }
public function show(MatchGame $match) public function show(MatchGame $match)
{ {
$match->load(['player1', 'player2', 'winner']); $match->load(["player1", "player2", "winner"]);
return response()->json($match); return response()->json($match);
} }
public function join(MatchGame $match) public function join(MatchGame $match)
{ {
$user = Auth::user(); $user = request()->user();
if ($match->player1_user_id == $user->id) { if ($match->player1_user_id == $user->id) {
return response()->json(['message' => 'Waiting for opponent...', 'match' => $match]); return response()->json(
["message" => "You cannot join your own match"],
400,
);
}
if ($match->status !== "Pending") {
return response()->json(
["message" => "Match is full or started"],
400,
);
} }
if ($match->status !== 'Pending') { if ($user->coins_balance < $match->stake) {
return response()->json(['message' => 'Match is full or started'], 400); return response()->json(
["message" => "Insufficient coin balance to join this match"],
400,
);
} }
$activeMatch = MatchGame::where(function ($q) use ($user) { $activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
})->whereIn('status', ['Pending', 'Playing'])->exists(); $user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) { if ($activeMatch) {
return response()->json(['message' => 'You are already in another active match.'], 400); return response()->json(
["message" => "You are already in another active match."],
400,
);
} }
return DB::transaction(function () use ($match, $user) {
$user->decrement("coins_balance", $match->stake);
$stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"],
["type" => "D"],
);
CoinTransaction::create([
'user_id' => $user->id,
'match_id' => $match->id,
'coin_transaction_type_id' => $stakeType->id,
'transaction_datetime' => now(),
'coins' => -$match->stake,
]);
$match->update([ $match->update([
'status' => 'Playing', "status" => "Playing",
'player2_user_id' => $user->id "player2_user_id" => $user->id,
]); ]);
return response()->json([ return response()->json([
'message' => 'Joined match successfully!', "message" => "Joined match successfully!",
'match' => $match "match" => $match,
"balance" => $user->coins_balance,
]); ]);
});
} }
public function update(Request $request, MatchGame $match) public function update(Request $request, MatchGame $match)
{ {
if ($match->status == 'Ended') { if ($match->status == "Ended") {
return response()->json(['message' => 'Match already ended'], 400); return response()->json(["message" => "Match already ended"], 400);
} }
$data = $request->validate([ $data = $request->validate([
'status' => 'in:Playing,Ended,Interrupted', "status" => "in:Playing,Ended,Interrupted",
'winner_user_id' => 'exists:users,id', "winner_user_id" => "exists:users,id",
'loser_user_id' => 'exists:users,id', "loser_user_id" => "exists:users,id",
'player1_marks' => 'integer', "player1_marks" => "integer",
'player2_marks' => 'integer', "player2_marks" => "integer",
'player1_points' => 'integer', "player1_points" => "integer",
'player2_points' => 'integer', "player2_points" => "integer",
'total_time' => 'numeric' "total_time" => "numeric",
]); ]);
return DB::transaction(function () use ($match, $data) {
if (isset($data['status']) && $data['status'] === 'Ended') { if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now(); $data['ended_at'] = now();
} }
$match->update($data); $match->update($data);
if (
$match->status === "Ended" &&
$match->stake > 0 &&
$match->winner_user_id
) {
$payoutType = CoinTransactionType::firstOrCreate(
["name" => "Match payout"],
["type" => "C"],
);
$totalPrize = $match->stake * 2;
CoinTransaction::create([
"user_id" => $match->winner_user_id,
"match_id" => $match->id,
"coin_transaction_type_id" => $payoutType->id,
"transaction_datetime" => now(),
"coins" => $totalPrize,
]);
$match->winner->increment("coins_balance", $totalPrize);
}
return response()->json($match); return response()->json($match);
});
}
public function host(Request $request)
{
$request->validate([
"type" => "required|in:3,9",
"stake" => "required|integer|min:0",
]);
$user = $request->user();
$stake = $request->stake;
if ($user->coins_balance < $stake) {
return response()->json(
["message" => "Insufficient coin balance"],
400,
);
}
$activeMatch = MatchGame::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeMatch) {
return response()->json(
["message" => "You already have an active match."],
400,
);
}
return DB::transaction(function () use ($request, $user, $stake) {
$GHOST_ID = 1;
$user->decrement("coins_balance", $stake);
$stakeType = CoinTransactionType::firstOrCreate(
["name" => "Match stake"],
["type" => "D"],
);
$match = MatchGame::create([
'type' => $request->type,
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $GHOST_ID,
'winner_user_id' => null,
'loser_user_id' => null,
'stake' => $stake,
'began_at' => now(),
'player1_marks' => 0, 'player2_marks' => 0,
'player1_points' => 0, 'player2_points' => 0,
]);
CoinTransaction::create([
"user_id" => $user->id,
"match_id" => $match->id,
"coin_transaction_type_id" => $stakeType->id,
"transaction_datetime" => now(),
"coins" => -$stake,
]);
return response()->json($match, 201);
});
}
public function open(Request $request)
{
$query = MatchGame::where('status', 'Pending')
->where(function($q) {
$q->whereNull('player2_user_id')
->orWhere('player2_user_id', 1);
});
if ($request->has('type')) {
$query->where('type', $request->type);
}
$matches = $query->with('player1:id,nickname')
->orderBy('began_at', 'desc')
->paginate(10);
return response()->json($matches);
}
public function destroy(MatchGame $match)
{
$user = request()->user();
if ($match->player1_user_id !== $user->id) {
return response()->json(['message' => 'Unauthorized'], 403);
}
if ($match->status !== 'Pending') {
return response()->json(['message' => 'Cannot cancel a match that has already started'], 400);
}
return DB::transaction(function () use ($match, $user) {
DB::table('games')->where('match_id', $match->id)->delete();
CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
if ($match->stake > 0) {
$user->increment('coins_balance', $match->stake);
$refundType = CoinTransactionType::firstOrCreate(
['name' => 'Refund'],
['type' => 'C']
);
CoinTransaction::create([
'user_id' => $user->id,
'match_id' => null,
'coin_transaction_type_id' => $refundType->id,
'transaction_datetime' => now(),
'coins' => $match->stake,
'custom' => "Refund for Match #{$match->id}"
]);
}
$match->delete();
return response()->json([
'message' => 'Match canceled and refunded',
'balance' => $user->coins_balance
]);
});
} }
} }
@@ -82,7 +82,7 @@ class ProfileController extends Controller
]); ]);
} }
public function updateAvatar(Request $request) public function uploadAvatar(Request $request)
{ {
$request->validate([ $request->validate([
"avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048", "avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048",
@@ -29,16 +29,23 @@ class StatisticsController extends Controller
{ {
$type = $request->query('type'); $type = $request->query('type');
$query = User::where('type', 'P') $leaderboard = User::where('type', 'P')
->withCount(['wonGames' => function ($query) use ($type) { ->withCount(['wonGames' => function ($query) use ($type) {
if ($type) { if ($type) {
$query->where('type', $type); $query->where('type', $type);
} }
}]) }])
->orderBy('won_games_count', 'desc') ->orderBy('won_games_count', 'desc')
->take(10); ->paginate(10);
$leaderboard = $query->get(['id', 'nickname', 'photo_avatar_filename']); $leaderboard->through(function ($user) {
return [
'id' => $user->id,
'nickname' => $user->nickname,
'photo_avatar_filename' => $user->photo_avatar_filename,
'won_games_count' => $user->won_games_count,
];
});
return response()->json($leaderboard); return response()->json($leaderboard);
} }
@@ -58,7 +65,7 @@ class StatisticsController extends Controller
->where('winner_user_id', $user->id) ->where('winner_user_id', $user->id)
->count(); ->count();
$totalLosses = $totalGames - $totalWins; // Simplest math $totalLosses = $totalGames - $totalWins;
$winRate = 0; $winRate = 0;
if ($totalGames > 0) { if ($totalGames > 0) {
@@ -71,7 +78,7 @@ class StatisticsController extends Controller
'total_wins' => $totalWins, 'total_wins' => $totalWins,
'total_losses' => $totalLosses, 'total_losses' => $totalLosses,
'win_rate' => $winRate . '%', 'win_rate' => $winRate . '%',
'current_balance' => $user->coins_balance, // Extra info 'current_balance' => $user->coins_balance,
]); ]);
} }
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers;
use App\Models\CoinTransaction;
use Illuminate\Http\Request;
class TransactionController extends Controller
{
public function index(Request $request)
{
$this->authorize('viewAny', CoinTransaction::class);
$query = CoinTransaction::with('user:id,nickname,email', 'type:id,name');
if ($request->has('sort_coins')) {
$direction = $request->get('sort_coins') === 'asc' ? 'asc' : 'desc';
$query->orderBy('coins', $direction);
}
$dateDirection = $request->get('sort_datetime') === 'asc' ? 'asc' : 'desc';
$query->orderBy('transaction_datetime', $dateDirection);
$transactions = $query->paginate(15);
return response()->json($transactions);
}
}
+188 -112
View File
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Game; use App\Models\Game;
use App\Models\User; use App\Models\User;
use App\Models\MatchGame;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -11,238 +12,313 @@ use Illuminate\Validation\Rule;
class UserController extends Controller class UserController extends Controller
{ {
/**
* GET /api/users
* Lista todos os utilizadores (Apenas para Admins)
*/
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize('viewAny', User::class); $this->authorize("viewAny", User::class);
$query = User::query(); $query = User::query();
// Filtros úteis para o Backoffice if ($request->has("type")) {
if ($request->has('type')) { $query->where("type", $request->type);
$query->where('type', $request->type);
} }
if ($request->has('search')) { if ($request->has("blocked")) {
$query->where("blocked", $request->type);
}
if ($request->has("search")) {
$query->where(function ($q) use ($request) { $query->where(function ($q) use ($request) {
$q->where('name', 'like', '%'.$request->search.'%') $q->where("name", "like", "%" . $request->search . "%")
->orWhere('email', 'like', '%'.$request->search.'%') ->orWhere("email", "like", "%" . $request->search . "%")
->orWhere('nickname', 'like', '%'.$request->search.'%'); ->orWhere("nickname", "like", "%" . $request->search . "%");
}); });
} }
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
/**
* POST /api/users
* Registar um novo utilizador (Admin only)
*/
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize('create', User::class); $this->authorize("create", User::class);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string|max:255', "name" => "required|string|max:255",
'nickname' => 'required|string|max:20|unique:users,nickname', "email" => "required|email|unique:users,email",
'email' => 'required|email|unique:users,email', "password" => "required|string|min:3",
'password' => 'required|string|min:3', "type" => "required|in:A,U",
'type' => 'required|in:A,U',
]); ]);
$validated['password'] = Hash::make($validated['password']); $validated["password"] = Hash::make($validated["password"]);
$user = User::create($validated); $user = User::create($validated);
return response()->json($user, 201); return response()->json($user, 201);
} }
/**
* GET /api/users/me
* Retorna o perfil do utilizador autenticado atual.
* Usado pelo VueJS para saber quem está logado.
*/
public function me(Request $request) public function me(Request $request)
{ {
return response()->json($request->user()); return response()->json($request->user());
} }
/**
* GET /api/users/{user}
* Mostra um perfil específico.
*/
public function show(User $user) public function show(User $user)
{ {
$currentUser = Auth::user(); $currentUser = Auth::user();
// Admins and owners get full profile, others get limited view if ($currentUser->type === "A" || $currentUser->id === $user->id) {
if ($currentUser->type === 'A' || $currentUser->id === $user->id) {
return response()->json($user); return response()->json($user);
} else { } else {
return response()->json([ return response()->json([
'id' => $user->id, "id" => $user->id,
'name' => $user->name, "name" => $user->name,
'nickname' => $user->nickname, "nickname" => $user->nickname,
'photo_avatar_filename' => $user->photo_avatar_filename, "photo_avatar_filename" => $user->photo_avatar_filename,
'type' => $user->type, "type" => $user->type,
]); ]);
} }
} }
/**
* PUT/PATCH /api/users/{user}
* Atualizar perfil (Nome, Nickname, Password, Foto)
*/
public function update(Request $request, User $user) public function update(Request $request, User $user)
{ {
$this->authorize('update', $user); $this->authorize("update", $user);
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', "name" => "sometimes|string|max:255",
'nickname' => [ "nickname" => [
'sometimes', "sometimes",
'string', "string",
'max:20', "max:20",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'email' => [ "email" => [
'sometimes', "sometimes",
'email', "email",
Rule::unique('users')->ignore($user->id), Rule::unique("users")->ignore($user->id),
], ],
'password' => 'sometimes|string|min:3', "password" => "sometimes|string|min:3",
]); ]);
// Lógica de Upload de Foto (Exemplo Básico)
// if ($request->hasFile('photo_avatar')) {
// $path = $request->file('photo_avatar')->store('avatars', 'public');
// $validated['photo_avatar_filename'] = basename($path);
// }
$user->update($validated); $user->update($validated);
return response()->json($user); return response()->json($user);
} }
public function updatePassword(Request $request, User $user) public function updatePassword(Request $request, User $user)
{ {
$this->authorize('update', $user); $this->authorize("update", $user);
$validated = $request->validate([ $validated = $request->validate([
'current_password' => 'required|string', "current_password" => "required|string",
'new_password' => 'required|string|min:3|confirmed', "new_password" => "required|string|min:3|confirmed",
]); ]);
// Verificar se a password atual está correta if (!Hash::check($validated["current_password"], $user->password)) {
if (!Hash::check($validated['current_password'], $user->password)) { return response()->json(
return response()->json(['message' => 'Current password is incorrect'], 403); ["message" => "Current password is incorrect"],
403,
);
} }
// Atualizar para a nova password $user->password = Hash::make($validated["new_password"]);
$user->password = Hash::make($validated['new_password']);
$user->save(); $user->save();
return response()->json(['message' => 'Password updated successfully']); return response()->json(["message" => "Password updated successfully"]);
} }
/**
* DELETE /api/users/{user}
* Apagar conta (Soft Delete)
*/
public function destroy(User $user) public function destroy(User $user)
{ {
$this->authorize('delete', $user); $this->authorize("delete", $user);
// Check if user has transactions or games - if so, soft delete
$hasTransactions = $user->transactions()->exists(); $hasTransactions = $user->transactions()->exists();
$hasGames = Game::where(function ($q) use ($user) { $hasGames = Game::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id) $q->where("player1_user_id", $user->id)->orWhere(
->orWhere('player2_user_id', $user->id); "player2_user_id",
$user->id,
);
})->exists(); })->exists();
if ($hasTransactions || $hasGames) { if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete $user->delete();
$message = 'User account deactivated (soft-deleted due to transaction/game history)'; $message =
"User account deactivated (soft-deleted due to transaction/game history)";
} else { } else {
$user->forceDelete(); // Hard delete $user->forceDelete();
$message = 'User permanently deleted'; $message = "User permanently deleted";
} }
return response()->json(['message' => $message]); return response()->json(["message" => $message]);
} }
/** public function getGames(Request $request, User $user)
* GET /api/users/{user}/matches
* Histórico de Jogos Multiplayer do Utilizador
*/
public function getMatches(Request $request, User $user)
{ {
$this->authorize('view', $user); $this->authorize("view", $user);
$matches = Game::query() $query = \App\Models\Game::query()
->where(function ($q) use ($user) { ->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id)->orWhere( $q->where("player1_user_id", $user->id)->orWhere(
'player2_user_id', "player2_user_id",
$user->id, $user->id,
); );
}) })
->with(['winner', 'player1', 'player2']) ->with(["winner", "player1", "player2"]);
->orderBy('began_at', 'desc')
->paginate(10); if ($request->has("type")) {
$query->where("type", $request->type);
}
if ($request->has("status")) {
$query->where("status", $request->status);
}
if ($request->has("date_from")) {
$query->whereDate("began_at", ">=", $request->date_from);
}
if ($request->has("date_to")) {
$query->whereDate("began_at", "<=", $request->date_to);
}
$sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
$allowedColumns = [
"began_at",
"ended_at",
"total_time",
"total_points",
];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy("began_at", $sortDirection);
}
$games = $query->paginate(10);
$games->getCollection()->transform(function ($game) use ($user) {
if ($game->winner_user_id == $user->id) {
$game->outcome = "win";
} elseif ($game->is_draw) {
$game->outcome = "draw";
} else {
$game->outcome = "loss";
}
return $game;
});
return response()->json($games);
}
public function getMatches(Request $request, User $user)
{
$this->authorize("view", $user);
$query = MatchGame::query()
->where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->with(["winner", "player1", "player2"]);
if ($request->has("type")) {
$query->where("type", $request->type);
}
if ($request->has("status")) {
$query->where("status", $request->status);
}
if ($request->has("date_from")) {
$query->whereDate("began_at", ">=", $request->date_from);
}
if ($request->has("date_to")) {
$query->whereDate("began_at", "<=", $request->date_to);
}
$sortColumn = $request->get("sort_by", "began_at");
$sortDirection = $request->get("sort_direction", "desc");
$allowedColumns = ["began_at", "ended_at", "stake", "total_time"];
if (in_array($sortColumn, $allowedColumns)) {
$query->orderBy($sortColumn, $sortDirection);
} else {
$query->orderBy("began_at", $sortDirection);
}
$matches = $query->paginate(10);
$matches->getCollection()->transform(function ($match) use ($user) {
if ($match->winner_user_id == $user->id) {
$match->outcome = "win";
} elseif (
$match->winner_user_id &&
$match->winner_user_id != $user->id
) {
$match->outcome = "loss";
} else {
$match->outcome = "interrupted";
}
return $match;
});
return response()->json($matches); return response()->json($matches);
} }
public function block(User $user) public function block(User $user)
{ {
$this->authorize('block', $user); $this->authorize("block", $user);
$user->blocked = true; $user->blocked = true;
$user->save(); $user->save();
// Revoke all active tokens for immediate effect
$user->tokens()->delete(); $user->tokens()->delete();
return response()->json(['message' => 'User blocked successfully']); return response()->json(["message" => "User blocked successfully"]);
} }
public function unblock(User $user) public function unblock(User $user)
{ {
$this->authorize('unblock', $user); $this->authorize("unblock", $user);
$user->blocked = false; $user->blocked = false;
$user->save(); $user->save();
return response()->json(['message' => 'User unblocked successfully']); return response()->json(["message" => "User unblocked successfully"]);
} }
public function getTransactions(Request $request, User $user) public function getTransactions(Request $request, User $user)
{ {
if ($request->user()->id !== $user->id && $request->user()->type !== 'A') { if (
return response()->json(['message' => 'Forbidden'], 403); $request->user()->id !== $user->id &&
$request->user()->type !== "A"
) {
return response()->json(["message" => "Forbidden"], 403);
} }
$query = $user->transactions()->with('type'); $query = $user->transactions()->with("type");
if ($request->has('type')) { if ($request->has("type")) {
$query->whereHas('type', function ($q) use ($request) { $query->whereHas("type", function ($q) use ($request) {
$q->where('name', $request->type); $q->where("type", $request->type);
}); });
} }
if ($request->has('date_from')) { if ($request->has("date_from")) {
$query->whereDate('transaction_datetime', '>=', $request->date_from); $query->whereDate(
"transaction_datetime",
">=",
$request->date_from,
);
} }
if ($request->has('date_to')) { if ($request->has("date_to")) {
$query->whereDate('transaction_datetime', '<=', $request->date_to); $query->whereDate("transaction_datetime", "<=", $request->date_to);
} }
$transactions = $query->orderBy('transaction_datetime', 'desc') $transactions = $query
->orderBy("transaction_datetime", "desc")
->paginate(10); ->paginate(10);
return response()->json($transactions); return response()->json($transactions);
@@ -7,31 +7,18 @@ use Illuminate\Support\Facades\Auth;
class StoreGameRequest extends FormRequest class StoreGameRequest extends FormRequest
{ {
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool public function authorize(): bool
{ {
// Só utilizadores autenticados podem criar jogos
return Auth::check(); return Auth::check();
} }
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array public function rules(): array
{ {
return [ return [
// Agora validamos diretamente '3' ou '9' para bater certo com a BD
'type' => 'required|in:3,9', 'type' => 'required|in:3,9',
]; ];
} }
/**
* Mensagens personalizadas (Opcional)
*/
public function messages(): array public function messages(): array
{ {
return [ return [
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinPurchase extends Model
{
use HasFactory;
protected $table = 'coin_purchases';
public $timestamps = false;
protected $fillable = [
'purchase_datetime',
'user_id',
'coin_transaction_id',
'euros',
'payment_type',
'payment_reference',
'custom'
];
protected $casts = [
'purchase_datetime' => 'datetime',
'custom' => 'array',
];
public function transaction()
{
return $this->belongsTo(CoinTransaction::class, 'coin_transaction_id');
}
}
+6 -7
View File
@@ -4,17 +4,16 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CoinTransactionType extends Model class CoinTransactionType extends Model
{ {
use HasFactory, SoftDeletes; use HasFactory;
protected $table = 'coin_transaction_types'; public $timestamps = false;
protected $fillable = ['name', 'type', 'custom']; protected $fillable = [
'name',
protected $casts = [ 'type',
'custom' => 'array', 'description'
]; ];
} }
+11
View File
@@ -29,11 +29,22 @@ class Game extends Model
'match_id' 'match_id'
]; ];
protected $casts = [
'began_at' => 'datetime',
'ended_at' => 'datetime',
'is_draw' => 'boolean',
];
public function winner() public function winner()
{ {
return $this->belongsTo(User::class, "winner_user_id"); return $this->belongsTo(User::class, "winner_user_id");
} }
public function loser()
{
return $this->belongsTo(User::class, "loser_user_id");
}
public function player1() public function player1()
{ {
return $this->belongsTo(User::class, 'player1_user_id'); return $this->belongsTo(User::class, 'player1_user_id');
@@ -0,0 +1,18 @@
<?php
namespace App\Policies;
use App\Models\CoinTransaction;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class CoinTransactionPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->type === 'A' ? true : false;
}
}
+21 -35
View File
@@ -7,20 +7,14 @@ use App\Models\User;
class GamePolicy class GamePolicy
{ {
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool public function viewAny(User $user): bool
{ {
return true; return true;
} }
/**2
* Determine whether the user can view the model.
*/
public function view(User $user, Game $game): bool public function view(User $user, Game $game): bool
{ {
if ($user->type === 'A') { if ($user->type === "A") {
return true; return true;
} }
@@ -31,15 +25,16 @@ class GamePolicy
return true; return true;
} }
if ($game->status === "Pending") {
return true;
}
return false; return false;
} }
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool public function create(User $user): bool
{ {
if ($user->type === 'A') { if ($user->type === "A") {
return false; return false;
} }
@@ -48,28 +43,32 @@ class GamePolicy
public function join(User $user, Game $game): bool public function join(User $user, Game $game): bool
{ {
if ($user->type === 'A') { if ($user->type === "A") {
return false; return false;
} }
if ( if (
$game->status !== 'waiting' || $game->status !== "Pending" ||
$game->player1_user_id === $user->id $game->player1_user_id === $user->id
) { ) {
return false; return false;
} }
if ($game->player2_user_id !== null && $game->player2_user_id !== 1) {
return false;
}
return true; return true;
} }
public function resign(User $user, Game $game): bool public function resign(User $user, Game $game): bool
{ {
if ($user->type === 'A') { if ($user->type === "A") {
return false; return false;
} }
if ( if (
$game->status !== 'ongoing' || $game->status !== "Playing" ||
($game->player1_user_id !== $user->id && ($game->player1_user_id !== $user->id &&
$game->player2_user_id !== $user->id) $game->player2_user_id !== $user->id)
) { ) {
@@ -79,46 +78,33 @@ class GamePolicy
return true; return true;
} }
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Game $game): bool public function update(User $user, Game $game): bool
{ {
if ($user->type === 'A') { if ($user->type === "A") {
return false; return false;
} }
if ( if (!in_array($game->status, ['Playing', 'Pending'])) {
$game->status !== 'ongoing' || return false;
($game->player1_user_id !== $user->id && }
$game->player2_user_id !== $user->id)
) { if ($game->player1_user_id !== $user->id && $game->player2_user_id !== $user->id) {
return false; return false;
} }
return true; return true;
} }
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Game $game): bool public function delete(User $user, Game $game): bool
{ {
// Only admins can delete games return $user->type === "A";
return $user->type === 'A';
} }
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Game $game): bool public function restore(User $user, Game $game): bool
{ {
return false; return false;
} }
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Game $game): bool public function forceDelete(User $user, Game $game): bool
{ {
return false; return false;
-1
View File
@@ -17,6 +17,5 @@ return Application::configure(basePath: dirname(__DIR__))
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
//
}) })
->create(); ->create();
-5
View File
@@ -64,11 +64,6 @@ return [
'driver' => 'eloquent', 'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class), 'model' => env('AUTH_MODEL', App\Models\User::class),
], ],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
], ],
/* /*
-1
View File
@@ -61,7 +61,6 @@ return [
env('MEMCACHED_PASSWORD'), env('MEMCACHED_PASSWORD'),
], ],
'options' => [ 'options' => [
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
], ],
'servers' => [ 'servers' => [
[ [
-2376
View File
File diff suppressed because it is too large Load Diff
+22 -21
View File
@@ -6,19 +6,20 @@ use App\Http\Controllers\MatchController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StatisticsController; use App\Http\Controllers\StatisticsController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\CoinPurchaseController;
use App\Http\Controllers\TransactionController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
// Public Routes
Route::post('/login', [AuthController::class, 'login']); Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']); Route::post('/register', [AuthController::class, 'register']);
Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']); Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']);
Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']); Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
// Authenticated Routes
Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () { Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
Route::post('/logout', [AuthController::class, 'logout']); Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/coin-purchases', [CoinPurchaseController::class, 'store']);
Route::get('/statistics/me', [StatisticsController::class, 'getPersonalStats']); Route::get('/statistics/me', [StatisticsController::class, 'getPersonalStats']);
Route::prefix('users/me')->group(function () { Route::prefix('users/me')->group(function () {
Route::get('/', [ProfileController::class, 'show']); Route::get('/', [ProfileController::class, 'show']);
@@ -34,41 +35,41 @@ Route::prefix('v1')->group(function () {
Route::get('/transactions', [UserController::class, 'getMyTransactions']); Route::get('/transactions', [UserController::class, 'getMyTransactions']);
}); });
// User Resources
Route::prefix('/users')->group(function () { Route::prefix('/users')->group(function () {
Route::get('/{user}', [UserController::class, 'show']); Route::get('/{user}', [UserController::class, 'show']);
Route::get('/{user}/matches', [
UserController::class,
'getMatches',
]);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']); Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
Route::get('/{user}/games', [UserController::class, 'getGames']);
Route::get('/{user}/matches', [UserController::class, 'getMatches']);
}); });
// Game Resources
Route::prefix('games')->group(function () { Route::prefix('games')->group(function () {
Route::apiResource('/', GameController::class)->parameters([ Route::post('host', [GameController::class, 'host']);
'' => 'game', Route::get('open', [GameController::class, 'open']);
]); Route::get('/', [GameController::class, 'index']);
Route::post('/{game}/join', [GameController::class, 'join']); Route::get('/{game}', [GameController::class, 'show']);
Route::post('/', [GameController::class, 'store']);
Route::put('/{game}', [GameController::class, 'update']);
Route::delete('/{game}', [GameController::class, 'destroy']);
Route::post('/{game}/resign', [GameController::class, 'resign']); Route::post('/{game}/resign', [GameController::class, 'resign']);
Route::post('/{game}/join', [GameController::class, 'join']);
}); });
// Match Resources
Route::prefix('matches')->group(function () { Route::prefix('matches')->group(function () {
Route::apiResource('/', MatchController::class)->parameters([ Route::post('host', [MatchController::class, 'host']);
'' => 'match', Route::get('open', [MatchController::class, 'open']);
]); Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/join', [ Route::post('/{match}/start', [MatchController::class, 'start']);
MatchController::class, Route::delete('/{match}', [MatchController::class, 'destroy']);
'join', Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
]);
}); });
// Admin Routes
Route::middleware('user.type:A') Route::middleware('user.type:A')
->prefix('admin') ->prefix('admin')
->group(function () { ->group(function () {
Route::get('/statistics', [StatisticsController::class, 'getAdminStats']); Route::get('/statistics', [StatisticsController::class, 'getAdminStats']);
Route::get('/transactions', [TransactionController::class, 'index']);
Route::get('/games', [GameController::class, 'index']);
Route::get('/matches', [MatchController::class, 'index']);
Route::prefix('users')->group(function () { Route::prefix('users')->group(function () {
Route::get('/', [UserController::class, 'index']); Route::get('/', [UserController::class, 'index']);
Route::post('/', [UserController::class, 'store']); Route::post('/', [UserController::class, 'store']);
+54 -2
View File
@@ -5,7 +5,7 @@ Content-Type: application/json
Accept: application/json Accept: application/json
{ {
"email": "p[email protected]", "email": "a1@mail.pt",
"password": "123" "password": "123"
} }
@@ -14,7 +14,7 @@ Accept: application/json
@id = {{login.response.body.user.id}} @id = {{login.response.body.user.id}}
### Get All matches ### Get All matches
GET http://localhost:8000/api/v1/matches GET http://localhost:8000/api/v1/games
Content-Type: application/json Content-Type: application/json
Accept: application/json Accept: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
@@ -25,8 +25,60 @@ Content-Type: application/json
Accept: application/json Accept: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### Get me coins
GET http://localhost:8000/api/v1/users/me/coins
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### Get me matches ### Get me matches
GET http://localhost:8000/api/v1/users/{{id}}/matches GET http://localhost:8000/api/v1/users/{{id}}/matches
Content-Type: application/json Content-Type: application/json
Accept: application/json Accept: application/json
Authorization: Bearer {{token}} Authorization: Bearer {{token}}
### Get me transactions
GET http://localhost:8000/api/v1/users/{{id}}/transactions?type=C
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### post coin-purchases
POST http://localhost:8000/api/v1/coin-purchases
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
{
"euros": 1,
"payment_type": "MBWAY",
"payment_reference": "912345678"
}
### Get leaderboard
GET http://localhost:8000/api/v1/leaderboard
Content-Type: application/json
Accept: application/json
### Get me stats
GET http://localhost:8000/api/v1/statistics/me
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### Get public stats
GET http://localhost:8000/api/v1/statistics/public
Content-Type: application/json
Accept: application/json
### Get admin stats
GET http://localhost:8000/api/v1/admin/statistics
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### Get admin stats
GET http://localhost:8000/api/v1/admin/users
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
+15
View File
@@ -0,0 +1,15 @@
meta {
name: Create Game
type: http
seq: 3
}
post {
url: {{api_url}}/games
body: none
auth: inherit
}
settings {
encodeUrl: true
}
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: mysql name: mysql
namespace: dad-group-x namespace: dad-group-46
spec: spec:
selector: selector:
matchLabels: matchLabels:
@@ -37,7 +37,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: mysql name: mysql
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 3306 - port: 3306
+4 -4
View File
@@ -3,10 +3,10 @@ apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: app-ingress name: app-ingress
namespace: dad-group-x namespace: dad-group-46
spec: spec:
rules: rules:
- host: web-dad-group-x-172.22.21.253.sslip.io - host: web-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
@@ -16,7 +16,7 @@ spec:
name: vue-app name: vue-app
port: port:
number: 80 number: 80
- host: api-dad-group-x-172.22.21.253.sslip.io - host: api-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
@@ -26,7 +26,7 @@ spec:
name: laravel-app name: laravel-app
port: port:
number: 80 number: 80
- host: ws-dad-group-x-172.22.21.253.sslip.io - host: ws-dad-group-46-172.22.21.253.sslip.io
http: http:
paths: paths:
- path: / - path: /
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: laravel-app name: laravel-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: high-priority priorityClassName: high-priority
containers: containers:
- name: api - name: api
image: registry-172.22.21.115.sslip.io/dad-group-x/api:v1.0.0 image: registry-172.22.21.115.sslip.io/dad-group-46/api:v2.0.9
resources: resources:
requests: requests:
memory: "256Mi" memory: "256Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: laravel-app name: laravel-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 80 - port: 80
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: vue-app name: vue-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: low-priority priorityClassName: low-priority
containers: containers:
- name: web - name: web
image: registry-172.22.21.115.sslip.io/dad-group-46/web:v1.0.1 image: registry-172.22.21.115.sslip.io/dad-group-46/web:v2.0.9
resources: resources:
requests: requests:
memory: "64Mi" memory: "64Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: vue-app name: vue-app
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 80 - port: 80
+3 -3
View File
@@ -3,7 +3,7 @@ apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
name: websocket-server name: websocket-server
namespace: dad-group-x namespace: dad-group-46
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -17,7 +17,7 @@ spec:
priorityClassName: low-priority priorityClassName: low-priority
containers: containers:
- name: web - name: web
image: registry-172.22.21.115.sslip.io/dad-group-x/ws:v1.0.0 image: registry-172.22.21.115.sslip.io/dad-group-46/ws:v2.0.9
resources: resources:
requests: requests:
memory: "128Mi" memory: "128Mi"
@@ -31,7 +31,7 @@ apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: websocket-server name: websocket-server
namespace: dad-group-x namespace: dad-group-46
spec: spec:
ports: ports:
- port: 3000 - port: 3000
+2 -2
View File
@@ -16,7 +16,7 @@
"dependencies": { "dependencies": {
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"@tanstack/vue-table": "^8.21.3", "@tanstack/vue-table": "^8.21.3",
"@vueuse/core": "^14.0.0", "@vueuse/core": "^14.1.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -24,7 +24,7 @@
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"lucide-vue-next": "^0.554.0", "lucide-vue-next": "^0.554.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"reka-ui": "^2.6.0", "reka-ui": "^2.7.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17", "tailwindcss": "^4.1.17",
+2 -5
View File
@@ -3,8 +3,7 @@
<nav class="max-w-full p-5 flex flex-row justify-between align-middle"> <nav class="max-w-full p-5 flex flex-row justify-between align-middle">
<div class="align-middle text-xl"> <div class="align-middle text-xl">
<RouterLink to="/"> {{ pageTitle }} </RouterLink> <RouterLink to="/"> {{ pageTitle }} </RouterLink>
<span class="text-xs" v-if="authStore.currentUser" <span class="text-xs" v-if="authStore.currentUser">&nbsp;&nbsp;&nbsp; ({{ authStore.currentUser?.name }})
>&nbsp;&nbsp;&nbsp; ({{ authStore.currentUser?.name }})
</span> </span>
</div> </div>
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" /> <NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
@@ -29,8 +28,7 @@ import { useSocketStore } from './stores/socket'
const authStore = useAuthStore() const authStore = useAuthStore()
const socketStore = useSocketStore() const socketStore = useSocketStore()
const year = new Date().getFullYear() const pageTitle = ref(`DAD 2025/26`)
const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`)
const logout = () => { const logout = () => {
toast.promise(authStore.logout(), { toast.promise(authStore.logout(), {
@@ -44,7 +42,6 @@ const logout = () => {
onMounted(async () => { onMounted(async () => {
await authStore.restoreSession() await authStore.restoreSession()
socketStore.handleConnection()
}) })
</script> </script>
+1 -3
View File
@@ -88,7 +88,6 @@ const props = defineProps({
const deckPulse = ref(false) const deckPulse = ref(false)
const trumpReveal = ref(false) const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch( watch(
() => props.cardsRemaining, () => props.cardsRemaining,
() => { () => {
@@ -99,7 +98,6 @@ watch(
}, },
) )
// Trump reveal animation
watch( watch(
() => props.revealTrump, () => props.revealTrump,
(newValue) => { (newValue) => {
@@ -107,7 +105,7 @@ watch(
trumpReveal.value = true trumpReveal.value = true
setTimeout(() => { setTimeout(() => {
trumpReveal.value = false trumpReveal.value = false
}, 3000) // Show for 3 seconds }, 3000)
} }
}, },
) )
@@ -54,18 +54,14 @@ const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 }) const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => { onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition } currentPosition.value = { ...props.startPosition }
isVisible.value = true isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => { setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => { requestAnimationFrame(() => {
isAnimating.value = true isAnimating.value = true
currentPosition.value = { ...props.endPosition } currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => { setTimeout(() => {
emit('complete') emit('complete')
isVisible.value = false isVisible.value = false
+6 -1
View File
@@ -7,6 +7,8 @@
:player-score="playerScore" :player-score="playerScore"
:opponent-score="opponentScore" :opponent-score="opponentScore"
:current-turn="currentTurn" :current-turn="currentTurn"
:player-name="playerName"
:opponent-name="opponentName"
:round-number="1" :round-number="1"
/> />
</div> </div>
@@ -59,19 +61,22 @@ const props = defineProps({
cardsRemaining: { type: Number, default: 0 }, cardsRemaining: { type: Number, default: 0 },
deckIsEmpty: { type: Boolean, default: false }, deckIsEmpty: { type: Boolean, default: false },
maxCards: { type: Number, default: 3 }, maxCards: { type: Number, default: 3 },
// REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 }, playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 }, opponentScore: { type: Number, default: 0 },
playerName: { type: String, default: 'You' },
opponentName: { type: String, default: 'Bot' },
currentTurn: { currentTurn: {
type: String, type: String,
default: 'player', default: 'player',
validator: (value) => ['player', 'opponent'].includes(value), validator: (value) => ['player', 'opponent'].includes(value),
}, },
isMyTurn: { type: Boolean, default: false },
}) })
const emit = defineEmits(['play-card']) const emit = defineEmits(['play-card'])
const handleCardClick = (payload) => { const handleCardClick = (payload) => {
if (!props.isMyTurn) return
emit('play-card', payload.card) emit('play-card', payload.card)
} }
</script> </script>
@@ -218,21 +218,18 @@ const showConfetti = ref(false)
const displayPlayerScore = ref(0) const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0) const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => { const title = computed(() => {
if (props.winner === 'player') return 'Victory!' if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat' if (props.winner === 'opponent') return 'Defeat'
return 'Draw!' return 'Draw!'
}) })
// Computed subtitle
const subtitle = computed(() => { const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!' if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!' if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw' return 'The game ended in a draw'
}) })
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 1000 const duration = 1000
const steps = 30 const steps = 30
@@ -255,19 +252,16 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for visibility and trigger animations
watch( watch(
() => props.isVisible, () => props.isVisible,
(newValue) => { (newValue) => {
if (newValue) { if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') { if (props.winner === 'player') {
setTimeout(() => { setTimeout(() => {
showConfetti.value = true showConfetti.value = true
}, 300) }, 300)
} }
// Animate scores counting up
setTimeout(() => { setTimeout(() => {
animateScore(0, props.playerScore, (value) => { animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value displayPlayerScore.value = value
@@ -51,6 +51,10 @@ const props = defineProps({
default: 3, default: 3,
validator: (value) => [3, 9].includes(value), validator: (value) => [3, 9].includes(value),
}, },
isDisabled: {
type: Boolean,
default: false,
},
}) })
const emit = defineEmits(['card-clicked']) const emit = defineEmits(['card-clicked'])
@@ -33,7 +33,8 @@
turnChanged && 'animate-pulse', turnChanged && 'animate-pulse',
]" ]"
> >
{{ currentTurn === 'player' ? 'Your Turn' : "Bot's Turn" }} <!-- You can replace the above line with the following if you want to show names instead -->
{{ currentTurn === 'player' ? playerName + "'s Turn" : opponentName + "'s Turn" }}
</div> </div>
<span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium"> <span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium">
Round {{ roundNumber }} Round {{ roundNumber }}
@@ -98,10 +99,9 @@ const turnChanged = ref(false)
const displayPlayerScore = ref(props.playerScore) const displayPlayerScore = ref(props.playerScore)
const displayOpponentScore = ref(props.opponentScore) const displayOpponentScore = ref(props.opponentScore)
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => { const animateScore = (fromValue, toValue, callback) => {
const duration = 500 // Total animation duration in ms const duration = 500
const steps = 20 // Number of increments const steps = 20
const stepDuration = duration / steps const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps const increment = (toValue - fromValue) / steps
@@ -121,7 +121,6 @@ const animateScore = (fromValue, toValue, callback) => {
}, stepDuration) }, stepDuration)
} }
// Watch for player score changes and trigger count-up animation
watch( watch(
() => props.playerScore, () => props.playerScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -142,7 +141,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for opponent score changes and trigger count-up animation
watch( watch(
() => props.opponentScore, () => props.opponentScore,
(newScore, oldScore) => { (newScore, oldScore) => {
@@ -163,7 +161,6 @@ watch(
{ immediate: true }, { immediate: true },
) )
// Watch for turn changes and trigger pulse animation
watch( watch(
() => props.currentTurn, () => props.currentTurn,
() => { () => {
+13 -36
View File
@@ -2,20 +2,6 @@
<div> <div>
<NavigationMenu> <NavigationMenu>
<NavigationMenuList class="justify-around gap-20"> <NavigationMenuList class="justify-around gap-20">
<NavigationMenuItem>
<NavigationMenuTrigger>Testing</NavigationMenuTrigger>
<NavigationMenuContent>
<li>
<NavigationMenuLink as-child>
<RouterLink to="/testing/laravel">Laravel</RouterLink>
</NavigationMenuLink>
<NavigationMenuLink as-child>
<RouterLink to="/testing/websockets">Web Sockets</RouterLink>
</NavigationMenuLink>
</li>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn"> <NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuLink> <NavigationMenuLink>
<RouterLink to="/login">Login</RouterLink> <RouterLink to="/login">Login</RouterLink>
@@ -23,14 +9,14 @@
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem v-else class="flex flex-row gap-4"> <NavigationMenuItem v-else class="flex flex-row gap-4">
<NavigationMenuLink v-if="coins > 0" href="/user?tab=transaction-history" <NavigationMenuLink v-if="userStore.coins > 0" href="/coins-purchase"
class="flex flex-row items-center gap-1"> class="flex flex-row items-center gap-1">
<div class="flex items-center justify-center w-5 h-5 bg-purple-400 rounded-full shadow-sm"> <div class="flex items-center justify-center w-5 h-5 bg-purple-400 rounded-full shadow-sm">
<span class="text-[10px] font-bold text-purple-900">$</span> <span class="text-[10px] font-bold text-purple-900">$</span>
</div> </div>
<span class="text-sm font-medium tabular-nums"> <span class="text-sm font-medium tabular-nums">
{{ coins }} <span class="text-muted-foreground text-xs">Coins</span> {{ userStore.coins }} <span class="text-muted-foreground text-xs">Coins</span>
</span> </span>
</NavigationMenuLink> </NavigationMenuLink>
<NavigationMenuLink> <NavigationMenuLink>
@@ -55,21 +41,19 @@ import {
NavigationMenuList, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuTrigger,
} from '@/components/ui/navigation-menu' } from '@/components/ui/navigation-menu'
import router from '@/router'; import router from '@/router'
import { ref, watch } from 'vue'; import { watch } from 'vue'
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user'
const emits = defineEmits(['logout']) const emits = defineEmits(['logout'])
const { userLoggedIn } = defineProps(['userLoggedIn']) const { userLoggedIn } = defineProps(['userLoggedIn'])
const coins = ref(0);
const biscaStore = useBiscaStore() const biscaStore = useBiscaStore()
const logoutClickHandler = () => { const logoutClickHandler = () => {
if (biscaStore.isGameRunning) { if (biscaStore.isGameRunning) {
const confirmLogout = window.confirm( const confirmLogout = window.confirm(
'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?' 'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?',
) )
if (!confirmLogout) return if (!confirmLogout) return
@@ -84,22 +68,15 @@ const logoutClickHandler = () => {
router.push('/login') router.push('/login')
} }
const fetchUserCoins = async () => { const userStore = useUserStore()
if (userLoggedIn) {
try {
const response = await useUserStore().getCoins()
coins.value = response.data?.coins || 0
} catch (error) {
console.error('Error fetching user coins', error)
}
}
}
watch(() => userLoggedIn, async (isLoggedIn) => { watch(
() => userLoggedIn,
async (isLoggedIn) => {
if (isLoggedIn) { if (isLoggedIn) {
await fetchUserCoins() await userStore.fetchCoins()
} else { } else {
coins.value = 0 userStore.coins = 0
} }
}, { immediate: true }) }, { immediate: true })
</script> </script>
@@ -0,0 +1,29 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>
@@ -0,0 +1 @@
export { default as Label } from "./Label.vue";
@@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: Boolean, required: false },
modelValue: { type: [Boolean, null], required: false },
disabled: { type: Boolean, required: false },
id: { type: String, required: false },
value: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SwitchRoot
v-slot="slotProps"
data-slot="switch"
v-bind="forwarded"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SwitchThumb
data-slot="switch-thumb"
:class="
cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)
"
>
<slot name="thumb" v-bind="slotProps" />
</SwitchThumb>
</SwitchRoot>
</template>
@@ -0,0 +1 @@
export { default as Switch } from "./Switch.vue";
-5
View File
@@ -1,19 +1,15 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { io } from 'socket.io-client'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
const apiDomain = import.meta.env.VITE_API_DOMAIN const apiDomain = import.meta.env.VITE_API_DOMAIN
const wsConnection = import.meta.env.VITE_WS_CONNECTION
console.log('[main.js] api domain', apiDomain) console.log('[main.js] api domain', apiDomain)
console.log('[main.js] ws connection', wsConnection)
const app = createApp(App) const app = createApp(App)
//app.provide('socket', io(wsConnection))
app.provide('serverBaseURL', `http://${apiDomain}`) app.provide('serverBaseURL', `http://${apiDomain}`)
app.provide('apiBaseURL', `http://${apiDomain}/api/v1`) app.provide('apiBaseURL', `http://${apiDomain}/api/v1`)
@@ -21,4 +17,3 @@ app.use(createPinia())
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')
-342
View File
@@ -1,342 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">
All Animations Test Page
</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<!-- Score Animation Tests -->
<div class="mb-6">
<h3 class="text-emerald-400 font-semibold mb-3">Score Count-Up Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="increasePlayerScore"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
+10 Player Score
</button>
<button
@click="increaseOpponentScore"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
+10 Opponent Score
</button>
<button
@click="resetScores"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Reset Scores
</button>
</div>
</div>
<!-- Turn Transition Tests -->
<div class="mb-6">
<h3 class="text-blue-400 font-semibold mb-3">Turn Transition Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="toggleTurn"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Switch Turn
</button>
<button
@click="currentTurn = 'player'"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Your Turn
</button>
<button
@click="currentTurn = 'opponent'"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Bot's Turn
</button>
</div>
</div>
<!-- Trump Reveal Tests -->
<div class="mb-6">
<h3 class="text-amber-400 font-semibold mb-3">Trump Reveal Animation</h3>
<div class="flex flex-wrap gap-3">
<button
@click="triggerTrumpReveal"
class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium transition-colors"
>
Reveal Trump
</button>
</div>
</div>
<!-- Game Over Tests -->
<div class="mb-6">
<h3 class="text-purple-400 font-semibold mb-3">Game Over Screen</h3>
<div class="flex flex-wrap gap-3">
<button
@click="showGameOverPlayerWin"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="showGameOverOpponentWin"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="showGameOverDraw"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Draw
</button>
</div>
</div>
<!-- Full Sequence -->
<div>
<h3 class="text-pink-400 font-semibold mb-3">Complete Sequence</h3>
<div class="flex flex-wrap gap-3">
<button
@click="runFullSequence"
:disabled="isRunningSequence"
class="px-4 py-2 bg-pink-600 hover:bg-pink-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Run Full Animation Sequence
</button>
</div>
</div>
</div>
<!-- Game Board Preview -->
<div class="grid grid-rows-[auto_1fr] gap-8">
<!-- Score Display -->
<div class="flex justify-center">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Deck Area (with trump) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Trump Card</h3>
<DeckArea
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="false"
:reveal-trump="revealTrump"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li>
<strong>Score Count-Up:</strong> Click "+10" buttons to see scores animate smoothly
from old to new value
</li>
<li>
<strong>Turn Transition:</strong> Switch turns to see the indicator pulse and glow with
color change
</li>
<li>
<strong>Trump Reveal:</strong> Triggers a 3-second pulse/glow animation on the trump
card with label
</li>
<li>
<strong>Game Over:</strong> Shows victory/defeat modal with score count-up and confetti
(on win)
</li>
<li>
<strong>Full Sequence:</strong> Runs all animations in order: trump reveal → turn
changes → score updates → game over
</li>
</ul>
</div>
</div>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="handlePlayAgain"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import GameOver from '@/components/game/GameOver.vue'
// Game state
const playerScore = ref(23)
const opponentScore = ref(15)
const currentTurn = ref('player')
const cardsRemaining = ref(30)
const revealTrump = ref(false)
const isRunningSequence = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Game Over state
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 8,
opponentTricks: 6,
})
// Score manipulation
const increasePlayerScore = () => {
playerScore.value += 10
}
const increaseOpponentScore = () => {
opponentScore.value += 10
}
const resetScores = () => {
playerScore.value = 0
opponentScore.value = 0
}
// Turn control
const toggleTurn = () => {
currentTurn.value = currentTurn.value === 'player' ? 'opponent' : 'player'
}
// Trump reveal
const triggerTrumpReveal = () => {
revealTrump.value = true
setTimeout(() => {
revealTrump.value = false
}, 100)
}
// Game Over screens
const showGameOverPlayerWin = () => {
playerScore.value = 67
opponentScore.value = 53
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
}
const showGameOverOpponentWin = () => {
playerScore.value = 45
opponentScore.value = 75
gameOverWinner.value = 'opponent'
gameOverStats.value = {
playerTricks: 7,
opponentTricks: 13,
}
gameOverVisible.value = true
}
const showGameOverDraw = () => {
playerScore.value = 60
opponentScore.value = 60
gameOverWinner.value = 'draw'
gameOverStats.value = {
playerTricks: 10,
opponentTricks: 10,
}
gameOverVisible.value = true
}
const handlePlayAgain = () => {
gameOverVisible.value = false
resetScores()
currentTurn.value = 'player'
cardsRemaining.value = 40
}
// Full animation sequence
const runFullSequence = async () => {
if (isRunningSequence.value) return
isRunningSequence.value = true
// Reset everything
resetScores()
currentTurn.value = 'player'
gameOverVisible.value = false
await new Promise((resolve) => setTimeout(resolve, 500))
// 1. Trump Reveal
console.log('1. Trump Reveal')
triggerTrumpReveal()
await new Promise((resolve) => setTimeout(resolve, 3500))
// 2. First turn
console.log('2. Player turn')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 3. Player scores
console.log('3. Player scores')
playerScore.value = 11
await new Promise((resolve) => setTimeout(resolve, 2000))
// 4. Switch turn
console.log('4. Switch to opponent turn')
currentTurn.value = 'opponent'
await new Promise((resolve) => setTimeout(resolve, 1500))
// 5. Opponent scores
console.log('5. Opponent scores')
opponentScore.value = 10
await new Promise((resolve) => setTimeout(resolve, 2000))
// 6. Back to player
console.log('6. Back to player')
currentTurn.value = 'player'
await new Promise((resolve) => setTimeout(resolve, 1000))
// 7. More scoring
console.log('7. More scoring')
playerScore.value = 23
await new Promise((resolve) => setTimeout(resolve, 1500))
opponentScore.value = 19
await new Promise((resolve) => setTimeout(resolve, 1500))
playerScore.value = 34
await new Promise((resolve) => setTimeout(resolve, 1500))
// 8. Final scores and game over
console.log('8. Game over')
playerScore.value = 67
opponentScore.value = 53
await new Promise((resolve) => setTimeout(resolve, 2000))
// Show game over
gameOverWinner.value = 'player'
gameOverStats.value = {
playerTricks: 11,
opponentTricks: 9,
}
gameOverVisible.value = true
isRunningSequence.value = false
}
</script>
-252
View File
@@ -1,252 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-4xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Animation Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3">
<button
@click="playPlayerCard"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-medium transition-colors"
>
Play Player Card
</button>
<button
@click="playOpponentCard"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Play Opponent Card
</button>
<button
@click="playBothCards"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="clearCards"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg font-medium transition-colors"
>
Clear Cards
</button>
<button
@click="setPlayerWinner"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium transition-colors"
>
Player Wins
</button>
<button
@click="setOpponentWinner"
class="px-4 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded-lg font-medium transition-colors"
>
Opponent Wins
</button>
<button
@click="fullSequence"
class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium transition-colors"
>
Full Trick Sequence
</button>
<button
@click="collectTrickPlayerWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-cyan-600 hover:bg-cyan-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Player Wins)
</button>
<button
@click="collectTrickOpponentWins"
:disabled="!playerCard || !opponentCard"
class="px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Collect Trick (Opponent Wins)
</button>
</div>
<!-- Current State Display -->
<div class="mt-4 text-gray-300 text-sm">
<p>
<strong>Player Card:</strong>
{{ playerCard ? `${playerCard.suit}${playerCard.rank}` : 'None' }}
</p>
<p>
<strong>Opponent Card:</strong>
{{ opponentCard ? `${opponentCard.suit}${opponentCard.rank}` : 'None' }}
</p>
<p><strong>Winner:</strong> {{ winner || 'None' }}</p>
<p><strong>First Player:</strong> {{ firstPlayer || 'None' }}</p>
</div>
</div>
<!-- PlayArea Component -->
<div class="bg-gray-800/50 rounded-lg p-8">
<h2 class="text-white text-xl font-semibold mb-4 text-center">Play Area</h2>
<PlayArea
:player-card="playerCard"
:opponent-card="opponentCard"
:winner="winner"
:first-player="firstPlayer"
/>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Play Player Card:</strong> Animates a card from bottom (player's hand)</li>
<li><strong>Play Opponent Card:</strong> Animates a card from top (opponent's hand)</li>
<li><strong>Play Both Cards:</strong> Both cards animate in simultaneously</li>
<li><strong>Clear Cards:</strong> Triggers exit animations (cards slide out)</li>
<li>
<strong>Winner Buttons:</strong> Add glowing ring effect to winning card (play cards
first)
</li>
<li>
<strong>Full Trick Sequence:</strong> Plays both cards shows winner clears (2 second
delay)
</li>
<li>
<strong>Collect Trick (Player Wins):</strong> Sets player as winner, waits 1.5s, then
cards slide DOWN toward player area
</li>
<li>
<strong>Collect Trick (Opponent Wins):</strong> Sets opponent as winner, waits 1.5s,
then cards slide UP toward opponent area
</li>
</ul>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import PlayArea from '@/components/game/PlayArea.vue'
// Reactive state
const playerCard = ref(null)
const opponentCard = ref(null)
const winner = ref(null)
const firstPlayer = ref(null)
// Sample cards to test with
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 7 },
]
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Control functions
const playPlayerCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'player'
}
playerCard.value = getRandomCard()
winner.value = null
}
const playOpponentCard = () => {
if (!playerCard.value && !opponentCard.value) {
firstPlayer.value = 'opponent'
}
opponentCard.value = getRandomCard()
winner.value = null
}
const playBothCards = () => {
firstPlayer.value = 'player' // Player plays first in this scenario
playerCard.value = getRandomCard()
opponentCard.value = getRandomCard()
winner.value = null
}
const clearCards = () => {
playerCard.value = null
opponentCard.value = null
winner.value = null
firstPlayer.value = null
}
const setPlayerWinner = () => {
if (playerCard.value) {
winner.value = 'player'
}
}
const setOpponentWinner = () => {
if (opponentCard.value) {
winner.value = 'opponent'
}
}
const fullSequence = async () => {
// Clear first
clearCards()
await new Promise((resolve) => setTimeout(resolve, 500))
// Play both cards
playBothCards()
await new Promise((resolve) => setTimeout(resolve, 1000))
// Show winner (randomly)
winner.value = Math.random() > 0.5 ? 'player' : 'opponent'
await new Promise((resolve) => setTimeout(resolve, 2000))
// Clear cards
clearCards()
}
// Collect trick with player winning
const collectTrickPlayerWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set player as winner
winner.value = 'player'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward player)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
// Collect trick with opponent winning
const collectTrickOpponentWins = async () => {
if (!playerCard.value || !opponentCard.value) return
// Set opponent as winner
winner.value = 'opponent'
// Wait 1.5 seconds to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Clear cards (triggers exit animation toward opponent)
playerCard.value = null
opponentCard.value = null
// Reset after animation completes
await new Promise((resolve) => setTimeout(resolve, 600))
winner.value = null
firstPlayer.value = null
}
</script>
-275
View File
@@ -1,275 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900 p-8">
<div class="max-w-6xl mx-auto">
<!-- Title -->
<h1 class="text-white text-3xl font-bold mb-8 text-center">Card Dealing Test Page</h1>
<!-- Control Panel -->
<div class="bg-gray-900/80 rounded-lg p-6 mb-8">
<h2 class="text-white text-xl font-semibold mb-4">Controls</h2>
<div class="flex flex-wrap gap-3 mb-4">
<button
@click="startDealing"
:disabled="isDealing"
class="px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Start Dealing
</button>
<button
@click="dealSingleCard"
:disabled="isDealing || cardsRemaining === 0"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg font-medium transition-colors"
>
Deal One Card
</button>
<button
@click="reset"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium transition-colors"
>
Reset
</button>
</div>
<!-- Current State Display -->
<div class="grid grid-cols-3 gap-4 text-gray-300 text-sm">
<div>
<p><strong>Player Hand:</strong> {{ playerHand.length }} cards</p>
<p><strong>Opponent Hand:</strong> {{ opponentHand.length }} cards</p>
</div>
<div>
<p><strong>Cards Remaining:</strong> {{ cardsRemaining }}</p>
<p><strong>Dealing:</strong> {{ isDealing ? 'Yes' : 'No' }}</p>
</div>
<div>
<p><strong>Next Recipient:</strong> {{ nextRecipient }}</p>
</div>
</div>
</div>
<!-- Game Board Layout -->
<div class="grid grid-rows-[auto_1fr_auto] gap-8">
<!-- Opponent Hand (Top) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Opponent Hand</h3>
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
<!-- Center Area (Deck) -->
<div class="flex items-center justify-center">
<div class="bg-gray-800/30 rounded-lg p-6">
<h3 class="text-white text-sm font-semibold mb-4 text-center">Deck</h3>
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
/>
</div>
</div>
<!-- Player Hand (Bottom) -->
<div class="flex justify-center">
<div class="bg-gray-800/30 rounded-lg p-4">
<h3 class="text-white text-sm font-semibold mb-2 text-center">Your Hand</h3>
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="[]"
/>
</div>
</div>
</div>
<!-- Instructions -->
<div class="bg-gray-900/80 rounded-lg p-6 mt-8 text-gray-300">
<h3 class="text-white font-semibold mb-2">Instructions:</h3>
<ul class="list-disc list-inside space-y-1 text-sm">
<li><strong>Start Dealing:</strong> Automatically deals 6 cards (3 to each player) with 200ms delay</li>
<li><strong>Deal One Card:</strong> Manually deal a single card to the next recipient</li>
<li><strong>Reset:</strong> Clear all hands and reset the deck to 40 cards</li>
<li>Watch the FlyingCard animation as cards move from deck to hands</li>
<li>Notice the deck counter decreasing and the pulse effect</li>
<li>Cards appear in hands with a fade-in animation</li>
</ul>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
// Refs for components
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const cardsRemaining = ref(40)
const isDealing = ref(false)
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Trump card (fixed for testing)
const trumpCard = ref({ suit: 'o', rank: 7 })
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Computed
const nextRecipient = computed(() => {
const totalDealt = playerHand.value.length + opponentHand.value.length
return totalDealt % 2 === 0 ? 'Player' : 'Opponent'
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get position of an element
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64, // Center, minus half card width
y: rect.top + rect.height / 2 - 89, // Center, minus half card height
}
}
// Deal a single card
const dealSingleCard = async () => {
if (cardsRemaining.value === 0) return
await nextTick()
// Determine recipient
const totalDealt = playerHand.value.length + opponentHand.value.length
const isPlayer = totalDealt % 2 === 0
// Get positions
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
// Create flying card
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer, // Face down for opponent
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card animation complete
const onFlyingCardComplete = (flyingCard) => {
// Add card to appropriate hand
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
// Remove flying card
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Start automatic dealing sequence
const startDealing = async () => {
if (isDealing.value) return
isDealing.value = true
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
await dealSingleCard()
// Wait 200ms before next card
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Reset everything
const reset = () => {
playerHand.value = []
opponentHand.value = []
cardsRemaining.value = 40
flyingCards.value = []
isDealing.value = false
}
</script>
-444
View File
@@ -1,444 +0,0 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-green-700 via-green-800 to-green-900">
<!-- Control Panel (Floating) -->
<div class="fixed top-4 left-4 bg-gray-900/95 rounded-lg p-4 max-w-xs z-50 shadow-2xl">
<h3 class="text-white font-bold mb-3 text-sm">Test Controls</h3>
<div class="space-y-2">
<button
@click="dealInitialCards"
:disabled="isDealing"
class="w-full px-3 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Deal Cards
</button>
<button
@click="playRandomCards"
:disabled="playerHand.length === 0"
class="w-full px-3 py-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Play Both Cards
</button>
<button
@click="collectTrick"
:disabled="!currentTrick.playerCard || !currentTrick.opponentCard"
class="w-full px-3 py-2 bg-purple-600 hover:bg-purple-700 disabled:bg-gray-600 text-white rounded text-xs font-medium transition-colors"
>
Collect Trick
</button>
<button
@click="revealTrump = !revealTrump"
class="w-full px-3 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded text-xs font-medium transition-colors"
>
{{ revealTrump ? 'Hide' : 'Reveal' }} Trump
</button>
<button
@click="showGameOver"
class="w-full px-3 py-2 bg-rose-600 hover:bg-rose-700 text-white rounded text-xs font-medium transition-colors"
>
Show Game Over
</button>
<button
@click="resetGame"
class="w-full px-3 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded text-xs font-medium transition-colors"
>
Reset Game
</button>
<div class="pt-2 border-t border-gray-700 mt-2 text-gray-400 text-xs">
<p><strong>Turn:</strong> {{ currentTurn === 'player' ? 'You' : 'Bot' }}</p>
<p><strong>Deck:</strong> {{ cardsRemaining }} cards</p>
</div>
</div>
</div>
<!-- Main Game Board -->
<div
class="grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8 min-h-screen"
>
<!-- Score Display (Top Left) -->
<div class="col-start-1 col-end-4 row-start-1 flex justify-start items-start">
<ScoreDisplay
:player-score="playerScore"
:opponent-score="opponentScore"
:current-turn="currentTurn"
:round-number="1"
/>
</div>
<!-- Opponent Hand -->
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
ref="opponentHandRef"
:cards="opponentHand"
:face-down="true"
:max-cards="3"
:playable-cards="[]"
/>
</div>
<!-- Play Area -->
<div class="col-start-2 col-end-3 row-start-3 flex items-center justify-center">
<PlayArea
:player-card="currentTrick.playerCard"
:opponent-card="currentTrick.opponentCard"
:winner="currentTrick.winner"
:first-player="currentTrick.firstPlayer"
/>
</div>
<!-- Deck Area -->
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea
ref="deckRef"
:trump-card="trumpCard"
:cards-remaining="cardsRemaining"
:is-empty="cardsRemaining === 0"
:reveal-trump="revealTrump"
/>
</div>
<!-- Player Hand -->
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
ref="playerHandRef"
:cards="playerHand"
:face-down="false"
:max-cards="3"
:playable-cards="playableCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
<!-- Flying Cards Container -->
<FlyingCard
v-for="flyingCard in flyingCards"
:key="flyingCard.id"
:card="flyingCard.card"
:start-position="flyingCard.startPosition"
:end-position="flyingCard.endPosition"
:duration="500"
:face-down="flyingCard.faceDown"
:delay="flyingCard.delay"
@complete="onFlyingCardComplete(flyingCard)"
/>
<!-- Game Over Modal -->
<GameOver
:is-visible="gameOverVisible"
:winner="gameOverWinner"
:player-score="playerScore"
:opponent-score="opponentScore"
:stats="gameOverStats"
@close="gameOverVisible = false"
@play-again="resetGame"
/>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import GameBoard from '@/components/game/GameBoard.vue'
import ScoreDisplay from '@/components/game/ScoreDisplay.vue'
import PlayerHand from '@/components/game/PlayerHand.vue'
import PlayArea from '@/components/game/PlayArea.vue'
import DeckArea from '@/components/game/DeckArea.vue'
import FlyingCard from '@/components/game/FlyingCard.vue'
import GameOver from '@/components/game/GameOver.vue'
// Component refs
const playerHandRef = ref(null)
const opponentHandRef = ref(null)
const deckRef = ref(null)
// Game state
const playerHand = ref([])
const opponentHand = ref([])
const playerScore = ref(0)
const opponentScore = ref(0)
const currentTurn = ref('player')
const cardsRemaining = ref(40)
const isDealing = ref(false)
const revealTrump = ref(false)
// Trump card
const trumpCard = ref({ suit: 'o', rank: 7 })
// Current trick
const currentTrick = ref({
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
})
// Flying cards for dealing animation
const flyingCards = ref([])
let flyingCardIdCounter = 0
// Game Over
const gameOverVisible = ref(false)
const gameOverWinner = ref('player')
const gameOverStats = ref({
playerTricks: 0,
opponentTricks: 0,
})
// Sample deck
const sampleCards = [
{ suit: 'c', rank: 1 },
{ suit: 'c', rank: 2 },
{ suit: 'c', rank: 3 },
{ suit: 'c', rank: 4 },
{ suit: 'c', rank: 5 },
{ suit: 'c', rank: 6 },
{ suit: 'c', rank: 7 },
{ suit: 'c', rank: 11 },
{ suit: 'c', rank: 12 },
{ suit: 'c', rank: 13 },
{ suit: 'e', rank: 1 },
{ suit: 'e', rank: 2 },
{ suit: 'e', rank: 3 },
{ suit: 'e', rank: 4 },
{ suit: 'e', rank: 5 },
{ suit: 'e', rank: 6 },
{ suit: 'e', rank: 7 },
{ suit: 'e', rank: 11 },
{ suit: 'e', rank: 12 },
{ suit: 'e', rank: 13 },
{ suit: 'o', rank: 1 },
{ suit: 'o', rank: 2 },
{ suit: 'o', rank: 3 },
{ suit: 'o', rank: 4 },
{ suit: 'o', rank: 5 },
{ suit: 'o', rank: 6 },
{ suit: 'p', rank: 1 },
{ suit: 'p', rank: 2 },
{ suit: 'p', rank: 3 },
{ suit: 'p', rank: 4 },
{ suit: 'p', rank: 5 },
{ suit: 'p', rank: 6 },
{ suit: 'p', rank: 7 },
{ suit: 'p', rank: 11 },
{ suit: 'p', rank: 12 },
{ suit: 'p', rank: 13 },
]
// Playable cards (all cards in hand for testing)
const playableCards = computed(() => {
return playerHand.value.map((_, index) => index)
})
// Get random card
const getRandomCard = () => {
return sampleCards[Math.floor(Math.random() * sampleCards.length)]
}
// Get element position
const getElementPosition = (el) => {
if (!el) return { x: 0, y: 0 }
const rect = el.$el?.getBoundingClientRect() || el.getBoundingClientRect()
return {
x: rect.left + rect.width / 2 - 64,
y: rect.top + rect.height / 2 - 89,
}
}
// Deal a single card
const dealSingleCard = async (isPlayer) => {
if (cardsRemaining.value === 0) return
await nextTick()
const startPos = getElementPosition(deckRef.value)
const endPos = getElementPosition(isPlayer ? playerHandRef.value : opponentHandRef.value)
const card = getRandomCard()
const flyingCard = {
id: flyingCardIdCounter++,
card,
startPosition: startPos,
endPosition: endPos,
faceDown: !isPlayer,
delay: 0,
recipient: isPlayer ? 'player' : 'opponent',
}
flyingCards.value.push(flyingCard)
cardsRemaining.value--
}
// Flying card complete
const onFlyingCardComplete = (flyingCard) => {
if (flyingCard.recipient === 'player') {
playerHand.value.push(flyingCard.card)
} else {
opponentHand.value.push(flyingCard.card)
}
flyingCards.value = flyingCards.value.filter((fc) => fc.id !== flyingCard.id)
}
// Deal initial cards
const dealInitialCards = async () => {
if (isDealing.value) return
isDealing.value = true
// Reveal trump at start
revealTrump.value = true
await new Promise((resolve) => setTimeout(resolve, 1000))
revealTrump.value = false
// Deal 6 cards (3 to each player)
for (let i = 0; i < 6; i++) {
if (cardsRemaining.value === 0) break
const isPlayer = i % 2 === 0
await dealSingleCard(isPlayer)
await new Promise((resolve) => setTimeout(resolve, 700))
}
isDealing.value = false
}
// Handle card click from player hand
const handleCardClick = ({ card, index }) => {
if (currentTurn.value !== 'player') return
if (currentTrick.value.playerCard) return
// Set first player if no cards played yet
if (!currentTrick.value.opponentCard) {
currentTrick.value.firstPlayer = 'player'
}
// Play the card
currentTrick.value.playerCard = card
playerHand.value.splice(index, 1)
// Switch turn
currentTurn.value = 'opponent'
// Auto-play opponent card after delay
setTimeout(() => {
if (opponentHand.value.length > 0 && !currentTrick.value.opponentCard) {
playOpponentCard()
}
}, 1000)
}
// Play opponent card
const playOpponentCard = () => {
if (opponentHand.value.length === 0) return
// Set first player if no cards played yet
if (!currentTrick.value.playerCard) {
currentTrick.value.firstPlayer = 'opponent'
}
// Play random card
const randomIndex = Math.floor(Math.random() * opponentHand.value.length)
const card = opponentHand.value[randomIndex]
currentTrick.value.opponentCard = card
opponentHand.value.splice(randomIndex, 1)
// Switch turn
currentTurn.value = 'player'
}
// Play random cards from both players
const playRandomCards = () => {
if (playerHand.value.length === 0) return
// Player plays first
currentTrick.value.firstPlayer = 'player'
const playerCard = playerHand.value[0]
currentTrick.value.playerCard = playerCard
playerHand.value.shift()
// Opponent plays after delay
setTimeout(() => {
if (opponentHand.value.length > 0) {
const opponentCard = opponentHand.value[0]
currentTrick.value.opponentCard = opponentCard
opponentHand.value.shift()
}
}, 600)
}
// Collect trick
const collectTrick = async () => {
if (!currentTrick.value.playerCard || !currentTrick.value.opponentCard) return
// Randomly determine winner
const winner = Math.random() > 0.5 ? 'player' : 'opponent'
currentTrick.value.winner = winner
// Wait to show winner glow
await new Promise((resolve) => setTimeout(resolve, 1500))
// Update score (random points)
const points = Math.floor(Math.random() * 15) + 5
if (winner === 'player') {
playerScore.value += points
gameOverStats.value.playerTricks++
} else {
opponentScore.value += points
gameOverStats.value.opponentTricks++
}
// Clear cards (triggers collection animation)
currentTrick.value.playerCard = null
currentTrick.value.opponentCard = null
// Reset after animation
await new Promise((resolve) => setTimeout(resolve, 600))
currentTrick.value.winner = null
currentTrick.value.firstPlayer = null
// Deal new cards if deck has cards
if (cardsRemaining.value >= 2 && playerHand.value.length < 3) {
await new Promise((resolve) => setTimeout(resolve, 500))
await dealSingleCard(winner === 'player')
await new Promise((resolve) => setTimeout(resolve, 700))
await dealSingleCard(winner === 'opponent')
}
// Switch turn to winner
currentTurn.value = winner
}
// Show game over
const showGameOver = () => {
// Make player win for demo
if (playerScore.value <= opponentScore.value) {
playerScore.value = opponentScore.value + 10
}
gameOverWinner.value =
playerScore.value > opponentScore.value
? 'player'
: opponentScore.value > playerScore.value
? 'opponent'
: 'draw'
gameOverVisible.value = true
}
// Reset game
const resetGame = () => {
playerHand.value = []
opponentHand.value = []
playerScore.value = 0
opponentScore.value = 0
currentTurn.value = 'player'
cardsRemaining.value = 40
currentTrick.value = {
playerCard: null,
opponentCard: null,
winner: null,
firstPlayer: null,
}
flyingCards.value = []
gameOverVisible.value = false
gameOverStats.value = {
playerTricks: 0,
opponentTricks: 0,
}
revealTrump.value = false
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,439 @@
<template>
<div class="relative min-h-screen overflow-hidden bg-green-900">
<div v-if="shouldShowBoard && !gameOver" class="absolute top-4 right-4 z-50">
<button
@click="handleSurrender"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2"
>
<span>🏳</span> Surrender
</button>
</div>
<div
v-if="!wsStore.connected && hasConnectedOnce"
class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700 shadow-xl"
>
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
<span class="text-white text-sm">Reconnecting...</span>
</div>
<div
v-if="shouldShowBoard && !gameOver && isMyTurn"
class="absolute top-4 left-1/2 -translate-x-1/2 z-50"
>
<div
class="bg-gray-800 px-6 py-3 rounded-lg border-2 shadow-xl transition-colors duration-300"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'"
>
<div class="flex items-center gap-3">
<svg
class="w-6 h-6"
:class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
>
<circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg>
<span
:class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']"
>
{{ timeLeft }}
</span>
</div>
</div>
</div>
<div v-if="shouldShowBoard">
<GameBoard
:trump-card="trumpCard"
:cards-remaining="deckCount + (trumpCard ? 1 : 0)"
:player-hand="visibleHand"
:opponent-hand="opponentHand"
:player-score="playerScore"
:opponent-score="opponentScore"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
:current-turn="currentTurnLabel"
:current-trick="displayedTrick"
:is-my-turn="isMyTurn"
@play-card="handlePlayCard"
/>
</div>
<div
v-else-if="!gameOver"
class="flex h-screen items-center justify-center bg-green-900 text-white"
>
<div
class="text-center p-8 bg-green-800/50 rounded-2xl border border-green-700 shadow-2xl backdrop-blur-sm"
>
<div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-600"></div>
<div
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"
></div>
</div>
<h2 class="text-2xl font-bold mb-2">Waiting for Opponent...</h2>
<p class="text-green-100 mb-8 max-w-sm">
Share the game code or wait for a player to join your lobby.
</p>
<div
class="bg-black/20 p-3 rounded mb-6 font-mono text-sm border border-white/10 text-green-100"
>
Match ID: <span class="font-bold text-white">{{ matchState.id }}</span>
</div>
<button
@click="handleCloseModal"
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg font-semibold transition-all shadow-lg hover:shadow-red-500/20"
>
Cancel & Return Home
</button>
</div>
</div>
<GameOver
:is-visible="gameOver"
:winner="winnerResult"
:player-score="playerScore"
:opponent-score="opponentScore"
:player-name="myDisplayName"
:opponent-name="opponentDisplayName"
:stats="{ playerTricks: 0, opponentTricks: 0 }"
:is-logging-out="false"
@play-again="handleCloseModal"
@close="handleCloseModal"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useSocketStore } from '@/stores/socket'
import { useAuthStore } from '@/stores/auth'
import { toast } from 'vue-sonner'
import axios from 'axios'
import GameBoard from '@/components/game/GameBoard.vue'
import GameOver from '@/components/game/GameOver.vue'
const route = useRoute()
const router = useRouter()
const wsStore = useSocketStore()
const authStore = useAuthStore()
const API_BASE_URL = inject('apiBaseURL')
const gameId = ref(route.params.id)
const visibleHand = ref([])
const gameOver = ref(false)
const isDealing = ref(false)
const hasInitialDealHappened = ref(false)
const winnerResult = ref('draw')
const timeLeft = ref(20)
const timerInterval = ref(null)
const hasConnectedOnce = ref(false)
const matchState = computed(() => wsStore.gameState || { id: gameId.value })
const gameMetadata = ref({
p1_id: null,
p1_name: 'Player 1',
p2_id: null,
p2_name: 'Opponent',
})
const displayedTrick = ref({ playerCard: null, opponentCard: null })
const trickClearTimer = ref(null)
const myUserId = computed(() => authStore.currentUser?.id)
const shouldShowBoard = computed(() => {
const s = wsStore.gameState
if (s && s.player2 && s.player2.id > 1) {
return true
}
return false
})
const amIHost = computed(() => {
if (gameMetadata.value.p1_id) {
return String(gameMetadata.value.p1_id) === String(myUserId.value)
}
const p1Id = wsStore.gameState?.player1_user_id || wsStore.gameState?.player1?.id
return String(p1Id) === String(myUserId.value)
})
const myDisplayName = computed(() =>
amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name,
)
const opponentDisplayName = computed(() =>
amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name,
)
const mappedTrick = computed(() => {
const table = wsStore.gameState?.table
if (!table) return { playerCard: null, opponentCard: null }
if (amIHost.value) {
return { playerCard: table.playerCard, opponentCard: table.opponentCard }
} else {
return { playerCard: table.opponentCard, opponentCard: table.playerCard }
}
})
const currentTurnLabel = computed(() => {
const turnId = wsStore.gameState?.currentTurn
if (!turnId) return 'player'
return String(turnId) === String(myUserId.value) ? 'player' : 'opponent'
})
const trumpCard = computed(() => wsStore.gameState?.trumpCard || null)
const deckCount = computed(() => wsStore.gameState?.deckCount || 0)
const playerScore = computed(() => wsStore.gameState?.points || 0)
const opponentScore = computed(() => wsStore.gameState?.opponent?.points || 0)
const serverHand = computed(() => wsStore.gameState?.hand || [])
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
})
watch(
() => wsStore.connected,
(isConnected) => {
if (isConnected) {
hasConnectedOnce.value = true
wsStore.joinGame(gameId.value)
}
},
{ immediate: true },
)
watch(
() => wsStore.gameState,
async (newState, oldState) => {
const oldP2 = oldState?.player2?.id
const newP2 = newState?.player2?.id
if (newP2 && newP2 > 1 && newP2 !== oldP2) {
await fetchGameMetadata()
}
},
{ deep: true },
)
watch(
mappedTrick,
(newVal) => {
if (newVal.playerCard || newVal.opponentCard) {
if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
displayedTrick.value = { ...newVal }
} else {
const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard
if (currentlyShowing) {
trickClearTimer.value = setTimeout(() => {
displayedTrick.value = { playerCard: null, opponentCard: null }
}, 1500)
} else {
displayedTrick.value = { playerCard: null, opponentCard: null }
}
}
},
{ deep: true },
)
const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value)
if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') {
timeLeft.value = 20
return
}
timeLeft.value = 20
const startAt = wsStore.gameState?.turnStartedAt
? new Date(wsStore.gameState.turnStartedAt).getTime()
: Date.now()
const update = () => {
const now = Date.now()
const elapsed = Math.floor((now - startAt) / 1000)
timeLeft.value = Math.max(0, 20 - elapsed)
if (timeLeft.value <= 0) clearInterval(timerInterval.value)
}
update()
timerInterval.value = setInterval(update, 1000)
}
watch(
() => wsStore.gameState?.currentTurn,
(newTurn) => {
if (newTurn) startTimer()
},
{ immediate: true },
)
const animateDeal = async (cards) => {
if (isDealing.value) return
isDealing.value = true
visibleHand.value = []
for (const card of cards) {
visibleHand.value.push(card)
await new Promise((r) => setTimeout(r, 150))
}
isDealing.value = false
}
watch(
serverHand,
(newHand) => {
if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) {
animateDeal(newHand)
hasInitialDealHappened.value = true
} else if (!isDealing.value) {
visibleHand.value = [...newHand]
}
},
{ deep: true },
)
const fetchGameMetadata = async () => {
try {
const response = await axios.get(`${API_BASE_URL}/games/${gameId.value}`)
const game = response.data.data || response.data
gameMetadata.value = {
p1_id: game.player1_user_id,
p1_name: game.player1?.nickname || 'Host',
p2_id: game.player2_user_id,
p2_name: game.player2?.nickname || 'Opponent',
}
} catch (e) {
console.error('Failed to load metadata', e)
}
}
const handlePlayCard = (card) => {
if (!isMyTurn.value || gameOver.value) return
visibleHand.value = visibleHand.value.filter((c) => c.id !== card.id)
wsStore.playCard(gameId.value, card.id)
}
const handleBeforeUnload = (event) => {
if (shouldShowBoard.value && !gameOver.value) {
event.preventDefault()
event.returnValue = ''
}
}
const handleSurrender = () => {
if (confirm('Are you sure you want to surrender? You will lose.')) {
wsStore.surrender(gameId.value)
}
}
const handleCloseModal = async () => {
if (!shouldShowBoard.value && amIHost.value && !gameOver.value) {
try {
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
console.log('Lobby cancelled and deleted.')
} catch (e) {
console.error('Failed to delete pending game:', e)
}
}
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect')
}
wsStore.disconnect()
router.push({ name: 'home' })
}
onBeforeRouteLeave(async (to, from, next) => {
if (!shouldShowBoard.value && !gameOver.value) {
if (amIHost.value) {
const confirmClose = window.confirm('This will cancel the lobby. Are you sure?')
if (confirmClose) {
try {
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`)
} catch (e) {
console.error(e)
}
if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
} else {
next(false)
}
return
} else {
if (wsStore.socket) wsStore.socket.emit('notify_disconnect')
wsStore.disconnect()
next()
return
}
}
if (gameOver.value) {
next()
return
}
const answer = window.confirm(
"If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?",
)
if (answer) {
try {
await wsStore.surrender(gameId.value)
} catch (e) {}
if (wsStore.socket) {
wsStore.socket.emit('notify_disconnect', () => {
wsStore.disconnect()
next()
})
setTimeout(() => {
if (wsStore.connected) {
wsStore.disconnect()
next()
}
}, 500)
return
}
wsStore.disconnect()
next()
} else {
next(false)
}
})
onMounted(async () => {
await fetchGameMetadata()
wsStore.connect()
wsStore.onTrickEnd((result) => {
const winnerName = result.winnerId == authStore.currentUser?.id ? 'You' : 'Opponent'
toast.success(`${winnerName} won the trick!`, { position: 'top-center', duration: 1500 })
})
wsStore.onGameOver((result) => {
gameOver.value = true
if (result.isDraw) winnerResult.value = 'draw'
else winnerResult.value = result.winnerId == authStore.currentUser?.id ? 'player' : 'opponent'
})
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (timerInterval.value) clearInterval(timerInterval.value)
wsStore.disconnect()
})
</script>
@@ -0,0 +1,373 @@
<script setup>
import { ref, onMounted, computed, inject, onUnmounted, watch } from 'vue'
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSocketStore } from '@/stores/socket'
import { Coins, Trophy, Frown } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import GameBoard from '@/components/game/GameBoard.vue'
import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL')
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const wsStore = useSocketStore()
const isCancelling = ref(false)
const matchState = ref({
id: route.params.id,
wager: 0,
myMarks: 0,
oppMarks: 0,
currentGameId: null,
player1: null,
player2: null,
status: 'Pending'
})
const timeLeft = ref(20)
const timerInterval = ref(null)
const showSurrenderModal = ref(false)
const showTrickResult = ref(false)
const lastTrickWinner = ref(null)
const showRoundModal = ref(false)
const roundData = ref({ winnerId: null, marksAdded: 0, myPoints: 0, oppPoints: 0, reason: '' })
const myUserId = computed(() => authStore.currentUser?.id)
const amIHost = computed(() => matchState.value.player1?.id === myUserId.value)
const isPending = computed(() => !matchState.value.player2 || matchState.value.player2.id <= 1)
const matchOver = computed(() => matchState.value.myMarks >= 4 || matchState.value.oppMarks >= 4)
const iWonMatch = computed(() => matchState.value.myMarks > matchState.value.oppMarks)
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
const currentTurnLabel = computed(() => isMyTurn.value ? 'player' : 'opponent')
const myDisplayName = computed(() => amIHost.value ? matchState.value.player1?.nickname : matchState.value.player2?.nickname)
const opponentDisplayName = computed(() => amIHost.value ? matchState.value.player2?.nickname : matchState.value.player1?.nickname)
const opponentHand = computed(() => {
const count = wsStore.gameState?.opponent?.cardCount || 0
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
})
watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true })
const startTimer = () => {
if (timerInterval.value) clearInterval(timerInterval.value);
timeLeft.value = 20;
if (!wsStore.gameState) return;
timerInterval.value = setInterval(() => {
if (timeLeft.value > 0) timeLeft.value--;
else clearInterval(timerInterval.value);
}, 1000);
}
const handlePlayCard = (card) => {
if (wsStore.socket) wsStore.socket.emit('game:play-card', { matchId: matchState.value.id, cardId: card.id })
}
const openSurrenderModal = () => { showSurrenderModal.value = true }
const confirmSurrenderRound = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id });
}
const confirmSurrenderMatch = () => {
showSurrenderModal.value = false;
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
}
const closeRoundModal = () => { showRoundModal.value = false; }
const updateLocalState = (data) => {
if (!data) return
if (data.player1) matchState.value.player1 = data.player1
if (data.player2) matchState.value.player2 = data.player2
if (data.stake) matchState.value.wager = data.stake
matchState.value.status = data.status
if (data.marks) {
const myId = authStore.currentUser?.id;
const oppId = amIHost.value ? matchState.value.player2?.id : matchState.value.player1?.id;
matchState.value.myMarks = data.marks[myId] || 0;
matchState.value.oppMarks = data.marks[oppId] || 0;
}
if (data.game) {
matchState.value.currentGameId = data.game.id;
wsStore.gameState = data.game;
} else if (data.status === 'Playing') {
matchState.value.currentGameId = null;
wsStore.gameState = null;
}
}
const cancelMatch = async () => {
if (isCancelling.value) return;
isCancelling.value = true;
try {
await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`)
router.push({ name: 'home' })
} catch (e) {
if (e.response && e.response.status === 404) {
router.push({ name: 'home' })
} else {
isCancelling.value = false;
alert(e.message)
}
}
}
const leaveMatch = () => router.push({ name: 'home' })
const handleBeforeUnload = (e) => {
if (matchState.value.status === 'Playing' && !matchOver.value) {
const msg = "If you leave now, you will lose the match and your wager.";
e.returnValue = msg;
return msg;
}
}
onBeforeRouteLeave((to, from, next) => {
if (isCancelling.value) {
return next();
}
if (matchState.value.status === 'Playing' && !matchOver.value) {
if (confirm("⚠️ WARNING: Leaving now triggers an automatic match surrender (Loss).\n\nAre you sure?")) {
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
next();
} else {
next(false);
}
} else if (isPending.value && amIHost.value) {
if (confirm("Cancel this lobby?")) {
isCancelling.value = true;
cancelMatch().then(() => next());
} else {
next(false);
}
} else {
next();
}
})
onMounted(async () => {
window.addEventListener('beforeunload', handleBeforeUnload)
try {
const res = await axios.get(`${API_BASE_URL}/matches/${matchState.value.id}`)
const d = res.data.data || res.data
matchState.value.player1 = d.player1
matchState.value.player2 = d.player2
matchState.value.wager = d.stake
} catch (e) { }
wsStore.connect()
if (wsStore.socket) {
wsStore.socket.emit('match:enter', { matchId: matchState.value.id })
wsStore.socket.on('match:update', updateLocalState)
wsStore.socket.on('match:new-round', (data) => {
updateLocalState(data);
toast.info("New round started!");
})
wsStore.socket.on('match:ended', updateLocalState)
wsStore.socket.on('game:update', (state) => { wsStore.gameState = state })
wsStore.socket.on('game:trick-end', (result) => {
lastTrickWinner.value = result.winnerId;
showTrickResult.value = true;
setTimeout(() => { showTrickResult.value = false }, 2000);
});
wsStore.socket.on('match:round-ended', (data) => {
const myPoints = data.winnerId == myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
const oppPoints = data.winnerId != myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
roundData.value = {
winnerId: data.winnerId,
marksAdded: data.marksAdded,
myPoints: myPoints,
oppPoints: oppPoints,
reason: data.reason
};
showRoundModal.value = true;
});
}
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
if (wsStore.socket) {
wsStore.socket.off('match:update')
wsStore.socket.off('match:new-round')
wsStore.socket.off('game:trick-end')
wsStore.socket.off('match:round-ended')
}
wsStore.disconnect()
})
</script>
<template>
<div class="relative min-h-screen bg-green-900 text-white overflow-hidden">
<div class="fixed top-0 left-0 right-0 z-40 bg-green-950/90 backdrop-blur border-b border-green-800 p-2 shadow-xl">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<div class="flex flex-col items-start">
<div class="flex items-center gap-2">
<span class="font-bold text-lg text-white drop-shadow-md">{{ myDisplayName }}</span>
<span v-if="matchState.wager > 0"
class="flex items-center text-xs text-yellow-300 bg-yellow-900/50 px-2 rounded border border-yellow-700/50">
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
</span>
</div>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-green-400 bg-green-900"
:class="{ '!bg-green-400 shadow-[0_0_10px_rgba(74,222,128,0.5)]': matchState.myMarks >= i }"></div>
</div>
</div>
<div class="flex flex-col items-center">
<span class="text-[10px] uppercase tracking-widest text-green-400 font-bold">Match #{{ matchState.id }}</span>
<span class="text-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{
matchState.oppMarks }}</span>
</div>
<div class="flex flex-col items-end">
<span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span>
<div class="flex gap-1 mt-1">
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-red-400 bg-green-900"
:class="{ '!bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]': matchState.oppMarks >= i }"></div>
</div>
</div>
</div>
</div>
<div v-if="wsStore.gameState && !matchOver" class="absolute top-24 right-4 z-50">
<button @click="openSurrenderModal"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all hover:scale-105 flex items-center gap-2 text-sm cursor-pointer">
<span>🏳</span> Surrender
</button>
</div>
<div v-if="wsStore.gameState && !matchOver && isMyTurn" class="absolute top-24 left-1/2 -translate-x-1/2 z-50">
<div class="bg-gray-800 px-6 py-2 rounded-lg border-2 shadow-xl transition-colors duration-300"
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
<div class="flex items-center gap-3">
<svg class="w-5 h-5" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24"
fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="2" />
<polyline points="12 6 12 12 16 14" stroke-width="2" />
</svg>
<span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft
}}</span>
</div>
</div>
</div>
<div class="pt-20 h-screen">
<GameBoard v-if="wsStore.gameState && !matchOver" :game-id="matchState.currentGameId" @play-card="handlePlayCard"
:trump-card="wsStore.gameState?.trumpCard" :cards-remaining="wsStore.gameState?.deckCount"
:player-hand="wsStore.gameState?.hand" :opponent-hand="opponentHand" :player-score="wsStore.gameState?.points"
:opponent-score="wsStore.gameState?.opponent?.points" :current-turn="currentTurnLabel"
:current-trick="wsStore.gameState?.table" :is-my-turn="isMyTurn" :player-name="myDisplayName"
:opponent-name="opponentDisplayName" />
<div v-else-if="isPending" class="flex h-full items-center justify-center flex-col gap-6">
<div
class="text-center p-8 bg-green-800/60 rounded-2xl border border-green-600 shadow-2xl backdrop-blur-md max-w-sm w-full mx-4">
<div class="relative mx-auto mb-6 h-16 w-16">
<div class="absolute inset-0 rounded-full border-4 border-green-700/50"></div>
<div
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin">
</div>
</div>
<h2 class="text-2xl font-bold mb-2 text-white">Waiting for Opponent</h2>
<button v-if="amIHost" @click="cancelMatch"
class="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded shadow mt-4">Cancel Match</button>
</div>
</div>
<div v-else-if="!matchOver && !wsStore.gameState" class="flex h-full items-center justify-center flex-col gap-4">
<div class="text-3xl font-bold animate-pulse text-white drop-shadow">Preparing next round...</div>
</div>
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
<div
class="text-center space-y-6 p-8 bg-green-900 border-2 border-green-500 rounded-2xl shadow-2xl max-w-md w-full">
<Trophy v-if="iWonMatch" class="w-24 h-24 text-yellow-400 mx-auto animate-bounce" />
<Frown v-else class="w-24 h-24 text-green-300 mx-auto opacity-75" />
<h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{
iWonMatch ? 'Victory!' : 'Defeat' }}</h1>
<p class="text-green-100 text-lg">Marks: <span class="font-bold">{{ matchState.myMarks }} - {{
matchState.oppMarks }}</span></p>
<Button @click="leaveMatch" class="w-full py-6 text-lg bg-purple-600 hover:bg-purple-700">Return to
Lobby</Button>
</div>
</div>
</div>
<div v-if="showRoundModal"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md">
<div
class="bg-gray-800 p-8 rounded-2xl border-2 border-yellow-500/50 shadow-2xl max-w-md w-full text-center relative overflow-hidden">
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-transparent via-yellow-500 to-transparent">
</div>
<h2 class="text-3xl font-black text-white mb-2 uppercase">Round Over</h2>
<p class="text-gray-400 mb-6 uppercase text-xs tracking-widest">
{{ roundData.reason === 'surrender' ? 'Opponent Surrendered' : 'Points Limit Reached' }}</p>
<div class="flex justify-between items-center bg-black/30 p-4 rounded-lg mb-6">
<div class="text-left">
<p class="text-xs text-gray-500 uppercase">You</p>
<p class="text-2xl font-bold text-white">{{ roundData.myPoints }} pts</p>
</div>
<div class="text-xl font-bold text-yellow-500">VS</div>
<div class="text-right">
<p class="text-xs text-gray-500 uppercase">Opponent</p>
<p class="text-2xl font-bold text-white">{{ roundData.oppPoints }} pts</p>
</div>
</div>
<div class="mb-8">
<p class="text-lg text-white" v-if="roundData.winnerId == myUserId">
You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>!
</p>
<p class="text-lg text-white" v-else>
Opponent won <span class="text-red-400 font-bold">+{{ roundData.marksAdded }} marks</span>.
</p>
</div>
<button @click="closeRoundModal"
class="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl shadow-lg transition-transform hover:scale-105">
Ready for Next Round
</button>
</div>
</div>
<div v-if="showSurrenderModal"
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<div class="bg-gray-800 p-6 rounded-lg border border-gray-600 shadow-2xl max-w-sm w-full text-center">
<h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3>
<div class="space-y-3">
<button @click="confirmSurrenderRound"
class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded text-white font-bold border border-gray-500 flex justify-between items-center group">
<span>Surrender Round</span>
<span
class="text-xs bg-black/40 px-2 py-1 rounded text-gray-300 group-hover:bg-red-500 group-hover:text-white">-2
Marks</span>
</button>
<button @click="confirmSurrenderMatch"
class="w-full px-4 py-3 bg-red-900/50 hover:bg-red-600 rounded text-red-200 hover:text-white font-bold border border-red-800 flex justify-between items-center group">
<span>Surrender Match</span>
<span
class="text-xs bg-black/40 px-2 py-1 rounded text-red-300 group-hover:bg-red-800 group-hover:text-white">Defeat</span>
</button>
</div>
<button @click="showSurrenderModal = false"
class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button>
</div>
</div>
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 scale-50"
enter-to-class="opacity-100 scale-100" leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75">
<div v-if="showTrickResult" class="fixed top-1/3 left-1/2 -translate-x-1/2 z-[70] pointer-events-none">
<div class="bg-black/70 backdrop-blur-md px-8 py-4 rounded-2xl border border-white/10 shadow-2xl text-center">
<p class="text-sm uppercase tracking-widest text-gray-300 font-bold mb-1">Trick Winner</p>
<h2 class="text-3xl font-black" :class="lastTrickWinner === myUserId ? 'text-green-400' : 'text-red-400'">{{
lastTrickWinner === myUserId ? 'YOU' : 'OPPONENT' }}</h2>
</div>
</div>
</Transition>
</div>
</template>
@@ -17,8 +17,11 @@
:opponent-hand="store.opponentHand" :opponent-hand="store.opponentHand"
:player-score="store.playerScore" :player-score="store.playerScore"
:opponent-score="store.opponentScore" :opponent-score="store.opponentScore"
:current-turn="store.currentTurn" :current-turn="'player'"
:is-my-turn="true"
:current-trick="store.table" :current-trick="store.table"
:player-name="'You'"
:opponent-name="'CPU'"
@play-card="handlePlayCard" @play-card="handlePlayCard"
/> />
</div> </div>
@@ -41,9 +44,9 @@
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue' import { onMounted, onUnmounted, onBeforeMount, watch } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router' import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback import { toast } from 'vue-sonner'
import { useBiscaStore } from '@/stores/bisca' import { useBiscaStore } from '@/stores/bisca'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import GameBoard from '@/components/game/GameBoard.vue' import GameBoard from '@/components/game/GameBoard.vue'
@@ -60,6 +63,14 @@ const store = useBiscaStore()
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
watch(
() => store.playerHand,
(newHand) => {
console.log('Player hand updated:', newHand)
},
{ deep: true },
)
onBeforeMount(() => { onBeforeMount(() => {
store.isGameOver = false store.isGameOver = false
}) })
@@ -67,6 +78,14 @@ onBeforeMount(() => {
onMounted(() => { onMounted(() => {
store.startGame(props.gameType) store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload) window.addEventListener('beforeunload', handleBrowserUnload)
setTimeout(() => {
console.log('Game state after init:', {
playerHand: store.playerHand,
currentTurn: store.currentTurn,
isGameRunning: store.isGameRunning,
})
}, 500)
}) })
onUnmounted(() => { onUnmounted(() => {
+477 -124
View File
@@ -1,6 +1,10 @@
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue' import { User, Trophy, Coins } from 'lucide-vue-next'
import { ref, onMounted, nextTick, inject, computed } from 'vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { watch } from 'vue';
import { import {
Card, Card,
CardContent, CardContent,
@@ -10,124 +14,348 @@ import {
} from '@/components/ui/card' } from '@/components/ui/card'
import { useAPIStore } from '@/stores/api' import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import axios from 'axios'
import { toast } from 'vue-sonner'
const API_BASE_URL = inject('apiBaseURL')
const apiStore = useAPIStore() const apiStore = useAPIStore()
const router = useRouter() const router = useRouter()
const openGames = ref([]) const openGames = ref([])
const selectedMode = ref('9') const selectedMode = ref('9')
const observerTarget = ref(null); const gObserverTarget = ref(null);
const observer = ref(null); const gObserver = ref(null);
const isLoading = ref(false); const gLoading = ref(false);
const pageCounter = ref(1); const gPage = ref(1);
const openMatches = ref([])
const mObserverTarget = ref(null);
const mObserver = ref(null);
const mLoading = ref(false);
const mPage = ref(1);
const showHostModal = ref(false) const showHostModal = ref(false)
const showJoinModal = ref(false) const showLeaderboardModal = ref(false)
const selectedGame = ref(null) const showStatsModal = ref(false)
const isReady = ref(false) const userStats = ref(null)
const statsLoading = ref(false)
const leaderboardEntries = ref([])
const lbLoading = ref(false)
const lbPage = ref(1)
const lbObserverTarget = ref(null)
const lbObserver = ref(null)
const lbMorePages = ref(true)
const publicStats = ref(null)
const isLoggedIn = useAuthStore().isLoggedIn
const isBestOfThree = ref(false)
const wagerAmount = ref(0)
const authStore = useAuthStore()
const userStore = useUserStore()
const userLoggedIn = computed(() => authStore.isLoggedIn)
const userCoins = ref(0)
const hostLoading = ref(false)
const gHasMore = ref(true);
const mHasMore = ref(true);
const setupObserver = () => { const setupMatchesObserver = () => {
if (observer.value) observer.value.disconnect(); if (mObserver.value) mObserver.value.disconnect();
observer.value = new IntersectionObserver((entries) => { mObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading.value && openGames.value.length > 0) { if (entries[0].isIntersecting && !mLoading.value && openMatches.value.length > 0) {
getPendingMatches();
}
}, { threshold: 0.1 });
if (mObserverTarget.value) {
mObserver.value.observe(mObserverTarget.value);
}
}
const setupGamesObserver = () => {
if (gObserver.value) gObserver.value.disconnect();
gObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !gLoading.value && openGames.value.length > 0) {
getPendingGames(); getPendingGames();
} }
}, { threshold: 0.1 }); }, { threshold: 0.1 });
if (observerTarget.value) { if (gObserverTarget.value) {
observer.value.observe(observerTarget.value); gObserver.value.observe(gObserverTarget.value);
} }
} }
const getPendingGames = async (mode = selectedMode.value) => { const getPendingGames = async (mode = selectedMode.value) => {
if (isLoading.value) return; if (gLoading.value || !gHasMore.value) return;
isLoading.value = true; gLoading.value = true;
apiStore.gameQueryParameters.page = pageCounter.value++;
apiStore.gameQueryParameters.filters.sort_direction = "asc"
apiStore.gameQueryParameters.filters.type = mode
apiStore.gameQueryParameters.filters.status = 'Pending'
try { try {
const response = await apiStore.getGames(); const response = await axios.get(`${API_BASE_URL}/games/open`, {
params: { type: mode, page: gPage.value }
});
const newGames = response.data.data; const newGames = response.data.data;
if (newGames.length === 0) {
gHasMore.value = false;
} else {
openGames.value = [...openGames.value, ...newGames]; openGames.value = [...openGames.value, ...newGames];
gPage.value++;
}
} finally { } finally {
isLoading.value = false; gLoading.value = false;
}
}
const getPendingMatches = async (mode = selectedMode.value) => {
if (mLoading.value || !mHasMore.value) return;
mLoading.value = true;
try {
const response = await axios.get(`${API_BASE_URL}/matches/open`, {
params: { type: mode, page: mPage.value }
});
const newMatches = response.data.data;
if (newMatches.length === 0) {
mHasMore.value = false;
} else {
openMatches.value = [...openMatches.value, ...newMatches];
mPage.value++;
}
} finally {
mLoading.value = false;
}
}
const fetchPublicStats = async () => {
try {
const response = await apiStore.getPublicStats()
publicStats.value = response.data
} catch (error) {
console.error("Failed to fetch public stats", error)
}
}
const openStats = async () => {
showStatsModal.value = true
statsLoading.value = true
try {
const response = await apiStore.getCurrentUserStats()
userStats.value = response.data
} catch (error) {
console.error("Failed to fetch stats", error)
} finally {
statsLoading.value = false
}
}
const getAvatarUrl = (filename) => {
return filename
? `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/${filename}`
: `${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/anonymous.png`;
}
const fetchLeaderboard = async () => {
if (lbLoading.value || !lbMorePages.value) return;
lbLoading.value = true;
try {
const response = await apiStore.getLeaderboard({ page: lbPage.value });
const newEntries = response.data.data;
if (newEntries.length < 10) {
lbMorePages.value = false;
}
leaderboardEntries.value = [...leaderboardEntries.value, ...newEntries];
lbPage.value++;
} finally {
lbLoading.value = false;
}
}
const openLeaderboard = async () => {
leaderboardEntries.value = [];
lbPage.value = 1;
showLeaderboardModal.value = true;
await nextTick();
setupLeaderboardObserver();
await fetchLeaderboard();
}
const setupLeaderboardObserver = () => {
if (lbObserver.value) lbObserver.value.disconnect();
lbObserver.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !lbLoading.value && leaderboardEntries.value.length > 0) {
fetchLeaderboard();
}
}, { threshold: 0.1 });
if (lbObserverTarget.value) {
lbObserver.value.observe(lbObserverTarget.value);
} }
} }
const clickMode = async (mode) => { const clickMode = async (mode) => {
if (selectedMode.value === mode) return; if (selectedMode.value === mode) return;
openGames.value = [];
selectedMode.value = mode; selectedMode.value = mode;
pageCounter.value = 1; openGames.value = [];
await getPendingGames(); openMatches.value = [];
setupObserver(); gPage.value = 1;
mPage.value = 1;
gHasMore.value = true;
mHasMore.value = true;
await Promise.all([getPendingGames(), getPendingMatches()]);
setupGamesObserver();
setupMatchesObserver();
} }
const startSingle = () => { const startSingle = () => {
router.push({ name: 'bisca' + selectedMode.value }); router.push({ name: 'bisca' + selectedMode.value });
} }
const JoinGameModal = (game) => { const joinMatch = async (match) => {
selectedGame.value = game const cost = match.stake || match.wager || 0;
isReady.value = false
showJoinModal.value = true if (cost > userCoins.value) {
toast.error(`Insufficient funds! You need ${cost} coins.`);
return;
}
if (!confirm(`Join match for ${cost} coins?`)) {
return;
}
try {
await axios.post(`${API_BASE_URL}/matches/${match.id}/join`)
router.push({
name: 'multiplayer-match',
params: { id: match.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join match');
}
} }
const hostGameModal = () => { const hostGame = async () => {
showHostModal.value = true if (isBestOfThree.value) {
if (wagerAmount.value <= 0) {
toast.error("Stake must be at least 1 coin.");
return;
}
if (wagerAmount.value > userCoins.value) {
toast.error("Insufficient coins!");
return;
}
}
hostLoading.value = true;
const endpoint = isBestOfThree.value ? '/matches/host' : '/games/host'
const payload = isBestOfThree.value
? { type: parseInt(selectedMode.value), stake: parseInt(wagerAmount.value) }
: { type: parseInt(selectedMode.value) }
try {
const response = await axios.post(`${API_BASE_URL}${endpoint}`, payload)
const resultId = response.data.match?.id || response.data.id || response.data.data?.id;
if (!resultId) throw new Error("Missing ID");
showHostModal.value = false;
router.push({
name: isBestOfThree.value ? 'multiplayer-match' : 'multiplayer-game',
params: { id: resultId },
query: { type: selectedMode.value }
})
} catch (error) {
if (error.response?.data?.message) {
toast.error(error.response.data.message);
} else {
toast.error('Failed to host game');
}
} finally {
hostLoading.value = false;
}
} }
const toggleReady = () => { const joinGame = async (game) => {
isReady.value = !isReady.value try {
await axios.post(`${API_BASE_URL}/games/${game.id}/join`)
router.push({
name: 'multiplayer-game',
params: { id: game.id },
query: { type: selectedMode.value }
})
} catch (error) {
toast.error(error.response?.data?.message || 'Failed to join game');
}
} }
const openHostModal = (forMatch) => {
isBestOfThree.value = forMatch;
showHostModal.value = true;
wagerAmount.value = 0;
}
watch(() => userLoggedIn, async (isLoggedIn) => {
if (isLoggedIn) {
await userStore.fetchCoins()
userCoins.value = userStore.coins
} else {
userStore.coins = 0
}
}, { immediate: true })
onMounted(async () => { onMounted(async () => {
await getPendingGames(); await fetchPublicStats();
if (isLoggedIn) {
await Promise.all([getPendingGames(), getPendingMatches()]);
await nextTick(); await nextTick();
setupObserver(); setupGamesObserver();
setupMatchesObserver();
}
}) })
</script> </script>
<template> <template>
<div class="min-h-screen bg-cover bg-center transition-all duration-700"> <div class="min-h-screen bg-cover bg-center transition-all duration-700">
<div class="flex flex-col justify-center items-center gap-5 pt-10 pb-10 px-4">
<div class="flex flex-col justify-center items-center gap-5 pt-10 px-4"> <Card v-if="publicStats" class="w-full max-w-2xl border-[#a855f7] bg-[#f8f0ff] shadow-sm">
<CardContent class="p-4">
<div class="flex justify-around items-center">
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Players</p>
<p class="text-xl font-black">{{ publicStats.total_players }}</p>
</div>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Total Games</p>
<p class="text-xl font-black">{{ publicStats.total_games }}</p>
</div>
<div class="h-8 w-[1px] bg-[#a855f7]/20"></div>
<div class="text-center">
<p class="text-xs uppercase font-bold opacity-70">Active</p>
<p class="text-xl font-black">{{ publicStats.active_games }}</p>
</div>
</div>
</CardContent>
</Card>
<div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden"> <div class="relative w-full max-w-md bg-card rounded-lg border shadow-sm overflow-hidden">
<div class="relative h-14 flex items-center"> <div class="relative h-14 flex items-center">
<button @click="clickMode('9')" <button @click="clickMode('9')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0" class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out border-r last:border-r-0"
:class="{ :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '9', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '9' }">
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '9',
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '9'
}">
<span>Bisca de 9</span> <span>Bisca de 9</span>
</button> </button>
<button @click="clickMode('3')" <button @click="clickMode('3')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out" class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
:class="{ :class="{ 'w-2/3 z-10 text-purple-600 bg-purple-500/10': selectedMode === '3', 'w-1/3 z-0 text-muted-foreground/40': selectedMode !== '3' }">
'w-2/3 z-10 text-purple-600 dark:text-purple-400 bg-purple-500/10': selectedMode === '3',
'w-1/3 z-0 text-muted-foreground/40 bg-transparent': selectedMode !== '3'
}">
<span>Bisca de 3</span> <span>Bisca de 3</span>
</button> </button>
</div> </div>
</div> </div>
<div class="flex flex-col items-center gap-5 w-[100vw]"> <div class="flex flex-col items-center gap-5 w-[100vw]">
<Card class="w-full max-w-2xl"> <Card class="w-full max-w-2xl">
<CardHeader> <CardHeader>
<CardTitle class="text-3xl font-bold text-center"> <CardTitle class="text-3xl font-bold text-center">Single Player</CardTitle>
Single Player <CardDescription class="text-center">Go against one of our bots!</CardDescription>
</CardTitle>
<CardDescription class="text-center">
Go against one of our bots!
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="space-y-6">
<div class="flex justify-center"> <div class="flex justify-center">
@@ -138,105 +366,230 @@ onMounted(async () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card class="w-full max-w-2xl">
<Card class="w-full max-w-2xl flex flex-col">
<CardHeader> <CardHeader>
<CardTitle class="text-3xl font-bold text-center"> <CardTitle class="text-3xl font-bold text-center">Multi Player</CardTitle>
MultiPlayer <CardDescription class="text-center">Go one on one with another player!</CardDescription>
</CardTitle>
<CardDescription class="text-center">
Go one on one with another player!
</CardDescription>
</CardHeader> </CardHeader>
<CardContent class="space-y-6"> <CardContent class="flex-1 flex flex-col space-y-6">
<label class="text-sm font-medium">Open games (oldest first)</label>
<div class="flex flex-col flex-1">
<div class="flex justify-between items-end mb-2">
<label class="text-sm font-bold uppercase tracking-wider text-muted-foreground">Open Matches (Best of
3)</label>
<Button v-if="isLoggedIn" @click="openHostModal(true)" size="sm" variant="outline"
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host
</Button>
</div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm"> <div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground"> <div v-if="openMatches.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No open games yet. Try hosting one! No open matches.
</div> </div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1"> <div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(match, index) in openMatches" :key="match.id" @click="joinMatch(match)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-red-100 text-red-700">
{{ index + 1 }}
</div>
<div>
<div class="font-medium text-sm flex items-center gap-2">
Match #{{ match.id }}
<span
class="flex items-center text-xs bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full">
<Coins class="w-3 h-3 mr-1" /> {{ match.stake || match.wager }}
</span>
</div>
<div class="text-[10px] text-muted-foreground">Host: {{ match.player1?.nickname || 'Unknown' }}
</div>
</div>
</div>
</div>
<div ref="mObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="mLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
</div>
</div>
</div>
</div>
<div v-for="(game, index) in openGames" :key="game.id" @click="JoinGameModal(game)" <div class="flex flex-col flex-1">
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500"> <div class="flex justify-between items-end mb-2">
<label class="text-sm font-medium uppercase tracking-wider text-muted-foreground">Open Games
(Single)</label>
<Button v-if="isLoggedIn" @click="openHostModal(false)" size="sm" variant="outline"
class="border-purple-500 text-purple-500 hover:bg-purple-500 hover:text-white font-bold">
Host
</Button>
</div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="openGames.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No open games.
</div>
<div v-else class="max-h-[350px] overflow-y-auto custom-scrollbar p-1">
<div v-for="(game, index) in openGames" :key="game.id" @click="joinGame(game)"
class="flex items-center justify-between p-3 mb-2 border border-transparent rounded-lg cursor-pointer transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<div <div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700"> class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
{{ index + 1 }} {{ index + 1 }}
</div> </div>
<div> <div>
<div class="font-medium text-sm">Started: {{ game.began_at }}</div> <div class="font-medium text-sm">Host: {{ game.player1?.nickname || 'Anonymous' }}</div>
<div class="text-xs text-muted-foreground">host: {{ </div>
game.player1?.nickname || 'Anonymous' }}</div> </div>
</div>
<div ref="gObserverTarget" class="h-10 flex items-center justify-center">
<span v-if="gLoading" class="text-xs text-purple-500 animate-pulse">Loading...</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div ref="observerTarget" class="h-10 flex items-center justify-center">
<span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading
more...</span>
</div>
</div>
</div>
<div class="flex justify-center">
<Button @click="hostGameModal()" size="lg" variant="secondary"
class="hover:bg-purple-500 hover:text-slate-200">
Host Game
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
</div> </div>
<div v-if="showHostModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> <div v-if="showStatsModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-sm max-h-2/3 border-purple-500 shadow-2xl">
<CardHeader class="border-b bg-muted/20">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<User class="text-yellow-500" /> My Statistics
</CardTitle>
<Button variant="ghost" size="sm" @click="showStatsModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="py-6">
<div v-if="statsLoading" class="flex flex-col items-center py-10 gap-4">
<div class="h-8 w-8 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
<p class="text-sm text-muted-foreground">Calculating your glory...</p>
</div>
<div v-else-if="userStats" class="space-y-6">
<div class="text-center">
<h3 class="text-2xl font-bold text-purple-600">{{ userStats.nickname }}</h3>
<p class="text-sm text-muted-foreground">Performance Overview</p>
</div>
<div class="grid grid-cols-2 gap-4">
<div
class="rounded-lg bg-muted/50 p-4 text-center border border-transparent hover:border-purple-500/30 transition-all">
<p class="text-xs uppercase text-muted-foreground font-semibold">Total Games</p>
<p class="text-2xl font-bold">{{ userStats.total_games }}</p>
</div>
<div class="rounded-lg bg-purple-500/10 p-4 text-center border border-purple-500/20">
<p class="text-xs uppercase text-purple-600 font-semibold">Win Rate</p>
<p class="text-2xl font-bold text-purple-600">{{ userStats.win_rate }}</p>
</div>
<div class="rounded-lg bg-green-500/10 p-4 text-center border border-green-500/20">
<p class="text-xs uppercase text-green-600 font-semibold">Wins</p>
<p class="text-2xl font-bold text-green-600">{{ userStats.total_wins }}</p>
</div>
<div class="rounded-lg bg-red-500/10 p-4 text-center border border-red-500/20">
<p class="text-xs uppercase text-red-600 font-semibold">Losses</p>
<p class="text-2xl font-bold text-red-600">{{ userStats.total_losses }}</p>
</div>
</div>
<div class="space-y-2">
<div class="flex justify-between text-xs font-medium">
<span>Win/Loss Ratio</span>
<span>{{ userStats.total_wins }}W - {{ userStats.total_losses }}L</span>
</div>
<div class="h-2 w-full bg-red-500/20 rounded-full overflow-hidden flex">
<div class="bg-green-500 h-full" :style="{ width: userStats.win_rate }"></div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div v-if="showLeaderboardModal"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card class="w-full max-w-lg max-h-1/2 h-[80vh] flex flex-col border-purple-500 shadow-2xl">
<CardHeader class="border-b">
<div class="flex justify-between items-center">
<CardTitle class="flex items-center gap-2">
<Trophy class="text-purple-500" /> Leaderboard
</CardTitle>
<Button variant="ghost" size="sm" @click="showLeaderboardModal = false">X</Button>
</div>
</CardHeader>
<CardContent class="flex-1 overflow-y-auto custom-scrollbar p-0">
<div v-for="(user, index) in leaderboardEntries" :key="user.id"
class="flex items-center justify-between p-4 border-b last:border-0 hover:bg-muted/30 transition-colors">
<div class="flex items-center gap-4">
<span class="font-bold text-muted-foreground w-6 text-center"
:class="{ 'text-yellow-500': index === 0, 'text-slate-400': index === 1, 'text-orange-400': index === 2 }">
#{{ index + 1 }}
</span>
<img :src="getAvatarUrl(user.photo_avatar_filename)"
class="h-10 w-10 rounded-full border border-purple-200 object-cover" alt="Avatar" />
<div>
<p class="font-semibold text-sm">{{ user.nickname }}</p>
<p class="text-[10px] text-muted-foreground uppercase tracking-wider">Player</p>
</div>
</div>
<div class="text-right">
<p class="text-lg font-bold text-purple-600">{{ user.won_games_count }}</p>
<p class="text-[10px] text-muted-foreground uppercase">Wins</p>
</div>
</div>
<div ref="lbObserverTarget" class="h-20 flex items-center justify-center">
<span v-if="lbLoading" class="text-purple-500 animate-bounce">Loading rankings...</span>
</div>
</CardContent>
</Card>
</div>
<div v-if="showHostModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-sm border-purple-500 shadow-2xl"> <Card class="w-full max-w-sm border-purple-500 shadow-2xl">
<CardHeader> <CardHeader>
<CardTitle class="text-center">Hosting Game</CardTitle> <CardTitle class="text-center">{{ isBestOfThree ? 'Host Match (Best of 3)' : 'Host Game' }}</CardTitle>
</CardHeader> </CardHeader>
<CardContent class="flex flex-col items-center gap-6 py-10"> <CardContent class="flex flex-col gap-6 py-4">
<div v-if="isBestOfThree" class="space-y-2">
<Label>Wager (Coins)</Label>
<div class="relative"> <div class="relative">
<div <Coins class="absolute left-3 top-2.5 h-4 w-4 text-yellow-500" />
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent"> <Input v-model="wagerAmount" type="number" min="0" class="pl-9" />
</div> </div>
<p class="text-xs text-muted-foreground text-right">Balance: {{ userCoins }}</p>
</div> </div>
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p> <div class="flex gap-2">
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button> <Button variant="outline" class="flex-1" @click="showHostModal = false"
</CardContent> :disabled="hostLoading">Cancel</Button>
</Card> <Button class="flex-1 bg-purple-600 hover:bg-purple-700" @click="hostGame" :disabled="hostLoading">
</div> {{ hostLoading ? 'Creating...' : 'Create' }}
<div v-if="showJoinModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<Card class="w-full max-w-md border-purple-500 shadow-2xl">
<CardHeader>
<CardTitle>Join Game</CardTitle>
<CardDescription v-if="selectedGame">
Hosted by {{ selectedGame.player1?.nickname || 'Anonymous' }}
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="rounded-lg bg-muted p-4 text-sm">
<p><strong>Game Type:</strong> Bisca de {{ selectedMode }}</p>
<p><strong>Started at:</strong> {{ selectedGame?.began_at }}</p>
</div>
<div v-if="isReady"
class="flex items-center justify-center rounded-md bg-green-500/10 p-3 text-green-600 dark:text-green-400">
<span class="animate-pulse font-semibold">Waiting for the host to start...</span>
</div>
<div class="flex gap-3 pt-4">
<Button variant="destructive" class="flex-1" @click="showJoinModal = false">
Leave
</Button>
<Button class="flex-1 transition-all duration-300" :variant="isReady ? 'outline' : 'default'"
:class="!isReady ? 'bg-purple-600 hover:bg-purple-700' : ''" @click="toggleReady">
{{ isReady ? 'Cancel Ready' : 'Ready up' }}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
<div class="fixed right-4 top-1/2 -translate-y-1/2 flex flex-col gap-4 z-40">
<Button @click="openLeaderboard" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<Trophy class="h-6 w-6" />
</Button>
<Button v-if="useAuthStore().isLoggedIn" @click="openStats" variant="outline" size="icon"
class="h-12 w-12 rounded-full shadow-lg border-purple-500 bg-card hover:bg-purple-500 hover:text-white transition-all">
<User class="h-6 w-6" />
</Button>
</div>
</div> </div>
</template> </template>
@@ -0,0 +1,139 @@
<template>
<div class="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<div class="w-full max-w-md space-y-8">
<div>
<h2 class="mt-6 text-center text-3xl font-bold tracking-tight text-gray-900">Buy Coins</h2>
<p class="mt-2 text-center text-sm text-gray-600">1.00 = 10 Coins</p>
</div>
<form class="mt-8 space-y-4" @submit.prevent="handleSubmit">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Amount (Euros)</label>
<Input v-model.number="formData.euros" type="number" min="1" max="100" placeholder="Ex: 10"
required />
<p class="mt-1 text-xs text-blue-600 font-medium">
You will receive: {{ formData.euros * 10 }} coins
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select v-model="formData.payment_type"
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<option value="MBWAY">MB WAY</option>
<option value="MB">Multibanco (MB)</option>
<option value="VISA">VISA</option>
<option value="PAYPAL">PayPal</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{{ referenceLabel }}
</label>
<Input v-model="formData.payment_reference" type="text" :placeholder="referencePlaceholder"
required />
<p v-if="errors.payment_reference" class="mt-1 text-xs text-red-500">
{{ errors.payment_reference }}
</p>
</div>
<div class="pt-4">
<Button type="submit" class="w-full">
Confirm Purchase ({{ formData.euros }})
</Button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { usePurchaseStore } from '@/stores/purchase'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { useUserStore } from '@/stores/user'
const purchaseStore = usePurchaseStore()
const router = useRouter()
const formData = ref({
euros: 1,
payment_type: 'MBWAY',
payment_reference: ''
})
const errors = ref({})
const referenceLabel = computed(() => {
const labels = {
MBWAY: 'Phone Number',
MB: 'Entity-Reference',
VISA: 'Card Number',
PAYPAL: 'PayPal Email'
}
return labels[formData.value.payment_type]
})
const referencePlaceholder = computed(() => {
const placeholders = {
MBWAY: '912345678',
MB: '12345-123456789',
VISA: '1234 5678 9012 3456',
PAYPAL: '[email protected]'
}
return placeholders[formData.value.payment_type]
})
const validateForm = () => {
errors.value = {}
const type = formData.value.payment_type
const refValue = formData.value.payment_reference
const patterns = {
MBWAY: /^9[0-9]{8}$/,
MB: /^[0-9]{5}-[0-9]{9}$/,
VISA: /^4[0-9]{15}$/,
PAYPAL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/
}
if (!patterns[type].test(refValue)) {
errors.value.payment_reference = `Invalid format for ${type}`
return false
}
if (formData.value.euros < 1 || formData.value.euros > 100) {
toast.error("Amount must be between 1 and 100")
return false
}
return true
}
const userStore = useUserStore()
const handleSubmit = async () => {
if (!validateForm()) return
const promise = purchaseStore.purchase(formData.value)
toast.promise(promise, {
loading: 'Processing transaction...',
success: (res) => {
userStore.fetchCoins()
router.push('/')
return `Purchase successful! New balance: ${res.data.balance} coins`
},
error: (error) => {
if (error.response?.status === 422) {
errors.value = error.response.data.errors
return "Please check the highlighted fields"
}
return error.response?.data?.message || "Transaction failed"
}
})
}
</script>
+1 -1
View File
@@ -101,7 +101,7 @@ const handleFileChange = (event) => {
} }
const validateForm = () => { const validateForm = () => {
errors.value = {} // Reset errors errors.value = {}
let isValid = true let isValid = true
if (!formData.value.email.match(/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)) { if (!formData.value.email.match(/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/)) {
+445 -205
View File
@@ -1,6 +1,5 @@
<template> <template>
<div class="min-h-screen p-8 flex items-center justify-center"> <div class="min-h-screen p-8 flex items-center justify-center">
<!-- Loading State -->
<div v-if="loading" class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full"> <div v-if="loading" class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full">
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
<div class="spinner"></div> <div class="spinner"></div>
@@ -8,7 +7,6 @@
</div> </div>
</div> </div>
<!-- Error State -->
<div v-else-if="error" <div v-else-if="error"
class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full text-gray-800"> class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full text-gray-800">
<svg class="w-12 h-12 mx-auto mb-4 text-black" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="w-12 h-12 mx-auto mb-4 text-black" viewBox="0 0 24 24" fill="none" stroke="currentColor">
@@ -28,13 +26,12 @@
</button> </button>
</div> </div>
<!-- User Profile -->
<div v-else-if="authStore.currentUser" <div v-else-if="authStore.currentUser"
class="bg-white border border-gray-300 shadow-lg max-w-4xl w-full overflow-hidden"> class="bg-white border border-gray-300 shadow-lg max-w-4xl w-full overflow-hidden">
<div class="bg-black p-12 text-center relative text-white border-b border-gray-300"> <div class="bg-black p-12 text-center relative text-white border-b border-gray-300">
<div class="mb-6 relative inline-block"> <div class="mb-6 relative inline-block">
<img v-if="authStore.currentUser.photo_avatar_filename" <img v-if="authStore.currentUser.photo_avatar_filename"
:src="`${API_BASE_URL.replace('/api/v1', '').replace('v1/')}/storage/photos_avatars/${authStore.currentUser.photo_avatar_filename}`" :src="`${API_BASE_URL.replace('/api/v1', '').replace('v1/', '')}/storage/photos_avatars/${authStore.currentUser.photo_avatar_filename}`"
:alt="authStore.currentUser.name" class="w-24 h-24 border-[3px] border-white object-cover" /> :alt="authStore.currentUser.name" class="w-24 h-24 border-[3px] border-white object-cover" />
<div v-else <div v-else
class="w-24 h-24 border-[3px] border-white bg-white text-black flex items-center justify-center text-4xl font-semibold"> class="w-24 h-24 border-[3px] border-white bg-white text-black flex items-center justify-center text-4xl font-semibold">
@@ -58,48 +55,60 @@
class="inline-flex items-center gap-2 bg-white text-black px-4 py-2 mt-6 font-medium text-base border border-gray-300"> class="inline-flex items-center gap-2 bg-white text-black px-4 py-2 mt-6 font-medium text-base border border-gray-300">
<svg class="w-5 h-5 text-black" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="w-5 h-5 text-black" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<circle cx="12" cy="12" r="10" stroke-width="1.5" /> <circle cx="12" cy="12" r="10" stroke-width="1.5" />
<text x="12" y="16" text-anchor="middle" fill="currentColor" font-size="12" font-weight="bold">$</text> <text x="12" y="16" text-anchor="middle" fill="currentColor" font-size="12" font-weight="bold">
$
</text>
</svg> </svg>
<span>{{ authStore.currentUser.coins_balance }} coins</span> <span>{{ authStore.currentUser.coins_balance }} coins</span>
</div> </div>
</div> </div>
<div> <div>
<!-- Tab Navigation --> <div class="flex border-b border-gray-300 bg-gray-50 overflow-x-auto">
<div class="flex border-b border-gray-300 bg-gray-50"> <button :class="[
<button 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'info' }]" { 'border-b-black text-black bg-white': activeTab === 'info' },
@click="activeTab = 'info'"> ]" @click="activeTab = 'info'">
Profile Information Profile Info
</button> </button>
<button <button :class="[
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'edit' }]" 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
@click="activeTab = 'edit'"> { 'border-b-black text-black bg-white': activeTab === 'edit' },
]" @click="activeTab = 'edit'">
Edit Profile Edit Profile
</button> </button>
<button <button v-if="!authStore.isAdmin" :class="[
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'transaction-history' }]" 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
@click="activeTab = 'transaction-history'"> { 'border-b-black text-black bg-white': activeTab === 'transaction-history' },
Transaction History ]" @click="activeTab = 'transaction-history'">
Transactions
</button> </button>
<button <button v-if="!authStore.isAdmin" :class="[
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'game-history' }]" 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
@click="activeTab = 'game-history'"> { 'border-b-black text-black bg-white': activeTab === 'game-history' },
Game History ]" @click="activeTab = 'game-history'">
Games
</button> </button>
<button <button v-if="!authStore.isAdmin" :class="[
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'password' }]" 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
@click="activeTab = 'password'"> { 'border-b-black text-black bg-white': activeTab === 'match-history' },
Change Password ]" @click="activeTab = 'match-history'">
Matches
</button> </button>
<button <button :class="[
:class="['flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black', { 'border-b-black text-black bg-white': activeTab === 'delete' }]" 'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
@click="activeTab = 'delete'"> { 'border-b-black text-black bg-white': activeTab === 'password' },
Delete Account ]" @click="activeTab = 'password'">
Security
</button>
<button v-if="!authStore.isAdmin" :class="[
'flex-1 p-4 bg-transparent border-none border-b-2 border-transparent cursor-pointer text-sm font-medium text-gray-600 transition-all hover:bg-gray-100 hover:text-black whitespace-nowrap',
{ 'border-b-black text-black bg-white': activeTab === 'delete' },
]" @click="activeTab = 'delete'">
Delete
</button> </button>
</div> </div>
<!-- Profile Information Tab -->
<div v-if="activeTab === 'info'" class="p-8"> <div v-if="activeTab === 'info'" class="p-8">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-start gap-4 p-5 bg-white border border-gray-300 transition-shadow hover:shadow-md"> <div class="flex items-start gap-4 p-5 bg-white border border-gray-300 transition-shadow hover:shadow-md">
@@ -125,7 +134,9 @@
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Account <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Account
Type</label> Type</label>
<p class="text-sm text-black">{{ authStore.currentUser.type === 'P' ? 'Player' : 'Other' }}</p> <p class="text-sm text-black">
{{ authStore.currentUser.type === 'P' ? 'Player' : 'Other' }}
</p>
</div> </div>
</div> </div>
@@ -155,8 +166,12 @@
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Status</label> <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Status</label>
<p> <p>
<span <span :class="[
:class="['inline-block px-3 py-1 text-sm font-medium border', authStore.currentUser.blocked ? 'bg-black text-white border-black' : 'bg-white text-black border-black']"> 'inline-block px-3 py-1 text-sm font-medium border',
authStore.currentUser.blocked
? 'bg-black text-white border-black'
: 'bg-white text-black border-black',
]">
{{ authStore.currentUser.blocked ? 'Blocked' : 'Active' }} {{ authStore.currentUser.blocked ? 'Blocked' : 'Active' }}
</span> </span>
</p> </p>
@@ -174,7 +189,9 @@
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Email <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Email
Verified</label> Verified</label>
<p class="text-sm text-black">{{ formatDate(authStore.currentUser.email_verified_at) }}</p> <p class="text-sm text-black">
{{ formatDate(authStore.currentUser.email_verified_at) }}
</p>
</div> </div>
</div> </div>
@@ -194,7 +211,6 @@
</div> </div>
</div> </div>
<!-- Edit Profile Tab -->
<div v-if="activeTab === 'edit'" class="p-8"> <div v-if="activeTab === 'edit'" class="p-8">
<form @submit.prevent="updateProfile" class="max-w-lg"> <form @submit.prevent="updateProfile" class="max-w-lg">
<div class="mb-6"> <div class="mb-6">
@@ -220,21 +236,18 @@
</button> </button>
</div> </div>
<div v-if="profileMessage" <div v-if="profileMessage" :class="[
:class="['mt-4 px-3 py-3 border text-sm', profileMessageType === 'success' ? 'bg-blue-50 border-black text-black' : 'bg-red-50 border-black text-black']"> 'mt-4 px-3 py-3 border text-sm',
profileMessageType === 'success'
? 'bg-blue-50 border-black text-black'
: 'bg-red-50 border-black text-black',
]">
{{ profileMessage }} {{ profileMessage }}
</div> </div>
</form> </form>
</div> </div>
<!-- Transaction History Tab --> <div v-if="activeTab === 'transaction-history' && !authStore.isAdmin" class="p-8">
<div v-if="activeTab === 'transaction-history'" class="p-8">
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="transactions.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No transactions yet.
</div>
<div v-else class="max-h-[450px] overflow-y-auto custom-scrollbar p-1">
<div class="flex flex-wrap gap-4 mb-6 items-end"> <div class="flex flex-wrap gap-4 mb-6 items-end">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">Type</label> <label class="text-xs font-medium text-muted-foreground">Type</label>
@@ -248,7 +261,7 @@
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">From</label> <label class="text-xs font-medium text-muted-foreground">From</label>
<input type="date" v-model="filters.startDate" :max="filters.endDate" @change="handleStartDateChange" <input type="date" v-model="filters.startDate" :max="filters.endDate" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" /> class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" />
</div> </div>
@@ -263,26 +276,38 @@
</button> </button>
</div> </div>
<div v-for="(transaction) in transactions" :key="transaction.id" <div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="transactions.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No transactions yet.
</div>
<div v-else class="max-h-[450px] overflow-y-auto custom-scrollbar p-1">
<div v-for="transaction in transactions" :key="transaction.id"
class="flex items-center justify-between p-4 mb-3 border rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500" class="flex items-center justify-between p-4 mb-3 border rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500"
:class="[ :class="[
transaction.type === 'credit' ? 'border-green-500/30' : 'border-red-500/30' transaction.type === 'credit' ? 'border-green-500/30' : 'border-red-500/30',
]"> ]">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold" <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-bold"
:class="transaction.type === 'credit' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"> :class="transaction.type === 'credit'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
">
<span v-if="transaction.type === 'credit'">+</span> <span v-if="transaction.type === 'credit'">+</span>
<span v-else>-</span> <span v-else>-</span>
</div> </div>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="font-semibold text-sm capitalize"> <div class="font-semibold text-sm">
{{ transaction.type }} Payment {{ transaction.category }}
</div> </div>
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground">
{{ new Date(transaction.created_at).toLocaleDateString() }} {{ new {{ new Date(transaction.began_at).toLocaleDateString() }}
Date(transaction.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }} {{
new Date(transaction.began_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}}
</div> </div>
</div> </div>
</div> </div>
@@ -290,30 +315,22 @@
<div class="text-right"> <div class="text-right">
<div class="font-bold text-sm" <div class="font-bold text-sm"
:class="transaction.type === 'credit' ? 'text-green-600' : 'text-red-600'"> :class="transaction.type === 'credit' ? 'text-green-600' : 'text-red-600'">
{{ transaction.type === 'credit' ? '+' : '-' }}${{ transaction.amount.toLocaleString() }} {{ transaction.type === 'credit' ? '+' : '-' }}{{ transaction.amount }} coins
</div> </div>
<div class="text-[10px] text-muted-foreground uppercase tracking-wider"> <div class="text-[10px] text-muted-foreground uppercase tracking-wider">
{{ transaction.id.split('_')[0] }} {{ transaction.reference }}
</div> </div>
</div> </div>
</div> </div>
<div ref="observerTarget" class="h-10 flex items-center justify-center"> <div ref="observerTarget" class="h-10 flex items-center justify-center">
<span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading <span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading more...</span>
more...</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Game History Tab --> <div v-if="activeTab === 'game-history' && !authStore.isAdmin" class="p-8">
<div v-if="activeTab === 'game-history'" class="p-8">
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="games.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No games played yet.
</div>
<div v-else class="max-h-[450px] overflow-y-auto custom-scrollbar p-1">
<div class="flex flex-wrap gap-4 mb-6 items-end"> <div class="flex flex-wrap gap-4 mb-6 items-end">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">Variant</label> <label class="text-xs font-medium text-muted-foreground">Variant</label>
@@ -326,15 +343,15 @@
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">Outcome</label> <label class="text-xs font-medium text-muted-foreground">From</label>
<select v-model="filters.outcome" @change="resetAndFetch" <input type="date" v-model="filters.startDate" :max="filters.endDate" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm outline-none"> class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" />
<option value="all">All Results</option> </div>
<option value="win">Win</option>
<option value="loss">Loss</option> <div class="flex flex-col gap-1">
<option value="draw">Draw</option> <label class="text-xs font-medium text-muted-foreground">To</label>
<option value="forfeit">Forfeit</option> <input type="date" v-model="filters.endDate" :min="filters.startDate" @change="resetAndFetch"
</select> class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" />
</div> </div>
<button @click="clearFilters" class="text-xs text-purple-500 hover:underline pb-2"> <button @click="clearFilters" class="text-xs text-purple-500 hover:underline pb-2">
@@ -348,7 +365,6 @@
<select v-model="filters.sort.column" @change="resetAndFetch" <select v-model="filters.sort.column" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500"> class="bg-background border rounded-md px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500">
<option value="began_at">Date</option> <option value="began_at">Date</option>
<option value="outcome">Outcome</option>
<option value="total_time">Duration</option> <option value="total_time">Duration</option>
</select> </select>
@@ -361,9 +377,13 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="games.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No games played yet.
</div>
<div v-else class="max-h-[450px] overflow-y-auto custom-scrollbar p-1">
<div v-for="game in games" :key="game.id" <div v-for="game in games" :key="game.id"
class="p-4 mb-3 border rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500"> class="p-4 mb-3 border rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -373,7 +393,7 @@
{{ game.outcome }} {{ game.outcome }}
</div> </div>
<div> <div>
<div class="font-semibold text-sm">vs {{ game.opponent }}</div> <div class="font-semibold text-sm">Game #{{ game.id }}</div>
<div class="text-xs text-muted-foreground"> <div class="text-xs text-muted-foreground">
{{ game.variant }} {{ game.duration }} {{ game.variant }} {{ game.duration }}
</div> </div>
@@ -381,29 +401,138 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<div class="font-bold text-sm">{{ game.amount }} pts</div>
<div class="font-bold text-sm">{{ game.points }} pts</div>
<div class="text-[10px] text-muted-foreground"> <div class="text-[10px] text-muted-foreground">
{{ new Date(game.created_at).toLocaleTimeString() }} {{ new Date(game.began_at).toLocaleDateString() }}
{{
new Date(game.began_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}}
</div> </div>
</div> </div>
</div> </div>
<div class="mt-3 pt-3 border-t flex gap-4 text-[10px] uppercase font-medium text-muted-foreground"> <div class="mt-3 pt-3 border-t flex gap-4 text-[10px] uppercase font-medium text-muted-foreground">
<span>Marks: {{ game.details.marks }}</span>
<span>Capotes: {{ game.details.capotes }}</span> <span>Capotes: {{ game.details.capotes }}</span>
<span>Bandeiras: {{ game.details.bandeiras }}</span> <span>Bandeiras: {{ game.details.bandeiras }}</span>
</div> </div>
</div> </div>
<div ref="observerTarget" class="h-10 flex items-center justify-center"> <div ref="observerTarget" class="h-10 flex items-center justify-center">
<span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading <span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading more...</span>
more...</span> </div>
</div>
</div>
</div>
<div v-if="activeTab === 'match-history' && !authStore.isAdmin" class="p-8">
<div class="flex flex-wrap gap-4 mb-6 items-end">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">Variant</label>
<select v-model="filters.variant" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm outline-none">
<option value="all">All Variants</option>
<option value="3">Bisca 3</option>
<option value="9">Bisca 9</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">From</label>
<input type="date" v-model="filters.startDate" :max="filters.endDate" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" />
</div>
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">To</label>
<input type="date" v-model="filters.endDate" :min="filters.startDate" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm focus:ring-2 focus:ring-purple-500 outline-none" />
</div>
<button @click="clearFilters" class="text-xs text-purple-500 hover:underline pb-2">
Clear Filters
</button>
<div class="flex flex-row gap-4 ml-auto">
<div class="flex flex-col gap-1">
<label class="text-xs font-medium text-muted-foreground">Sort By</label>
<div class="flex gap-2">
<select v-model="filters.sort.column" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-purple-500">
<option value="began_at">Date</option>
<option value="total_time">Duration</option>
</select>
<button @click="handleSortChange(filters.sort.column)"
class="bg-background border rounded-md px-4 py-2 text-sm outline-none transition-all hover:ring-2 hover:ring-purple-500 flex items-center justify-center min-w-[42px]"
title="Toggle Order">
<span v-if="filters.sort.order === 'desc'" class="leading-none"></span>
<span v-else class="leading-none"></span>
</button>
</div>
</div>
</div>
</div>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div v-if="matches.length === 0" class="p-6 text-center text-sm text-muted-foreground">
No matches played yet.
</div>
<div v-else class="max-h-[450px] overflow-y-auto custom-scrollbar p-1">
<div v-for="match in matches" :key="match.id"
class="p-4 mb-3 border rounded-lg transition-all hover:bg-muted/50 hover:border-purple-500">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div :class="match.outcome === 'win' ? 'text-green-500' : 'text-red-500'"
class="font-bold uppercase text-xs">
{{ match.outcome }}
</div>
<div>
<div class="font-semibold text-sm">vs {{ match.opponent }}</div>
<div class="text-xs text-muted-foreground">
{{ match.variant }} {{ match.duration }}
</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-sm" :class="{
'text-green-600': match.outcome === 'win',
'text-red-600': match.outcome === 'loss',
'text-gray-600': match.outcome === 'draw',
}">
{{ match.outcome === 'win' ? '+' : match.outcome === 'loss' ? '-' : ''
}}{{ match.amount }} coins
</div>
<div class="text-[10px] text-muted-foreground">
{{ new Date(match.began_at).toLocaleDateString() }}
{{
new Date(match.began_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
}}
</div>
</div>
</div>
<div class="mt-3 pt-3 border-t flex gap-4 text-[10px] uppercase font-medium text-muted-foreground">
<span>Capotes: {{ match.details.capotes }}</span>
<span>Bandeiras: {{ match.details.bandeiras }}</span>
</div>
</div>
<div ref="observerTarget" class="h-10 flex items-center justify-center">
<span v-if="isLoading" class="text-xs text-purple-500 animate-pulse">Loading more...</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Change Password Tab -->
<div v-if="activeTab === 'password'" class="p-8"> <div v-if="activeTab === 'password'" class="p-8">
<form @submit.prevent="changePassword" class="max-w-lg"> <form @submit.prevent="changePassword" class="max-w-lg">
<div class="mb-6"> <div class="mb-6">
@@ -441,15 +570,18 @@
</button> </button>
</div> </div>
<div v-if="passwordMessage" <div v-if="passwordMessage" :class="[
:class="['mt-4 px-3 py-3 border text-sm', passwordMessageType === 'success' ? 'bg-blue-50 border-black text-black' : 'bg-red-50 border-black text-black']"> 'mt-4 px-3 py-3 border text-sm',
passwordMessageType === 'success'
? 'bg-blue-50 border-black text-black'
: 'bg-red-50 border-black text-black',
]">
{{ passwordMessage }} {{ passwordMessage }}
</div> </div>
</form> </form>
</div> </div>
<!-- Delete Account Tab --> <div v-if="activeTab === 'delete' && !authStore.isAdmin" class="p-8 flex flex-col items-center">
<div v-if="activeTab === 'delete'" class="p-8 flex flex-col items-center">
<div class="max-w-lg"> <div class="max-w-lg">
<div class="border border-red-600 bg-red-50 p-6 mb-6"> <div class="border border-red-600 bg-red-50 p-6 mb-6">
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -466,8 +598,10 @@
</p> </p>
<ul class="text-sm text-red-800 list-disc list-inside space-y-1"> <ul class="text-sm text-red-800 list-disc list-inside space-y-1">
<li>Permanently delete all your account data</li> <li>Permanently delete all your account data</li>
<li>Forfeit your current coin balance of <strong>{{ authStore.currentUser.coins_balance }} <li>
coins</strong></li> Forfeit your current coin balance of
<strong>{{ authStore.currentUser.coins_balance }} coins</strong>
</li>
<li>Remove your access to all games and matches</li> <li>Remove your access to all games and matches</li>
<li>Delete your profile and all associated information</li> <li>Delete your profile and all associated information</li>
</ul> </ul>
@@ -476,12 +610,10 @@
</div> </div>
<div class="bg-white border border-gray-300 p-6"> <div class="bg-white border border-gray-300 p-6">
<h4 class="text-base font-semibold text-gray-900 mb-4"> <h4 class="text-base font-semibold text-gray-900 mb-4">Are you absolutely sure?</h4>
Are you absolutely sure?
</h4>
<p class="text-sm text-gray-600 mb-4"> <p class="text-sm text-gray-600 mb-4">
To confirm deletion, please type your email address: <strong class="text-black">{{ To confirm deletion, please type your email address:
authStore.currentUser.email }}</strong> <strong class="text-black">{{ authStore.currentUser.email }}</strong>
</p> </p>
<input v-model="deleteConfirmEmail" type="text" placeholder="Enter your email to confirm" <input v-model="deleteConfirmEmail" type="text" placeholder="Enter your email to confirm"
@@ -504,7 +636,6 @@
</div> </div>
</div> </div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirmation" <div v-if="showDeleteConfirmation"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
@click.self="showDeleteConfirmation = false"> @click.self="showDeleteConfirmation = false">
@@ -526,8 +657,9 @@
This is your last chance to cancel. This is your last chance to cancel.
</p> </p>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
Once you click "Yes, Delete My Account", your account and all associated data will be permanently Once you click "Yes, Delete My Account", your account and all associated data will
deleted. You will lose your <strong>{{ authStore.currentUser.coins_balance }} coins</strong> forever. be permanently deleted. You will lose your
<strong>{{ authStore.currentUser.coins_balance }} coins</strong> forever.
</p> </p>
</div> </div>
</div> </div>
@@ -551,7 +683,6 @@
</div> </div>
</div> </div>
<!-- No Data State -->
<div v-else-if="!authStore.isLoggedIn" <div v-else-if="!authStore.isLoggedIn"
class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full"> class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full">
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
@@ -579,189 +710,301 @@ const error = ref(null)
const activeTab = ref('info') const activeTab = ref('info')
const fileInput = ref(null) const fileInput = ref(null)
// Profile form
const profileForm = reactive({ const profileForm = reactive({
name: '', name: '',
nickname: '' nickname: '',
}) })
const updatingProfile = ref(false) const updatingProfile = ref(false)
const profileMessage = ref('') const profileMessage = ref('')
const profileMessageType = ref('') const profileMessageType = ref('')
// Password form
const passwordForm = reactive({ const passwordForm = reactive({
current_password: '', current_password: '',
new_password: '', new_password: '',
confirm_password: '' confirm_password: '',
}) })
const updatingPassword = ref(false) const updatingPassword = ref(false)
const passwordMessage = ref('') const passwordMessage = ref('')
const passwordMessageType = ref('') const passwordMessageType = ref('')
// Delete account
const deleteConfirmEmail = ref('') const deleteConfirmEmail = ref('')
const showDeleteConfirmation = ref(false) const showDeleteConfirmation = ref(false)
const deletingAccount = ref(false) const deletingAccount = ref(false)
const transactions = ref([]); const transactions = ref([])
const games = ref([]); const games = ref([])
const matches = ref([])
const observerTarget = ref(null); const observerTarget = ref(null)
const observer = ref(null); const observer = ref(null)
const isLoading = ref(false); const isLoading = ref(false)
const pagination = ref({ const pagination = ref({
transactions: { page: 1, hasMore: true }, transactions: { page: 1, hasMore: true },
games: { page: 1, hasMore: true } games: { page: 1, hasMore: true },
}); matches: { page: 1, hasMore: true },
})
const filters = ref({ const filters = ref({
type: 'all', type: 'all',
variant: 'all', variant: 'all',
outcome: 'all',
startDate: '', startDate: '',
endDate: '', endDate: '',
sort: { sort: {
column: 'created_at', column: 'began_at',
order: 'desc' order: 'desc',
} },
}); })
const handleSortChange = (column) => { const handleSortChange = (column) => {
// If clicking same column, toggle order, else default to desc
if (filters.value.sort.column === column) { if (filters.value.sort.column === column) {
filters.value.sort.order = filters.value.sort.order === 'asc' ? 'desc' : 'asc'; filters.value.sort.order = filters.value.sort.order === 'asc' ? 'desc' : 'asc'
} else { } else {
filters.value.sort.column = column; filters.value.sort.column = column
filters.value.sort.order = 'desc'; filters.value.sort.order = 'desc'
} }
resetAndFetch(); resetAndFetch()
}; }
watch(activeTab, async (newTab) => { watch(activeTab, async (newTab) => {
if (newTab !== 'transaction-history' && newTab !== 'game-history') return; if (newTab !== 'transaction-history' && newTab !== 'game-history' && newTab !== 'match-history')
return
await nextTick(); await nextTick()
if (transactions.value.length !== 0 && games.value.length !== 0) return; if (newTab === 'transaction-history' && transactions.value.length > 0) return
if (newTab === 'game-history' && games.value.length > 0) return
if (newTab === 'match-history' && matches.value.length > 0) return
await fetchData(); await fetchData()
setupObserver(); setupObserver()
}); })
const fetchData = async () => { const fetchData = async () => {
const currentTab = activeTab.value; const currentTab = activeTab.value
const isTransaction = currentTab === 'transaction-history'; const isTransaction = currentTab === 'transaction-history'
const isGame = currentTab === 'game-history'; const isGame = currentTab === 'game-history'
const state = isTransaction ? pagination.value.transactions : pagination.value.games; const isMatch = currentTab === 'match-history'
if (isLoading.value || !state.hasMore) return; let state
isLoading.value = true; if (isTransaction) state = pagination.value.transactions
else if (isGame) state = pagination.value.games
else if (isMatch) state = pagination.value.matches
if (isLoading.value || !state || !state.hasMore) return
isLoading.value = true
try { try {
let newData = []; let newData = []
if (isTransaction) {
// const response = await apiStore.getTransactions(params);
// Logic: if response.data.length < limit, set hasMore.value = false
for (let i = 0; i < 15; i++) { if (isTransaction) {
newData.push({ const apiParams = {
id: `txn_${Date.now()}_${i}`, page: state.page,
amount: Math.floor(Math.random() * 1000), type:
type: Math.random() > 0.5 ? 'credit' : 'debit', filters.value.type === 'credit' ? 'C' : filters.value.type === 'debit' ? 'D' : undefined,
created_at: new Date().toISOString() date_from: filters.value.startDate || undefined,
}); date_to: filters.value.endDate || undefined,
} }
transactions.value.push(...newData); const response = await apiStore.getCurrentUserTransactions(apiParams)
const apiTxns = response.data.data
newData = apiTxns.map((txn) => ({
id: txn.id.toString(),
type: txn.type.type === 'D' ? 'debit' : 'credit',
category: txn.type.name,
amount: Math.abs(txn.coins),
began_at: txn.transaction_datetime,
reference: txn.match_id
? `Match #${txn.match_id}`
: txn.game_id
? `Game #${txn.game_id}`
: 'N/A',
}))
transactions.value.push(...newData)
}
const gameMatchParams = {
page: state.page,
type: filters.value.variant !== 'all' ? filters.value.variant : undefined,
status: 'Ended',
date_from: filters.value.startDate || undefined,
date_to: filters.value.endDate || undefined,
sort_by: filters.value.sort.column,
sort_direction: filters.value.sort.order,
} }
if (isGame) { if (isGame) {
const apiParams = { const response = await apiStore.getCurrentUserGames(gameMatchParams)
page: state.page,
type: filters.value.variant !== 'all' ? filters.value.variant : undefined,
status: filters.value.outcome !== 'all' ? 'Ended' : undefined,
sort_by: filters.value.sort.column,
sort_direction: filters.value.sort.order
};
const response = await apiStore.getCurrentUserMatches(apiParams);
const apiGames = response.data.data const apiGames = response.data.data
newData = apiGames.map(game => { newData = apiGames.map((game) => {
const isPlayer1 = game.player1_user_id === authStore.currentUserID; let outcome = 'loss'
const opponent = isPlayer1 ? game.player2 : game.player1; if (game.game_status === 'E') {
if (game.total_time < 9999) outcome = 'win'
}
if (game.winner_user_id === authStore.currentUser.id) outcome = 'win'
let outcome = 'loss'; return {
if (game.is_draw) outcome = 'draw'; id: game.id,
else if (game.winner_user_id === authStore.currentUserID) outcome = 'win'; variant: `Type ${game.type}`,
outcome: outcome,
opponent: null,
amount: game.total_points || 0,
points: game.player1_user_id === authStore.currentUser.id
? game.player1_points
: game.player2_points,
duration: `${Math.floor(game.total_time / 60)}m ${game.total_time % 60}s`,
began_at: game.began_at,
details: {
capotes: 0,
bandeiras: 0,
},
}
})
games.value.push(...newData)
}
if (isMatch) {
const response = await apiStore.getCurrentUserMatches(gameMatchParams)
const apiMatches = response.data.data
const minutes = Math.floor(game.total_time / 60); newData = apiMatches.map((game) => {
const seconds = game.total_time % 60; const isPlayer1 = game.player1_user_id === authStore.currentUser.id
const durationStr = `${minutes}m ${seconds}s`; const opponent = isPlayer1 ? game.player2 : game.player1
let outcome = 'loss'
if (game.is_draw) outcome = 'draw'
else if (game.winner_user_id === authStore.currentUser.id) outcome = 'win'
let stakeAmount = 0
if (game.stake) {
stakeAmount = parseFloat(game.stake)
}
let displayAmount = 0
if (outcome === 'win') {
displayAmount = stakeAmount
} else if (outcome === 'loss') {
displayAmount = stakeAmount
}
return { return {
id: game.id, id: game.id,
variant: `Type ${game.type}`, variant: `Type ${game.type}`,
outcome: outcome, outcome: outcome,
opponent: opponent?.nickname || 'Unknown Player', opponent: opponent?.nickname || 'Unknown Player',
amount: isPlayer1 ? game.player1_points : game.player2_points, amount: displayAmount,
duration: durationStr, duration: `${Math.floor(game.total_time / 60)}m ${game.total_time % 60}s`,
created_at: game.began_at, began_at: game.began_at,
details: { details: {
marks: game.custom?.marks || 0, capotes: 0,
capotes: game.custom?.capotes || 0, bandeiras: 0,
bandeiras: game.custom?.bandeiras || 0 },
} }
}; })
}); matches.value.push(...newData)
games.value.push(...newData);
} }
if (newData.length < 10) state.hasMore = false; if (newData.length < 10) state.hasMore = false
state.page++; state.page++
} catch (error) { } catch (error) {
console.error("Fetch failed", error); console.error('Fetch failed', error)
} finally { } finally {
isLoading.value = false; isLoading.value = false
} }
}; }
const setupObserver = () => { const setupObserver = () => {
if (observer.value) observer.value.disconnect(); if (observer.value) observer.value.disconnect()
const scrollContainer = document.querySelector('.custom-scrollbar'); const scrollContainer = document.querySelector('.custom-scrollbar')
observer.value = new IntersectionObserver((entries) => { observer.value = new IntersectionObserver(
if (entries[0].isIntersecting && !isLoading.value) { async (entries) => {
fetchData(); const isGame = activeTab.value === 'game-history'
const isMatch = activeTab.value === 'match-history'
let hasMore = false
if (activeTab.value === 'transaction-history') hasMore = pagination.value.transactions.hasMore
else if (isGame) hasMore = pagination.value.games.hasMore
else if (isMatch) hasMore = pagination.value.matches.hasMore
if (entries[0].isIntersecting && !isLoading.value && hasMore) {
fetchData()
} }
}, { root: scrollContainer, threshold: 0.1 }); },
{
root: scrollContainer,
threshold: 0.1,
rootMargin: '100px',
},
)
if (observerTarget.value) observer.value.observe(observerTarget.value); if (observerTarget.value) observer.value.observe(observerTarget.value)
}; }
const resetAndFetch = () => { const resetAndFetch = async () => {
if (activeTab.value === 'transaction-history') { if (activeTab.value === 'transaction-history') {
transactions.value = []; transactions.value = []
pagination.value.transactions = { page: 1, hasMore: true }; pagination.value.transactions = { page: 1, hasMore: true }
} }
if (activeTab.value === 'game-history') { if (activeTab.value === 'game-history') {
games.value = []; games.value = []
pagination.value.games = { page: 1, hasMore: true }; pagination.value.games = { page: 1, hasMore: true }
} }
fetchData(); if (activeTab.value === 'match-history') {
}; matches.value = []
pagination.value.matches = { page: 1, hasMore: true }
}
await nextTick()
await fetchData()
setupObserver()
}
const clearFilters = () => {
const isGameOrMatch = activeTab.value === 'game-history' || activeTab.value === 'match-history'
const defaultSortColumn = isGameOrMatch ? 'began_at' : 'transaction_datetime'
const isAlreadyDefault =
filters.value.startDate === '' &&
filters.value.endDate === '' &&
filters.value.sort.column === defaultSortColumn &&
filters.value.sort.order === 'desc' &&
(isGameOrMatch
? filters.value.variant === 'all' && filters.value.outcome === 'all'
: filters.value.type === 'all')
if (isAlreadyDefault) {
return
}
if (isGameOrMatch) {
filters.value.variant = 'all'
} else {
filters.value.type = 'all'
}
filters.value.startDate = ''
filters.value.endDate = ''
filters.value.sort = {
column: defaultSortColumn,
order: 'desc',
}
resetAndFetch()
}
const formatDate = (dateString) => { const formatDate = (dateString) => {
const date = new Date(dateString) const date = new Date(dateString)
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric',
}) })
} }
@@ -816,7 +1059,7 @@ const updateProfile = async () => {
try { try {
const response = await axios.put(`${API_BASE_URL}/users/me`, { const response = await axios.put(`${API_BASE_URL}/users/me`, {
name: profileForm.name, name: profileForm.name,
nickname: profileForm.nickname nickname: profileForm.nickname,
}) })
authStore.currentUser = response.data authStore.currentUser = response.data
@@ -851,7 +1094,7 @@ const changePassword = async () => {
await axios.put(`${API_BASE_URL}/users/me/password`, { await axios.put(`${API_BASE_URL}/users/me/password`, {
current_password: passwordForm.current_password, current_password: passwordForm.current_password,
new_password: passwordForm.new_password, new_password: passwordForm.new_password,
new_password_confirmation: passwordForm.confirm_password new_password_confirmation: passwordForm.confirm_password,
}) })
passwordMessage.value = 'Password changed successfully!' passwordMessage.value = 'Password changed successfully!'
@@ -879,8 +1122,8 @@ const handleAvatarUpload = async (event) => {
try { try {
const response = await axios.post(`${API_BASE_URL}/users/me/avatar`, formData, { const response = await axios.post(`${API_BASE_URL}/users/me/avatar`, formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data' 'Content-Type': 'multipart/form-data',
} },
}) })
authStore.currentUser = response.data authStore.currentUser = response.data
@@ -895,13 +1138,10 @@ const deleteAccount = async () => {
try { try {
await axios.delete(`${API_BASE_URL}/users/me`) await axios.delete(`${API_BASE_URL}/users/me`)
// Logout user
await authStore.logout() await authStore.logout()
// Close modal
showDeleteConfirmation.value = false showDeleteConfirmation.value = false
// Redirect to home/login
router.push('/login') router.push('/login')
} catch (err) { } catch (err) {
deletingAccount.value = false deletingAccount.value = false
@@ -917,8 +1157,8 @@ onMounted(async () => {
} }
const route = useRoute() const route = useRoute()
if (route.query.tab) { if (route.query.tab) {
activeTab.value = route.query.tab; activeTab.value = route.query.tab
router.replace({ query: {} }); router.replace({ query: {} })
} }
if (!authStore.currentUser) { if (!authStore.currentUser) {
+32 -32
View File
@@ -1,15 +1,14 @@
import HomePage from '@/pages/home/HomePage.vue' import HomePage from '@/pages/home/HomePage.vue'
import LoginPage from '@/pages/login/LoginPage.vue' import LoginPage from '@/pages/login/LoginPage.vue'
import LaravelPage from '@/pages/testing/LaravelPage.vue'
import WebsocketsPage from '@/pages/testing/WebsocketsPage.vue'
import UserPage from '@/pages/user/UserPage.vue' import UserPage from '@/pages/user/UserPage.vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import TestAnimations from '@/pages/TestAnimations.vue'
import TestDealing from '@/pages/TestDealing.vue'
import TestAllAnimations from '@/pages/TestAllAnimations.vue'
import TestGameBoard from '@/pages/TestGameBoard.vue'
import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue' import SinglePlayerGamePage from '@/pages/game/SinglePlayerGamePage.vue'
import MultiplayerGamePage from '@/pages/game/MultiplayerGamePage.vue'
import MultiplayerMatchPage from '@/pages/game/MultiplayerMatchPage.vue'
import RegisterPage from '@/pages/register/RegisterPage.vue' import RegisterPage from '@/pages/register/RegisterPage.vue'
import CoinsPurchasePage from '@/pages/purchase/CoinPurchasePage.vue'
import { useAuthStore } from '@/stores/auth'
import AdminPage from '@/pages/admin/AdminPage.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -18,6 +17,7 @@ const router = createRouter({
path: '/', path: '/',
name: 'home', name: 'home',
component: HomePage, component: HomePage,
meta: { requiresAdmin: false },
}, },
{ {
path: '/login', path: '/login',
@@ -34,7 +34,7 @@ const router = createRouter({
name: 'bisca3', name: 'bisca3',
component: SinglePlayerGamePage, component: SinglePlayerGamePage,
props: { gameType: 3 }, props: { gameType: 3 },
meta: { requiresAuth: true }, meta: { requiresAuth: true, requiresAdmin: false },
}, },
{ {
path: '/game/9', path: '/game/9',
@@ -43,44 +43,40 @@ const router = createRouter({
props: { gameType: 9 }, props: { gameType: 9 },
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/game/multiplayer/:id',
name: 'multiplayer-game',
component: MultiplayerGamePage,
meta: { requiresAuth: true },
},
{
path: '/match/:id',
name: 'multiplayer-match',
component: MultiplayerMatchPage,
meta: { requiresAuth: true }
},
{ {
path: '/user', path: '/user',
component: UserPage, component: UserPage,
meta: { requiresAuth: true },
}, },
{ {
path: '/testing', path: '/coins-purchase',
children: [ component: CoinsPurchasePage,
{ meta: { requiresAuth: true },
path: 'laravel',
component: LaravelPage,
}, },
{ {
path: 'websockets', path: '/admin',
component: WebsocketsPage, name: 'admin',
}, component: AdminPage,
{ meta: { requiresAuth: true, requiresAdmin: true },
path: 'animations',
component: TestAnimations,
},
{
path: 'dealing',
component: TestDealing,
},
{
path: 'all-animations',
component: TestAllAnimations,
},
{
path: 'gameboard',
component: TestGameBoard,
},
],
}, },
], ],
}) })
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth) const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
const requiresAdmin = to.matched.some((record) => record.meta.requiresAdmin)
if (requiresAuth) { if (requiresAuth) {
const token = localStorage.getItem('token') const token = localStorage.getItem('token')
@@ -89,6 +85,10 @@ router.beforeEach((to, from, next) => {
return next({ name: 'login' }) return next({ name: 'login' })
} }
} }
if (useAuthStore().isAdmin && to.name === 'home') return next({ name: 'admin' })
if (requiresAdmin && !useAuthStore().isAdmin) return next({ name: 'home' })
next() next()
}) })
+119 -4
View File
@@ -76,26 +76,141 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
const getCurrentUserGames = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.type && { type: params.type }),
...(params.outcome && { outcome: params.outcome }),
...(params.status && { status: params.status }),
...(params.date_from && { date_from: params.date_from }),
...(params.date_to && { date_to: params.date_to }),
sort_by: params.sort_by || 'began_at',
sort_direction: params.sort_direction || 'desc',
}).toString()
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/games?${queryParams}`)
}
const getCurrentUserMatches = (params = {}) => { const getCurrentUserMatches = (params = {}) => {
const queryParams = new URLSearchParams({ const queryParams = new URLSearchParams({
page: params.page || 1, page: params.page || 1,
...(params.type && { type: params.type }), ...(params.type && { type: params.type }),
...(params.outcome && { outcome: params.outcome }),
...(params.status && { status: params.status }), ...(params.status && { status: params.status }),
sort_by: params.sort || 'began_at', ...(params.date_from && { date_from: params.date_from }),
sort_direction: params.direction || 'desc', ...(params.date_to && { date_to: params.date_to }),
sort_by: params.sort_by || 'began_at',
sort_direction: params.sort_direction || 'desc',
}).toString() }).toString()
console.log(params, queryParams)
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/matches?${queryParams}`) return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/matches?${queryParams}`)
} }
const getCurrentUserTransactions = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.type && { type: params.type }),
...(params.blocked && { blocked: params.blocked }),
}).toString()
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/transactions?${queryParams}`)
}
const getLeaderboard = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
}).toString()
return axios.get(`${API_BASE_URL}/leaderboard?${queryParams}`)
}
const getCurrentUserStats = () => {
return axios.get(`${API_BASE_URL}/statistics/me`)
}
const getPublicStats = () => {
return axios.get(`${API_BASE_URL}/statistics/public`)
}
const getAdminStats = () => {
return axios.get(`${API_BASE_URL}/admin/statistics`)
}
const getAdminUsers = (params) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.type && { type: params.type }),
...(params.blocked && { blocked: params.blocked }),
}).toString()
return axios.get(`${API_BASE_URL}/admin/users?${queryParams}`)
}
const blockUser = (user) => {
return axios.post(`${API_BASE_URL}/admin/users/${user.id}/block`)
}
const unblockUser = (user) => {
return axios.post(`${API_BASE_URL}/admin/users/${user.id}/unblock`)
}
const deleteUser = (user) => {
return axios.delete(`${API_BASE_URL}/admin/users/${user.id}`)
}
const getAdminTransactions = (params) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.sort_datetime == 'asc' && { sort_datetime: 'asc' }),
...(params.sort_coins && { sort_coins: params.sort_coins }),
}).toString()
return axios.get(`${API_BASE_URL}/admin/transactions?${queryParams}`)
}
const getAdminGames = (params) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.sort_direction == 'asc' && { sort_direction: 'asc' }),
...(params.type && { type: params.type }),
}).toString()
return axios.get(`${API_BASE_URL}/games?${queryParams}`)
}
const getAdminMatches = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.sort_direction == 'asc' && { sort_direction: 'asc' }),
...(params.type && { type: params.type }),
...(params.pot && { pot: params.pot }),
}).toString()
return axios.get(`${API_BASE_URL}/matches?${queryParams}`)
}
const postAdmin = async (credentials) => {
return axios.post(`${API_BASE_URL}/admin/users`, credentials)
}
return { return {
postLogin, postLogin,
postLogout, postLogout,
getAuthUser, getAuthUser,
getGames, getGames,
getCurrentUserGames,
getCurrentUserMatches, getCurrentUserMatches,
getCurrentUserTransactions,
getCurrentUserStats,
getLeaderboard,
getPublicStats,
getAdminStats,
getAdminUsers,
blockUser,
unblockUser,
deleteUser,
getAdminTransactions,
getAdminGames,
getAdminMatches,
postAdmin,
gameQueryParameters, gameQueryParameters,
} }
}) })
+18 -7
View File
@@ -3,13 +3,14 @@ import { ref, computed } from 'vue'
import { useAPIStore } from './api' import { useAPIStore } from './api'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const DEFAULT_TIMEOUT = 30 * 60 * 1000
const DEFAULT_TIMEOUT = 5 * 60 * 1000
const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000 const REMEMBER_ME_TIMEOUT = 365 * 24 * 60 * 60 * 1000
const currentUser = ref(undefined) const currentUser = ref(undefined)
const token = ref(localStorage.getItem('token') || null) const token = ref(localStorage.getItem('token') || null)
const tokenExpiry = ref(localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null) const tokenExpiry = ref(
localStorage.getItem('tokenExpiry') ? parseInt(localStorage.getItem('tokenExpiry')) : null,
)
const rememberMe = ref(false) const rememberMe = ref(false)
const isLoggedIn = computed(() => { const isLoggedIn = computed(() => {
@@ -20,10 +21,14 @@ export const useAuthStore = defineStore('auth', () => {
return currentUser.value?.id return currentUser.value?.id
}) })
const isAdmin = computed(() => {
return currentUser.value?.type === 'A'
})
const isTokenAlmostExpired = () => { const isTokenAlmostExpired = () => {
if (!tokenExpiry.value) return false; if (!tokenExpiry.value) return false
return Date.now() > (tokenExpiry.value - 60 * 1000); return Date.now() > tokenExpiry.value - 60 * 1000
}; }
const resetTokenExpiry = () => { const resetTokenExpiry = () => {
const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT const timeout = rememberMe.value ? REMEMBER_ME_TIMEOUT : DEFAULT_TIMEOUT
@@ -102,7 +107,6 @@ export const useAuthStore = defineStore('auth', () => {
currentUser.value = userResp.data currentUser.value = userResp.data
localStorage.setItem('currentUser', JSON.stringify(userResp.data)) localStorage.setItem('currentUser', JSON.stringify(userResp.data))
} catch (apiError) { } catch (apiError) {
// API call failed, likely token is invalid
logout() logout()
return false return false
} }
@@ -112,10 +116,16 @@ export const useAuthStore = defineStore('auth', () => {
return true return true
} }
const updateCurrentUserCoins = (coins) => {
if (!currentUser.value) return
currentUser.value.coins_balance = coins
}
return { return {
currentUser, currentUser,
isLoggedIn, isLoggedIn,
currentUserID, currentUserID,
isAdmin,
token, token,
rememberMe, rememberMe,
getToken, getToken,
@@ -124,5 +134,6 @@ export const useAuthStore = defineStore('auth', () => {
resetTokenExpiry, resetTokenExpiry,
isTokenAlmostExpired, isTokenAlmostExpired,
restoreSession, restoreSession,
updateCurrentUserCoins,
} }
}) })
-1
View File
@@ -273,7 +273,6 @@ export const useBiscaStore = defineStore('bisca', () => {
playerScore.value = 0 playerScore.value = 0
winner.value = 'opponent' winner.value = 'opponent'
// Parar o jogo imediatamente
isGameRunning.value = false isGameRunning.value = false
isGameOver.value = true isGameOver.value = true
} }
+16
View File
@@ -0,0 +1,16 @@
import axios from 'axios'
import { defineStore } from 'pinia'
import { inject } from 'vue'
export const usePurchaseStore = defineStore('purchase', () => {
const API_BASE_URL = inject('apiBaseURL')
const purchase = async (data) => {
return await axios.post(`${API_BASE_URL}/coin-purchases`, data)
}
return {
purchase,
}
})
+120 -13
View File
@@ -1,24 +1,131 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { inject } from 'vue' import { ref } from 'vue'
import { io } from 'socket.io-client'
import { useAuthStore } from './auth'
export const useSocketStore = defineStore('socket', () => { export const useSocketStore = defineStore('websocket', () => {
const socket = inject('socket') const authStore = useAuthStore()
const handleConnection = () => { const socket = ref(null)
try { const connected = ref(false)
socket.on('connect', () => { const gameState = ref(null)
console.log(`[Socket] Connected -- ${socket.id}`) const lastError = ref(null)
const currentGameId = ref(null)
const WS_URL = import.meta.env.VITE_WS_CONNECTION || 'ws://localhost:3000'
const connect = () => {
if (socket.value?.connected) return
const token = authStore.getToken()
if (!token) {
console.error('No token available for WebSocket connection')
return
}
socket.value = io(WS_URL, {
auth: { token: `Bearer ${token}` },
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
}) })
socket.on('disconnect', () => {
console.log(`[Socket] Disconnected -- ${socket.id}`) console.log('[WebSocket] Attempting to connect to', WS_URL)
socket.value.on('connect', () => {
if (currentGameId.value) {
console.log('Reconnected to socket. Rejoining game...')
socket.emit('join-game', { gameId: currentGameId.value })
}
connected.value = true
console.log('[WebSocket] Connected:', socket.value.id)
}) })
} catch (error) {
console.error('[Socket] Connection error:', error) socket.value.on('disconnect', (reason) => {
connected.value = false
console.log('[WebSocket] Disconnected:', reason)
})
socket.value.on('connect_error', (error) => {
console.error('[WebSocket] Connection error:', error.message)
lastError.value = error.message
})
socket.value.on('heartbeat', () => {
socket.value.emit('heartbeat_ack')
})
socket.value.on('game-state', (state) => {
gameState.value = state
})
socket.value.on('game-error', (data) => {
lastError.value = data.message
console.error('[Game] Error:', data.message)
})
socket.value.on('error', (data) => {
lastError.value = data.message
console.error('[Socket] Error:', data.message)
})
}
const disconnect = () => {
if (socket.value) {
socket.value.disconnect()
socket.value = null
connected.value = false
gameState.value = null
lastError.value = null
currentGameId.value = null
}
}
const joinGame = (gameId) => {
currentGameId.value = gameId
if (!socket.value?.connected) return
console.log('[Game] Joining game:', gameId)
socket.value.emit('join-game', { gameId })
}
const playCard = (gameId, cardId) => {
if (!socket.value?.connected) return
socket.value.emit('play-card', { gameId, cardId })
}
const surrender = (gameId) => {
if (!socket.value?.connected) return
console.log('[Game] Surrendering:', gameId)
socket.value.emit('surrender', { gameId })
}
const onTrickEnd = (callback) => {
if (socket.value) {
socket.value.off('trick-end')
socket.value.on('trick-end', callback)
}
}
const onGameOver = (callback) => {
if (socket.value) {
socket.value.off('game-over')
socket.value.on('game-over', callback)
} }
} }
return { return {
handleConnection, socket,
connected,
gameState,
lastError,
currentGameId,
connect,
disconnect,
joinGame,
playCard,
surrender,
onTrickEnd,
onGameOver,
} }
}) })
+15 -5
View File
@@ -1,16 +1,26 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import axios from 'axios' import axios from 'axios'
import { inject } from 'vue' import { inject, ref } from 'vue'
import { useAuthStore } from './auth'
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const API_BASE_URL = inject('apiBaseURL') const API_BASE_URL = inject('apiBaseURL')
const coins = ref(0)
const getCoins = async () => { const fetchCoins = async () => {
return await axios.get(`${API_BASE_URL}/users/me/coins`) try {
const response = await axios.get(`${API_BASE_URL}/users/me/coins`)
coins.value = response.data?.coins || 0
useAuthStore().updateCurrentUserCoins(coins.value)
return response
} catch (error) {
console.error('Error fetching coins', error)
throw error
}
} }
return { return {
getCoins, coins,
fetchCoins,
} }
}) })
+2 -2
View File
@@ -1,6 +1,6 @@
GROUP := "dad-group-x" GROUP := "dad-group-46"
VERSION := "1.0.0" VERSION := "2.0.9"
kubectl-pods: kubectl-pods:
+6
View File
@@ -0,0 +1,6 @@
{
"name": "DADProject",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
-2
View File
@@ -1,2 +0,0 @@
PORT=
API_URL=
+62 -6
View File
@@ -1,11 +1,16 @@
class BiscaGame { class BiscaGame {
constructor(id, type, player1, player2) { constructor(id, type, player1, player2, dbId = null, onGameEnd = null) {
this.id = id; this.id = id;
this.dbId = dbId;
this.type = type; this.type = type;
this.onGameEnd = onGameEnd;
this.deck = []; this.deck = [];
this.trumpCard = null; this.trumpCard = null;
this.trumpSuit = null; this.trumpSuit = null;
this.timer = null;
this.turnDeadline = null;
this.timeoutCallback = null;
this.table = { this.table = {
playerCard: null, playerCard: null,
@@ -43,13 +48,54 @@ class BiscaGame {
this.dealInitialCards(); this.dealInitialCards();
const playerIds = Object.keys(this.players); const playerKeys = Object.keys(this.players);
this.currentTurn = playerIds.find((id) => id != 1) || playerIds[0]; const startKey = playerKeys.find((key) => key != 1) || playerKeys[0];
this.currentTurn = this.players[startKey].id;
this.startTime = Date.now(); this.startTime = Date.now();
this.status = "playing"; this.status = "playing";
} }
startTurnTimer(callback) {
if (this.timer) clearTimeout(this.timer);
this.timeoutCallback = callback;
const DURATION_MS = 20000;
this.turnDeadline = Date.now() + DURATION_MS;
console.log(`[Game ${this.id}] Timer started for User ${this.currentTurn}`);
this.timer = setTimeout(() => {
this.handleTimeout();
}, DURATION_MS);
}
stopTimer() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.turnDeadline = null;
}
handleTimeout() {
console.log(
`[Game ${this.id}] Timeout! Auto-playing for ${this.currentTurn}`
);
const lowestCard = this.getLowestCard(this.currentTurn);
if (lowestCard && this.timeoutCallback) {
this.timeoutCallback(this.currentTurn, lowestCard.id);
}
}
getLowestCard(userId) {
const hand = this.players[userId].hand;
if (!hand || hand.length === 0) return null;
const sortedHand = [...hand].sort((a, b) => a.strength - b.strength);
return sortedHand[0];
}
createDeck() { createDeck() {
const suits = ["c", "e", "o", "p"]; const suits = ["c", "e", "o", "p"];
const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13]; const ranks = [1, 2, 3, 4, 5, 6, 7, 11, 12, 13];
@@ -105,6 +151,8 @@ class BiscaGame {
} }
} }
this.stopTimer();
player.hand.splice(cardIndex, 1); player.hand.splice(cardIndex, 1);
if (!this.table.playerCard) { if (!this.table.playerCard) {
@@ -142,7 +190,7 @@ class BiscaGame {
this.players[winnerId].tricks += 1; this.players[winnerId].tricks += 1;
this.currentTurn = winnerId; this.currentTurn = winnerId;
if (this.type == 3) { if (this.deck.length > 0 || this.trumpCard) {
this.drawCard(winnerId); this.drawCard(winnerId);
this.drawCard(this.getOpponentId(winnerId)); this.drawCard(this.getOpponentId(winnerId));
} }
@@ -152,6 +200,7 @@ class BiscaGame {
if (this.players[winnerId].hand.length === 0) { if (this.players[winnerId].hand.length === 0) {
this.status = "finished"; this.status = "finished";
this.stopTimer();
const pIds = Object.keys(this.players); const pIds = Object.keys(this.players);
const p1Obj = this.players[pIds[0]]; const p1Obj = this.players[pIds[0]];
@@ -173,7 +222,7 @@ class BiscaGame {
? p2Obj.id ? p2Obj.id
: p1Obj.id; : p1Obj.id;
return { const result = {
action: "game_ended", action: "game_ended",
trickResult, trickResult,
winnerId: finalWinner, winnerId: finalWinner,
@@ -185,6 +234,11 @@ class BiscaGame {
player2_points: p2Obj.points, player2_points: p2Obj.points,
totalTime: totalTime, totalTime: totalTime,
}; };
if (this.onGameEnd) {
this.onGameEnd(result);
}
return result;
} }
return { action: "trick_resolved", trickResult }; return { action: "trick_resolved", trickResult };
@@ -194,6 +248,7 @@ class BiscaGame {
const oppId = this.getOpponentId(userId); const oppId = this.getOpponentId(userId);
return { return {
id: this.id, id: this.id,
dbId: this.dbId,
trumpCard: this.trumpCard, trumpCard: this.trumpCard,
trumpSuit: this.trumpSuit, trumpSuit: this.trumpSuit,
deckCount: this.deck.length + (this.trumpCard ? 1 : 0), deckCount: this.deck.length + (this.trumpCard ? 1 : 0),
@@ -205,6 +260,7 @@ class BiscaGame {
points: this.players[oppId].points, points: this.players[oppId].points,
cardCount: this.players[oppId].hand.length, cardCount: this.players[oppId].hand.length,
}, },
turnDeadline: this.turnDeadline,
}; };
} }
@@ -232,4 +288,4 @@ class BiscaGame {
} }
} }
module.exports = BiscaGame; export default BiscaGame;
+270
View File
@@ -0,0 +1,270 @@
import BiscaGame from "../classes/BiscaGame.js";
import axios from "axios";
const API_URL = process.env.API_URL || "http://localhost:8000/api/v1";
class BiscaMatch {
constructor(apiData, emitStateCallback, token, autoPlayCallback) {
this.id = apiData.id;
this.emitStateCallback = emitStateCallback;
this.autoPlayCallback = autoPlayCallback;
this.token = token;
this.stake = apiData.stake;
this.type = apiData.type;
this.status = apiData.status;
this.player1Id = apiData.player1_user_id;
this.player2Id = apiData.player2_user_id;
this.player1 = apiData.player1;
this.player2 = apiData.player2;
this.marks = {
[this.player1Id]: apiData.player1_marks || 0,
[this.player2Id]: apiData.player2_marks || 0,
};
this.points = {
[this.player1Id]: apiData.player1_points || 0,
[this.player2Id]: apiData.player2_points || 0,
};
this.currentGame = null;
this.GHOST_ID = 1;
if (this.status === "Playing" && this.player2Id !== this.GHOST_ID) {
this.startNewGame();
}
}
async joinPlayer(userId, user) {
if (this.player2Id !== this.GHOST_ID) return false;
if (userId === this.player1Id) return false;
this.player2Id = userId;
this.player2 = user;
this.marks[userId] = 0;
this.points[userId] = 0;
this.status = "Playing";
await this.startNewGame();
return true;
}
getAuthHeaders() {
return {
headers: {
Authorization: this.token,
"Content-Type": "application/json",
Accept: "application/json",
},
};
}
async startNewGame() {
console.log(`[Match ${this.id}] Starting new round...`);
let newGameDbId = null;
try {
const payload = {
type: this.type,
player1_user_id: this.player1Id,
player2_user_id: this.player2Id,
match_id: this.id,
status: "Playing",
began_at: new Date().toISOString(),
};
const res = await axios.post(
`${API_URL}/games`,
payload,
this.getAuthHeaders()
);
const gameData = res.data.game || res.data.data || res.data;
newGameDbId = gameData.id;
const p1Name = this.player1?.nickname || `User ${this.player1Id}`;
const p2Name = this.player2?.nickname || `User ${this.player2Id}`;
this.currentGame = new BiscaGame(
this.id,
this.type,
{ id: this.player1Id, name: p1Name },
{ id: this.player2Id, name: p2Name },
newGameDbId,
(result) => {
this.handleGameEnd(result);
}
);
this.currentGame.init();
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
if (this.emitStateCallback) {
this.emitStateCallback("match:new-round-start", null);
}
} catch (e) {
console.error(`❌ Start Game Error: ${e.message}`);
}
}
async abortCurrentGame(loserId) {
if (this.currentGame && this.currentGame.dbId) {
console.log(`[Match ${this.id}] Aborting Game #${this.currentGame.dbId}`);
try {
const winnerId =
loserId == this.player1Id ? this.player2Id : this.player1Id;
const payload = {
status: "Ended",
winner_user_id: winnerId,
loser_user_id: loserId,
is_draw: false,
ended_at: new Date().toISOString(),
};
await axios.put(
`${API_URL}/games/${this.currentGame.dbId}`,
payload,
this.getAuthHeaders()
);
} catch (e) {
console.error("Error aborting game:", e.message);
}
}
}
async handleGameEnd(result) {
console.log(`[Match ${this.id}] Round ended. Winner: ${result.winnerId}`);
if (this.points[result.player1_id] !== undefined)
this.points[result.player1_id] += result.player1_points;
if (this.points[result.player2_id] !== undefined)
this.points[result.player2_id] += result.player2_points;
if (this.currentGame && this.currentGame.dbId) {
try {
const payload = {
status: "Ended",
winner_user_id: result.winnerId,
loser_user_id: result.loserId,
is_draw: result.isDraw,
player1_points: result.player1_points,
player2_points: result.player2_points,
ended_at: new Date().toISOString(),
};
await axios.put(
`${API_URL}/games/${this.currentGame.dbId}`,
payload,
this.getAuthHeaders()
);
} catch (e) {
console.error("DB Game Update Error:", e.message);
}
}
let marksToAdd = 0;
let roundWinnerId = result.winnerId;
if (roundWinnerId) {
const winningScore =
roundWinnerId == result.player1_id
? result.player1_points
: result.player2_points;
if (winningScore === 120) marksToAdd = 4;
else if (winningScore > 90) marksToAdd = 2;
else if (winningScore >= 60) marksToAdd = 1;
if (this.marks[roundWinnerId] !== undefined)
this.marks[roundWinnerId] += marksToAdd;
}
if (this.emitStateCallback) {
this.emitStateCallback("match:round-ended", {
winnerId: roundWinnerId,
marksAdded: marksToAdd,
p1Points: result.player1_points,
p2Points: result.player2_points,
p1Marks: this.marks[this.player1Id],
p2Marks: this.marks[this.player2Id],
reason: result.reason || "points",
});
}
if (this.marks[this.player1Id] >= 4 || this.marks[this.player2Id] >= 4) {
await this.finishMatch();
if (this.emitStateCallback)
this.emitStateCallback("match:ended-signal", null);
} else {
setTimeout(() => {
this.startNewGame();
}, 1000);
}
}
async finishMatch() {
this.status = "Ended";
const winnerId =
this.marks[this.player1Id] >= 4 ? this.player1Id : this.player2Id;
const loserId =
winnerId == this.player1Id ? this.player2Id : this.player1Id;
const payload = {
status: "Ended",
winner_user_id: winnerId,
loser_user_id: loserId,
player1_marks: this.marks[this.player1Id],
player2_marks: this.marks[this.player2Id],
player1_points: this.points[this.player1Id],
player2_points: this.points[this.player2Id],
};
try {
await axios.put(
`${API_URL}/matches/${this.id}`,
payload,
this.getAuthHeaders()
);
console.log(`[Match ${this.id}] Match Closed in DB.`);
} catch (e) {
console.error(`Match Close Error: ${e.message}`);
}
}
playCard(userId, cardId) {
if (!this.currentGame || this.status !== "Playing") return;
const result = this.currentGame.playCard(userId, cardId);
if (
result &&
(result.action === "next_turn" || result.action === "trick_resolved")
) {
this.currentGame.startTurnTimer((uid, cid) => {
if (this.autoPlayCallback) this.autoPlayCallback(uid, cid);
});
}
return result;
}
getGameState(userId) {
if (!this.currentGame) return null;
if (this.currentGame.players[userId])
return this.currentGame.getStateForPlayer(userId);
return null;
}
getPublicState() {
return {
matchId: this.id,
status: this.status,
marks: this.marks,
points: this.points,
player1: this.player1,
player2: this.player2,
};
}
}
export default BiscaMatch;
+3 -6
View File
@@ -1,7 +1,7 @@
import { addUser, removeUser } from "../state/connection"; import { addUser, removeUser } from "../state/connection.js";
const HEARTBEAT_INTERVAL = 30000; // 30 seconds const HEARTBEAT_INTERVAL = 30000;
const HEARTBEAT_TIMEOUT = 10000; // 10 seconds const HEARTBEAT_TIMEOUT = 10000;
export const handleConnectionEvents = (io, socket) => { export const handleConnectionEvents = (io, socket) => {
let heartbeatInterval; let heartbeatInterval;
@@ -27,20 +27,17 @@ export const handleConnectionEvents = (io, socket) => {
socket.on("join", (user) => { socket.on("join", (user) => {
addUser(socket, user); addUser(socket, user);
console.log(`[Connection] User ${user.name} has joined the server`); console.log(`[Connection] User ${user.name} has joined the server`);
console.log(`[Connection] ${getUserCount()} users online`);
}); });
socket.on("leave", () => { socket.on("leave", () => {
const user = removeUser(socket.id); const user = removeUser(socket.id);
if (user) { if (user) {
console.log(`[Connection] User ${user.name} has left the server`); console.log(`[Connection] User ${user.name} has left the server`);
console.log(`[Connection] ${getUserCount()} users online`);
} }
}); });
socket.on("disconnect", () => { socket.on("disconnect", () => {
console.log("Connection Lost:", socket.id); console.log("Connection Lost:", socket.id);
removeUser(socket.id); removeUser(socket.id);
console.log(`[Connection] ${getUserCount()} users online`);
}); });
}; };
+231 -75
View File
@@ -2,7 +2,80 @@ import axios from "axios";
import { getGame, createGame } from "../state/game.js"; import { getGame, createGame } from "../state/game.js";
import { getUser } from "../state/connection.js"; import { getUser } from "../state/connection.js";
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.API_URL || "http://localhost:8000/api/v1";
const getStrength = (rank) => {
const strengthMap = {
1: 10,
7: 9,
13: 8,
11: 7,
12: 6,
6: 5,
5: 4,
4: 3,
3: 2,
2: 1,
};
return strengthMap[rank] || 0;
};
const drawCard = (game, playerId) => {
const player = game.players[playerId];
if (!player) return;
if (game.deck && game.deck.length > 0) {
const card = game.deck.pop();
player.hand.push(card);
} else if (game.trumpCard) {
player.hand.push(game.trumpCard);
game.trumpCard = null;
}
};
const sanitizeState = (state, game) => {
if (!state) return null;
const p1 = game.player1 || state.player1 || {};
const p2 = game.player2 || state.player2 || {};
return {
id: state.id,
status: state.status || game.status,
player1: { id: p1.id, name: p1.name },
player2: { id: p2.id, name: p2.name },
players: state.players,
hand: state.hand,
points: state.points,
opponent: {
id: state.opponent?.id,
name: state.opponent?.name,
points: state.opponent?.points,
cardCount: state.opponent?.cardCount,
},
table: state.table,
trumpCard: state.trumpCard,
deckCount: game.deck ? game.deck.length : state.deckCount || 0,
currentTurn: state.currentTurn,
turnStartedAt: state.turnStartedAt,
};
};
const broadcastState = (io, gameId, game) => {
const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) {
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id]) {
const rawState = game.getStateForPlayer(u.id);
const cleanState = sanitizeState(rawState, game);
s.emit("game-state", cleanState);
}
}
}
};
async function saveGameResult(gameId, result, token) { async function saveGameResult(gameId, result, token) {
try { try {
@@ -13,21 +86,116 @@ async function saveGameResult(gameId, result, token) {
player1_points: result.player1_points, player1_points: result.player1_points,
player2_points: result.player2_points, player2_points: result.player2_points,
}; };
if (!result.isDraw && result.winnerId) { if (!result.isDraw && result.winnerId) {
payload.winner_user_id = result.winnerId; payload.winner_user_id = result.winnerId;
payload.loser_user_id = result.loserId; payload.loser_user_id = result.loserId;
} }
const authHeader = token.startsWith("Bearer ") ? token : `Bearer ${token}`;
await axios.put(`${API_URL}/games/${gameId}`, payload, { await axios.put(`${API_URL}/games/${gameId}`, payload, {
headers: { Authorization: token }, headers: { Authorization: authHeader },
}); });
console.log(`[DB] Game ${gameId} saved.`);
} catch (error) { } catch (error) {
console.error(`[DB Error] Game ${gameId}:`, error.message); console.error(`[DB Error] Game ${gameId}:`, error.message);
} }
} }
const performGameEnd = (io, game, gameId, winnerId, loserId, reason) => {
const result = {
action: "game_ended",
winnerId: parseInt(winnerId),
loserId: parseInt(loserId),
totalTime: game.startTime
? Math.floor((Date.now() - game.startTime) / 1000)
: 0,
isDraw: false,
player1_points: game.players[Object.keys(game.players)[0]]?.points || 0,
player2_points: game.players[Object.keys(game.players)[1]]?.points || 0,
reason: reason,
};
game.status = "ended";
if (game.timer) clearInterval(game.timer);
io.to(`game_${gameId}`).emit("game-over", {
winnerId: result.winnerId,
isDraw: result.isDraw,
p1Points: result.player1_points,
p2Points: result.player2_points,
message: reason === "surrender" ? "Opponent Surrendered!" : "Game Over",
});
const anyId = Object.keys(game.players)[0];
const token = game.players[anyId]?.token;
if (token) saveGameResult(gameId, result, token);
};
const handleTimeout = (io, gameId, userId) => {
const game = getGame(gameId);
if (!game || game.status === "ended") return;
console.log(`[Timeout] User ${userId} auto-playing lowest card.`);
const player = game.players[userId];
if (!player || !player.hand || player.hand.length === 0) return;
const trumpSuit = game.trumpCard?.suit || "";
const sortedHand = [...player.hand].sort((a, b) => {
const strengthA = getStrength(a.rank) + (a.suit === trumpSuit ? 100 : 0);
const strengthB = getStrength(b.rank) + (b.suit === trumpSuit ? 100 : 0);
return strengthA - strengthB;
});
const cardToPlay = sortedHand[0];
if (cardToPlay) {
handleGameMove(io, gameId, userId, cardToPlay.id);
}
};
const handleGameMove = (io, gameId, userId, cardId) => {
const game = getGame(gameId);
if (!game) return;
const result = game.playCard(userId, cardId);
const roomName = `game_${gameId}`;
broadcastState(io, gameId, game);
if (result.action === "trick_resolved") {
io.to(roomName).emit("trick-end", result.trickResult);
setTimeout(() => {
if (game.status !== "ended") {
const canDraw =
(game.deck && game.deck.length > 0) || game.trumpCard !== null;
if (canDraw) {
const winnerId = result.trickResult.winnerId;
const loserId = Object.keys(game.players).find(
(id) => String(id) !== String(winnerId)
);
if (winnerId) drawCard(game, winnerId);
if (loserId) drawCard(game, loserId);
}
broadcastState(io, gameId, game);
}
}, 1500);
}
if (result.action === "game_ended") {
performGameEnd(io, game, gameId, result.winnerId, result.loserId, "points");
}
if (result.action === "next_turn" || result.action === "trick_resolved") {
game.startTurnTimer((timeoutUserId) => {
handleTimeout(io, gameId, timeoutUserId);
});
}
};
export default (io, socket) => { export default (io, socket) => {
socket.on("join-game", async ({ gameId }) => { socket.on("join-game", async ({ gameId }) => {
const user = getUser(socket.id); const user = getUser(socket.id);
@@ -37,110 +205,98 @@ export default (io, socket) => {
} }
let game = getGame(gameId); let game = getGame(gameId);
let gameData = null;
if (!game) {
try { try {
const token = socket.handshake.auth.token; const token = socket.handshake.auth.token;
const response = await axios.get(`${API_URL}/games/${gameId}`, { const response = await axios.get(`${API_URL}/games/${gameId}`, {
headers: { Authorization: token }, headers: { Authorization: token },
}); });
gameData = response.data.data || response.data;
const gameData = response.data.data || response.data; } catch (e) {
console.error("DB Sync Failed");
if (gameData.status === "Ended") {
socket.emit("error", { message: "Game already finished." });
return;
} }
if ( if (!game && gameData) {
gameData.player1_user_id != user.id && const p1Name = gameData.player1?.nickname || "Player 1";
gameData.player2_user_id != user.id && const p2Name = gameData.player2?.nickname || "Player 2";
gameData.player2_user_id !== 1
) {
socket.emit("error", { message: "You are not in this game." });
return;
}
const p1Name = gameData.player1?.name || "Player 1";
const p2Name = gameData.player2?.name || "Player 2";
const type = gameData.type == "9" ? 9 : 3; const type = gameData.type == "9" ? 9 : 3;
game = createGame( game = createGame(
gameId, gameId,
type, type,
{ id: gameData.player1_user_id, name: p1Name }, { id: gameData.player1_user_id, name: p1Name },
{ id: gameData.player2_user_id, name: p2Name } { id: gameData.player2_user_id, name: p2Name }
); );
} catch (error) { game.status = gameData.status === "Playing" ? "playing" : "pending";
socket.emit("error", { message: "Failed to join game." }); }
if (!game) {
socket.emit("error", { message: "Game not found." });
return; return;
} }
const myId = user.id;
const ghostKey = Object.keys(game.players).find((key) => key == 1);
const amIDbPlayer2 = gameData && gameData.player2_user_id == myId;
if (!game.players[myId] && (ghostKey || amIDbPlayer2)) {
let ghostHand = [];
let ghostPoints = 0;
let ghostTricks = 0;
if (ghostKey && game.players[ghostKey]) {
ghostHand = game.players[ghostKey].hand || [];
ghostPoints = game.players[ghostKey].points || 0;
ghostTricks = game.players[ghostKey].tricks || 0;
delete game.players[ghostKey];
} }
const GHOST_ID = 1; game.players[myId] = {
id: myId,
if (game && game.players) { name: user.nickname,
if (!game.players[user.id]) { hand: ghostHand,
if (game.players[GHOST_ID]) { points: ghostPoints,
delete game.players[GHOST_ID]; tricks: ghostTricks,
game.players[user.id] = { token: socket.handshake.auth.token,
id: user.id,
name: user.name,
hand: [],
points: 0,
tricks: 0,
}; };
} game.player2 = { id: myId, name: user.nickname };
game.status = "playing";
if (!game.timer) {
game.startTurnTimer((timeoutUserId) =>
handleTimeout(io, gameId, timeoutUserId)
);
} }
} }
socket.join(`game_${gameId}`); socket.join(`game_${gameId}`);
socket.activeGameId = gameId;
if (game && game.players[user.id]) { if (game.players[myId]) {
socket.emit("game-state", game.getStateForPlayer(user.id)); game.players[myId].token = socket.handshake.auth.token;
console.log(`--> Estado enviado para User ${user.id}`);
} else { const rawState = game.getStateForPlayer(myId);
socket.emit("error", { message: "Error joining game state." }); const cleanState = sanitizeState(rawState, game);
socket.emit("game-state", cleanState);
broadcastState(io, gameId, game);
} }
}); });
socket.on("play-card", ({ gameId, cardId }) => { socket.on("play-card", ({ gameId, cardId }) => {
const user = getUser(socket.id);
if (user) handleGameMove(io, gameId, user.id, cardId);
});
socket.on("surrender", ({ gameId }) => {
const user = getUser(socket.id); const user = getUser(socket.id);
const game = getGame(gameId); const game = getGame(gameId);
if (!game || !user) return; if (!game || !user || !game.players[user.id]) return;
const result = game.playCard(user.id, cardId); console.log(`[Game] User ${user.nickname} surrendered.`);
if (result.error) { const winnerId = Object.keys(game.players).find(
socket.emit("game-error", { message: result.error }); (id) => String(id) !== String(user.id)
return; );
} performGameEnd(io, game, gameId, winnerId, user.id, "surrender");
const roomName = `game_${gameId}`;
const socketsInRoom = io.sockets.adapter.rooms.get(roomName);
if (socketsInRoom) {
for (const socketId of socketsInRoom) {
const s = io.sockets.sockets.get(socketId);
const u = getUser(socketId);
if (u && game.players[u.id]) {
s.emit("game-state", game.getStateForPlayer(u.id));
}
}
}
if (result.action === "trick_resolved") {
io.to(roomName).emit("trick-end", result.trickResult);
}
if (result.action === "game_ended") {
io.to(roomName).emit("game-over", {
winnerId: result.winnerId,
isDraw: result.isDraw,
p1Points: result.player1_points,
p2Points: result.player2_points,
});
saveGameResult(gameId, result, socket.handshake.auth.token);
}
}); });
}; };
+168
View File
@@ -0,0 +1,168 @@
import { getMatch, addMatch } from "../state/match.js";
import BiscaMatch from "../classes/BiscaMatch.js";
import axios from "axios";
const API_URL = process.env.API_URL || "http://localhost:8000/api/v1";
export default (io, socket) => {
const broadcastMatchUpdate = (matchId, match) => {
const roomName = `match_${matchId}`;
const room = io.sockets.adapter.rooms.get(roomName);
if (room) {
for (const socketId of room) {
const clientSocket = io.sockets.sockets.get(socketId);
if (!clientSocket || !clientSocket.user) continue;
const userId = clientSocket.user.id;
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
clientSocket.emit("match:update", { ...publicState, game: gameState });
}
}
};
const createMatchEmitter = (matchId) => (eventName, payload) => {
const match = getMatch(matchId);
if (!match) return;
if (
eventName === "match:new-round-start" ||
eventName === "match:ended-signal"
) {
broadcastMatchUpdate(matchId, match);
} else if (eventName === "match:round-ended") {
io.to(`match_${matchId}`).emit("match:round-ended", payload);
} else {
io.to(`match_${matchId}`).emit(eventName, payload);
}
};
const handleAutoPlay = (matchId) => (userId, cardId) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
console.log(`[Match ${matchId}] Auto-playing for User ${userId}`);
const result = match.playCard(userId, cardId);
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => {
broadcastMatchUpdate(matchId, match);
}, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
};
socket.on("match:enter", async (data) => {
const { matchId } = data;
if (!socket.user || !socket.user.id)
return socket.emit("error", { message: "Auth failed" });
const userId = socket.user.id;
socket.activeMatchId = matchId;
const room = `match_${matchId}`;
let match = getMatch(matchId);
if (!match) {
try {
const res = await axios.get(`${API_URL}/matches/${matchId}`, {
headers: {
Authorization: socket.token || socket.handshake.auth.token,
},
});
const matchData = res.data.data || res.data;
match = new BiscaMatch(
matchData,
createMatchEmitter(matchId),
socket.token,
handleAutoPlay(matchId)
);
addMatch(match);
} catch (e) {
return socket.emit("error", "Match not found");
}
}
match.emitStateCallback = createMatchEmitter(matchId);
if (userId !== match.player1Id && match.player2Id === 1)
await match.joinPlayer(userId, socket.user);
socket.join(room);
const publicState = match.getPublicState();
const gameState = match.getGameState(userId);
socket.emit("match:update", { ...publicState, game: gameState });
if (match.status === "Playing") broadcastMatchUpdate(matchId, match);
});
socket.on("game:play-card", (data) => {
const { matchId, cardId } = data;
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const result = match.playCard(socket.user.id, cardId);
if (result && result.error)
return socket.emit("error", { message: result.error });
if (result && result.action === "trick_resolved") {
io.to(`match_${matchId}`).emit("game:trick-end", result.trickResult);
setTimeout(() => {
broadcastMatchUpdate(matchId, match);
}, 1500);
} else {
broadcastMatchUpdate(matchId, match);
}
});
socket.on("game:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const userId = socket.user.id;
console.log(`[Match ${matchId}] User ${userId} surrendered ROUND.`);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
const result = {
winnerId: opponentId,
loserId: userId,
isDraw: false,
player1_id: match.player1Id,
player2_id: match.player2Id,
player1_points: match.player1Id == opponentId ? 91 : 0,
player2_points: match.player2Id == opponentId ? 91 : 0,
reason: "surrender",
};
await match.handleGameEnd(result);
});
socket.on("match:surrender", async ({ matchId }) => {
const match = getMatch(matchId);
if (!match || match.status !== "Playing") return;
const userId = socket.user.id;
await match.abortCurrentGame(userId);
console.log(`[Match ${matchId}] User ${userId} surrendered MATCH.`);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(matchId, match);
});
socket.on("disconnect", async () => {
if (socket.activeMatchId) {
const match = getMatch(socket.activeMatchId);
if (match && match.status === "Playing") {
const userId = socket.user?.id;
if (userId === match.player1Id || userId === match.player2Id) {
console.log(`[Match ${match.id}] Player ${userId} disconnected.`);
await match.abortCurrentGame(userId);
const opponentId =
match.player1Id == userId ? match.player2Id : match.player1Id;
match.marks[opponentId] = 4;
await match.finishMatch();
broadcastMatchUpdate(match.id, match);
}
}
}
});
};
+1
View File
@@ -2,6 +2,7 @@
"name": "websockets", "name": "websockets",
"version": "1.0.0", "version": "1.0.0",
"main": "index.js", "main": "index.js",
"type": "module",
"scripts": { "scripts": {
"dev": "nodemon index.js", "dev": "nodemon index.js",
"start": "bun index.js " "start": "bun index.js "
+102 -6
View File
@@ -3,12 +3,18 @@ import axios from "axios";
import { handleConnectionEvents } from "./events/connection.js"; import { handleConnectionEvents } from "./events/connection.js";
import { addUser, removeUser } from "./state/connection.js"; import { addUser, removeUser } from "./state/connection.js";
import gameEvents from "./events/game.js"; import gameEvents from "./events/game.js";
import matchEvents from "./events/match.js";
export const server = { export const server = {
io: null, io: null,
}; };
const API_URL = "http://localhost:8000/api/v1"; const API_URL = process.env.API_URL || "http://localhost:8000/api/v1";
const disconnectTimers = new Map();
const RECONNECT_GRACE_PERIOD = 1 * 60 * 1000;
const userId = (socket) => socket.user && socket.user.id;
export const serverStart = (port) => { export const serverStart = (port) => {
server.io = new Server(port, { server.io = new Server(port, {
@@ -17,16 +23,25 @@ export const serverStart = (port) => {
}, },
}); });
console.log(`[WebSocket] API_URL is set to ${API_URL}`);
server.io.use(async (socket, next) => { server.io.use(async (socket, next) => {
const token = socket.handshake.auth.token; let token = socket.handshake.auth.token;
if (!token) { if (!token) {
return next(new Error("Authentication error: No token provided")); return next(new Error("Authentication error: No token provided"));
} }
if (!token.startsWith("Bearer ")) {
token = "Bearer " + token;
}
try { try {
const response = await axios.get(`${API_URL}/users/me`, { const response = await axios.get(`${API_URL}/users/me`, {
headers: { Authorization: token }, headers: {
Authorization: token,
Accept: "application/json",
},
}); });
const user = response.data.data || response.data; const user = response.data.data || response.data;
@@ -35,6 +50,18 @@ export const serverStart = (port) => {
return next(new Error("Authentication error: User data not found")); return next(new Error("Authentication error: User data not found"));
} }
socket.user = user;
socket.token = token;
socket.handshake.auth.token = token;
if (disconnectTimers.has(user.id)) {
console.log(
`[Connection] User ${user.id} reconnected! Cancelling surrender timer.`
);
clearTimeout(disconnectTimers.get(user.id));
disconnectTimers.delete(user.id);
}
addUser(socket.id, user); addUser(socket.id, user);
next(); next();
@@ -53,15 +80,84 @@ export const serverStart = (port) => {
}); });
server.io.on("connection", (socket) => { server.io.on("connection", (socket) => {
console.log("New connection:", socket.id);
gameEvents(server.io, socket); gameEvents(server.io, socket);
matchEvents(server.io, socket);
handleConnectionEvents(server.io, socket); handleConnectionEvents(server.io, socket);
socket.on("notify_disconnect", () => {
socket.isVoluntaryDisconnect = true;
});
socket.on("disconnect", () => { socket.on("disconnect", () => {
// Remove from active socket list
removeUser(socket.id); removeUser(socket.id);
console.log("Disconnected:", socket.id);
if (socket.isVoluntaryDisconnect) {
console.log(
`[Disconnect] User ${userId} disconnected voluntarily. No surrender needed.`
);
return;
}
if (userId) {
console.log(
`[Disconnect] User ${userId} disconnected. Waiting ${
RECONNECT_GRACE_PERIOD / 1000
}s...`
);
// -----------------------------------------------------------
// 2. START SURRENDER TIMER
// -----------------------------------------------------------
const timer = setTimeout(async () => {
console.log(
`[Timeout] User ${userId} did not return. Forcing surrender.`
);
// A. Find the Game ID this user is playing (You need a way to look this up)
// Ideally, your 'socket' object or a global map knows which gameID the user is in.
// For this example, let's assume you stored `socket.activeGameId` when they joined.
const gameId = socket.activeGameId;
if (gameId) {
try {
// B. Call Laravel API to resign on their behalf
// We use the token we saved during the handshake
await axios.post(
`${API_URL}/games/${gameId}/resign`,
{},
{
headers: {
Authorization: socket.handshake.auth.token,
Accept: "application/json",
},
}
);
// C. Notify the room (The API likely triggers a Pusher/Socket event,
// but we can also emit locally if needed)
server.io.to(gameId).emit("game_over", {
winner: "opponent",
reason: "disconnect",
});
console.log(
`[Timeout] Successfully resigned game ${gameId} for user ${userId}`
);
} catch (err) {
console.error(
`[Timeout] Failed to resign game for user ${userId}:`,
err.message
);
}
}
disconnectTimers.delete(userId);
}, RECONNECT_GRACE_PERIOD);
disconnectTimers.set(userId, timer);
}
}); });
}); });
}; };
+15 -9
View File
@@ -1,8 +1,15 @@
const BiscaGame = require("../classes/BiscaGame"); import BiscaGame from "../classes/BiscaGame.js";
const activeGames = new Map(); const activeGames = new Map();
const createGame = (id, type, player1, player2) => { export const createGame = (id, type, player1, player2) => {
const existingGame = activeGames.get(id);
if (existingGame) {
console.log(
`[Game State] Game ${id} already exists, returning existing instance`
);
return existingGame;
}
const game = new BiscaGame(id, type, player1, player2); const game = new BiscaGame(id, type, player1, player2);
game.init(); game.init();
@@ -13,17 +20,16 @@ const createGame = (id, type, player1, player2) => {
return game; return game;
}; };
const getGame = (id) => { export const getGame = (id) => {
return activeGames.get(id); return activeGames.get(id);
}; };
const removeGame = (id) => { export const removeGame = (id) => {
const game = activeGames.get(id);
if (game) {
game.stopTimer();
activeGames.delete(id); activeGames.delete(id);
console.log(`[Game State] Game removed: ${id}`); console.log(`[Game State] Game removed: ${id}`);
}
}; };
module.exports = {
createGame,
getGame,
removeGame,
};
+16
View File
@@ -0,0 +1,16 @@
const matches = new Map();
export const addMatch = (match) => {
matches.set(parseInt(match.id), match);
return match;
};
export const getMatch = (id) => {
return matches.get(parseInt(id));
};
export const removeMatch = (id) => {
matches.delete(parseInt(id));
};
export { matches };