Compare commits

..
Author SHA1 Message Date
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
KZix cf3ca26382 removed comments on code 2025-12-30 19:17:49 +00:00
KZix 58e6615876 Merge branch 'develop' into feature/api-adminstats-transactions 2025-12-30 19:02:09 +00:00
KZix 9bfa06b1ce Added endpoint to get authenticated user's transactions 2025-12-30 19:01:02 +00:00
Edd 2a438dce26 Merge pull request 'feat: api users change password endpoint' (#84) from feature/api-update-password into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/84
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-30 17:17:06 +00:00
KZix f38f08923f Added transaction details for the logged user 2025-12-30 16:13:46 +00:00
KZix 5f845b2ba7 Added routes, admin statistics to the controller and added models for coin transaction and coin transaction type 2025-12-30 16:08:53 +00:00
FernandoJVideira 54b4fc7cb1 feat: api users change password endpoint 2025-12-30 10:55:46 +00:00
KZix 3d23627433 Merge pull request 'register, coin balance, game history and transactions mockup' (#83) from na/history-and-register into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/83
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-30 10:24:15 +00:00
Edd 4f391e3638 histories are a little messed, will change the logic in the future (techdebt) 2025-12-30 01:49:39 +00:00
FernandoJVideira c456c9482f Merge pull request 'feature/api-stats' (#82) from feature/api-stats into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/82
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-29 19:08:47 +00:00
KZix 754a227608 Merge branch 'develop' into feature/api-stats 2025-12-29 19:07:38 +00:00
KZix 83f92e670d Merge pull request 'feature/api-admin-policies' (#78) from feature/api-admin-policies into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/78
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-29 17:11:15 +00:00
KZix 59df3e2d37 Added protection to Nickname and added user get coins endpoint 2025-12-29 15:59:25 +00:00
KZix 39f55a12c0 Adde routes for statistics 2025-12-29 15:40:23 +00:00
KZix edecef3af8 added StatisticsController.php and changed the User.php model and ProfileController.php that were missing some imports 2025-12-29 15:34:56 +00:00
FernandoJVideira 51f745a3dd Merge branch 'develop' into feature/api-admin-policies 2025-12-29 14:53:44 +00:00
FernandoJVideira 4b311c74eb fix: added missing route 2025-12-29 14:47:32 +00:00
FernandoJVideira 0ecdc30bd4 fix: removed .env 2025-12-29 11:15:50 +00:00
FernandoJVideira 25c819c6e4 feat: api admin user and game policies 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira f60b25f29b chore: stop tracking package-lock.json 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira 52887fc2eb SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added packagelock.json to gitignore 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira c4078d9b82 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added sockets for game joining and card play 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira d29e44c61c Added dummy user to create a game 2025-12-29 11:15:49 +00:00
KZixandFernandoJVideira 8f7102b335 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 Added initial setup for websockets 2025-12-29 11:15:49 +00:00
EddandFernandoJVideira a34027889f remove package-lock 2025-12-29 11:15:49 +00:00
EddandFernandoJVideira 87e0dc2518 home page feature final 2025-12-29 11:15:49 +00:00
KZixandFernandoJVideira 79be3544cb SyntaxSquad/DADProject#14 missed one file 2025-12-29 11:15:35 +00:00
KZixandFernandoJVideira eafb4a4d1c SyntaxSquad/DADProject#14 added surrender button 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira 08c20acdf4 SyntaxSquad/DADProject#14 Added final touches to the gameplay 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira ab231ee94f SyntaxSquad/DADProject#14 fixed routes 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira ceb3d735ad SyntaxSquad/DADProject#14 Bot works and play works, still has some bugs 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira 5924d08f73 SyntaxSquad/DADProject#14 added start logic for bot 2025-12-29 11:15:33 +00:00
KZixandFernandoJVideira 3ec6f8971c SyntaxSquad/DADProject#14 added protection to logout inside a game and going to another page 2025-12-29 11:15:33 +00:00
KZixandFernandoJVideira e68f1357cc Added game start for single player, 3 and 9 card game types and protected the pages for only logged in players 2025-12-29 11:15:20 +00:00
FernandoJVideira cfda949ea6 feat: gameboard ui 2025-12-29 11:15:02 +00:00
EddandFernandoJVideira c68cda350e home page feature part 1 2025-12-29 11:13:22 +00:00
FernandoJVideira b7daa4610b tmp: pc change 2025-12-29 09:43:25 +00:00
FernandoJVideira 329bbcc1d3 Merge pull request 'feature/initial_gameplay_websockets' (#77) from feature/initial_gameplay_websockets into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/77
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-28 18:05:52 +00:00
KZix b35cac3b2c chore: stop tracking package-lock.json 2025-12-28 16:54:01 +00:00
KZix b5c3c552da SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added packagelock.json to gitignore 2025-12-28 16:45:23 +00:00
KZix 14dd4b3a4e SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added sockets for game joining and card play 2025-12-28 16:34:51 +00:00
KZix 0d0f7569a6 Added dummy user to create a game 2025-12-28 16:30:18 +00:00
KZix 09c4732e19 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 Added initial setup for websockets 2025-12-27 19:52:46 +00:00
FernandoJVideira d4361121d8 feat: api admin policies 2025-12-27 15:07:48 +00:00
KZix ef0bdab0d3 Merge pull request 'Build game lobby for single-player and multiplayer modes' (#76) from feature/home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/76
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-25 11:42:00 +00:00
Edd 5295d0620f remove package-lock 2025-12-25 01:51:47 +00:00
Edd d8cdbbf2ad home page feature final 2025-12-25 01:49:02 +00:00
Edd 160489b080 Merge branch 'develop' into feature/home-page 2025-12-24 23:42:00 +00:00
Edd a0bbd8a69d Merge pull request 'SyntaxSquad/DADProject#14 feature/frontend-gameboard-singleplayer' (#75) from feature/frontend-gameboard-singleplayer into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/75
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-24 23:35:40 +00:00
Edd 943a5dd898 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-24 23:35:33 +00:00
KZix 8912029686 SyntaxSquad/DADProject#14 missed one file 2025-12-24 16:02:03 +00:00
KZix 0bf96fc948 SyntaxSquad/DADProject#14 added surrender button 2025-12-24 16:00:53 +00:00
FernandoJVideira 79310fa63e Merge pull request 'feat: api admin users endpoints' (#74) from feature/api-admin-user-endpoint into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/74
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-24 15:54:11 +00:00
KZix 83adbcf2f1 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-24 15:48:49 +00:00
KZix 00666a16b2 SyntaxSquad/DADProject#14 Added final touches to the gameplay 2025-12-24 15:48:16 +00:00
KZix 7a6fbb4ba3 SyntaxSquad/DADProject#14 fixed routes 2025-12-23 22:02:40 +00:00
KZix f3f6c6e564 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 21:55:23 +00:00
KZix d8e20dfc67 SyntaxSquad/DADProject#14 Bot works and play works, still has some bugs 2025-12-23 21:52:09 +00:00
KZix 1939822e9b Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 20:12:01 +00:00
KZix c0b77aebb1 SyntaxSquad/DADProject#14 added start logic for bot 2025-12-23 20:08:19 +00:00
Edd ea15f5e28a Merge remote-tracking branch 'origin/develop' into feature/home-page 2025-12-23 20:07:35 +00:00
KZix 8505d207e6 SyntaxSquad/DADProject#14 added protection to logout inside a game and going to another page 2025-12-23 19:43:57 +00:00
KZix e54ec58f0c Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 15:25:45 +00:00
KZix e833e403f7 Added game start for single player, 3 and 9 card game types and protected the pages for only logged in players 2025-12-23 00:18:34 +00:00
KZix 5c1fc11975 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-22 19:41:26 +00:00
Edd b59640c858 Merge branch 'develop' into feature/home-page 2025-12-22 17:14:56 +00:00
Edd e8ccea4e42 home page feature part 1 2025-12-21 23:12:13 +00:00
FernandoJVideira 2b161e2099 feat: gameboard ui 2025-12-20 17:03:18 +00:00
113 changed files with 5448 additions and 15620 deletions
+5 -1
View File
@@ -80,6 +80,7 @@ frontend/.env.*.local
# Websockets: NodeJs (in websockets) # Websockets: NodeJs (in websockets)
# ------------------------- # -------------------------
/websockets/node_modules/ /websockets/node_modules/
/websockets/.env
# ------------------------- # -------------------------
# Other useful ignores # Other useful ignores
# ------------------------- # -------------------------
@@ -100,5 +101,8 @@ Desktop.ini
# If you have any local secrets or files not to track, add them here: # If you have any local secrets or files not to track, add them here:
# /path/to/some/file # /path/to/some/file
package-lock.json /frontend/package-lock.json
/websockets/package-lock.json
/api/package-lock.json
.vscode .vscode
+2
View File
@@ -22,3 +22,5 @@
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
composer.lock
+24 -5
View File
@@ -2,11 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Models\User;
use App\Http\Requests\RegisterRequest;
class AuthController extends Controller class AuthController extends Controller
{ {
@@ -24,6 +24,13 @@ class AuthController extends Controller
} }
$user = Auth::user(); $user = Auth::user();
if ($user->blocked) {
Auth::logout();
return response()->json(['message' => 'Your account is blocked. Please contact support.'], 403);
}
$token = $user->createToken('auth-token')->plainTextToken; $token = $user->createToken('auth-token')->plainTextToken;
return response()->json([ return response()->json([
@@ -46,15 +53,27 @@ class AuthController extends Controller
if (User::where('email', $request->email)->exists()) { if (User::where('email', $request->email)->exists()) {
return response()->json(['message' => 'Email already registered'], 409); return response()->json(['message' => 'Email already registered'], 409);
} }
if (User::where('nickname', $request->nickname)->exists()) {
return response()->json(['message' => 'Nickname already registered'], 409);
}
$avatarFilename = null;
if ($request->hasFile('photo_avatar_filename')) {
$file = $request->file('photo_avatar_filename');
$path = $file->store('photos_avatars', 'public');
$avatarFilename = basename($path);
}
$user = User::create([ $user = User::create([
'email' => $request->email, 'email' => $request->email,
'nickname' => $request->nickname, 'nickname' => $request->nickname,
'name' => $request->name, 'name' => $request->name,
'password' => bcrypt($request->password), 'password' => bcrypt($request->password),
'photo_avatar_filename' => $request->photo, 'photo_avatar_filename' => $avatarFilename,
'type' => 'P', 'type' => 'P',
'blocked' => false, 'blocked' => false,
'coins_balance' => 10, 'coins_balance' => 10,
]); ]);
@@ -0,0 +1,77 @@
<?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,IBAN,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 'IBAN': $regex = '/^[A-Z]{2}[0-9]{23}$/'; 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);
});
}
}
+72 -67
View File
@@ -4,15 +4,28 @@ 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
{ {
const GHOST_ID = 1;
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize('viewAny', Game::class);
$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') {
// Players can only see games they participate in
$query->where(function ($q) use ($user) {
$q->where('player1_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);
@@ -30,100 +43,70 @@ class GameController extends Controller
$query->where("status", $request->status); $query->where("status", $request->status);
} }
$query->orderBy("began_at", "desc"); if ($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));
} }
public function store(Request $request) public function store(Request $request)
{ {
$this->authorize('create', Game::class);
$validated = $request->validate([ $validated = $request->validate([
"type" => "required|in:3,9", "type" => "required|in:3,9",
]); ]);
$user = Auth::user(); $user = Auth::user();
$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();
$activeGame = Game::where(function ($q) use ($user) { $activeGame = Game::where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere( $q->where("player1_user_id", $user->id)
"player2_user_id", ->orWhere("player2_user_id", $user->id);
$user->id,
);
}) })
->whereIn("status", ["Pending", "Playing"]) ->whereIn("status", ["Pending", "Playing"])
->exists(); ->exists();
if ($activeMatch || $activeGame) { if ($activeGame) {
return response()->json( return response()->json(["message" => "You already have an active game."], 400);
["message" => "You already have an active game or match."],
400,
);
} }
$game = Game::create([ $game = Game::create([
"type" => $validated["type"], "type" => $validated["type"],
"status" => "Pending", "status" => "Pending",
"player1_user_id" => $user->id, "player1_user_id" => $user->id,
"player2_user_id" => null, "player2_user_id" => self::GHOST_ID,
"winner_user_id" => null, "winner_user_id" => self::GHOST_ID,
"loser_user_id" => null, "loser_user_id" => self::GHOST_ID,
"began_at" => now(), "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" => "Multiplayer game room created", "game" => $game,
"game" => $game, ], 201);
],
201,
);
} }
public function join(Game $game) public function join(Game $game)
{ {
$this->authorize('join', $game);
$user = Auth::user(); $user = Auth::user();
if ($game->player1_user_id == $user->id) { if ($game->player1_user_id == $user->id) {
return response()->json( return response()->json([
[ "message" => "You are the host. Waiting for opponent...",
"message" => "Waiting for opponent...", "game" => $game
"game" => $game, ], 200);
],
200,
);
} }
if ($game->status !== "Pending") { if ($game->player2_user_id !== self::GHOST_ID) {
return response()->json( return response()->json(["message" => "Game is full"], 400);
["message" => "Game is full or already started"],
400,
);
}
$activeGame = Game::where(function ($query) use ($user) {
$query
->where("player1_user_id", $user->id)
->orWhere("player2_user_id", $user->id);
})
->whereIn("status", ["Pending", "Playing"])
->exists();
if ($activeGame) {
return response()->json(
["message" => "You are already in another active game."],
400,
);
} }
$game->update([ $game->update([
@@ -131,23 +114,24 @@ class GameController extends Controller
"player2_user_id" => $user->id, "player2_user_id" => $user->id,
]); ]);
return response()->json( return response()->json([
[ "message" => "Joined successfully!",
"message" => "Joined successfully! Game starts now.", "game" => $game,
"game" => $game, ], 200);
],
200,
);
} }
public function show(Game $game) public function show(Game $game)
{ {
$this->authorize('view', $game);
$game->load(["player1", "player2", "winner"]); $game->load(["player1", "player2", "winner"]);
return response()->json($game); return response()->json($game);
} }
public function update(Request $request, Game $game) public function update(Request $request, Game $game)
{ {
$this->authorize('update', $game);
if ($game->status == "Ended") { if ($game->status == "Ended") {
return response()->json(["message" => "Game already ended"], 400); return response()->json(["message" => "Game already ended"], 400);
} }
@@ -170,4 +154,25 @@ class GameController extends Controller
return response()->json($game); return response()->json($game);
} }
public function resign(Game $game)
{
$this->authorize('resign', $game);
$user = Auth::user();
$game->update([
'status' => 'Ended',
'winner_user_id' => $game->player1_user_id === $user->id
? $game->player2_user_id
: $game->player1_user_id,
'loser_user_id' => $user->id,
'ended_at' => now(),
]);
return response()->json([
'message' => 'You have resigned from the game',
'game' => $game->fresh(),
]);
}
} }
+110 -45
View File
@@ -4,12 +4,15 @@ namespace App\Http\Controllers;
use App\Models\MatchGame; use App\Models\MatchGame;
use App\Models\Game; 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\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']);
@@ -28,44 +31,61 @@ 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'];
if ($user->coins_balance < $stake) {
return response()->json(['message' => 'Insufficient coin balance'], 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('player2_user_id', $user->id); ->orWhere('player2_user_id', $user->id);
})->whereIn('status', ['Pending', 'Playing'])->exists(); })->whereIn('status', ['Pending', 'Playing'])->exists();
$activeGame = Game::where(function ($q) use ($user) { if ($activeMatch) {
$q->where('player1_user_id', $user->id) return response()->json(['message' => 'You already have an active match.'], 400);
->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);
} }
$match = MatchGame::create([ return DB::transaction(function () use ($validated, $user, $stake) {
'type' => $validated['type'], $GHOST_ID = 1;
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $user->id,
'winner_user_id' => $user->id,
'loser_user_id' => $user->id,
'stake' => $validated['stake'],
'began_at' => now(),
'player1_marks' => 0,
'player2_marks' => 0,
'player1_points' => 0,
'player2_points' => 0,
'custom' => null
]);
return response()->json([ $user->decrement('coins_balance', $stake);
'message' => 'Match created successfully',
'match' => $match $stakeType = CoinTransactionType::firstOrCreate(
], 201); ['name' => 'Match stake'],
['type' => 'D'] // Debit
);
$match = MatchGame::create([
'type' => $validated['type'],
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $GHOST_ID,
'winner_user_id' => $GHOST_ID,
'loser_user_id' => $GHOST_ID,
'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([
'message' => 'Match created successfully',
'match' => $match,
'balance' => $user->coins_balance
], 201);
});
} }
public function show(MatchGame $match) public function show(MatchGame $match)
@@ -76,16 +96,19 @@ class MatchController extends Controller
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') { if ($match->status !== 'Pending') {
return response()->json(['message' => 'Match is full or started'], 400); return response()->json(['message' => 'Match is full or started'], 400);
} }
if ($user->coins_balance < $match->stake) {
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('player2_user_id', $user->id); ->orWhere('player2_user_id', $user->id);
@@ -95,15 +118,33 @@ class MatchController extends Controller
return response()->json(['message' => 'You are already in another active match.'], 400); return response()->json(['message' => 'You are already in another active match.'], 400);
} }
$match->update([ return DB::transaction(function () use ($match, $user) {
'status' => 'Playing', $user->decrement('coins_balance', $match->stake);
'player2_user_id' => $user->id
]);
return response()->json([ $stakeType = CoinTransactionType::firstOrCreate(
'message' => 'Joined match successfully!', ['name' => 'Match stake'],
'match' => $match ['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([
'status' => 'Playing',
'player2_user_id' => $user->id
]);
return response()->json([
'message' => 'Joined match successfully!',
'match' => $match,
'balance' => $user->coins_balance
]);
});
} }
public function update(Request $request, MatchGame $match) public function update(Request $request, MatchGame $match)
@@ -123,12 +164,36 @@ class MatchController extends Controller
'total_time' => 'numeric' 'total_time' => 'numeric'
]); ]);
if (isset($data['status']) && $data['status'] === 'Ended') { return DB::transaction(function () use ($match, $data) {
$data['ended_at'] = now();
} if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now();
}
$match->update($data); $match->update($data);
return response()->json($match); if ($match->status === 'Ended' && $match->stake > 0 && $match->winner_user_id) {
$payoutType = CoinTransactionType::firstOrCreate(
['name' => 'Match payout'],
['type' => 'C'] // Credit
);
$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);
});
} }
} }
+17 -4
View File
@@ -3,6 +3,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ProfileController extends Controller class ProfileController extends Controller
{ {
@@ -52,7 +58,7 @@ class ProfileController extends Controller
$user = $request->user(); $user = $request->user();
if (!\Hash::check($request->current_password, $user->password)) { if (!Hash::check($request->current_password, $user->password)) {
return response()->json( return response()->json(
[ [
"message" => "Current password is incorrect", "message" => "Current password is incorrect",
@@ -61,7 +67,7 @@ class ProfileController extends Controller
); );
} }
$user->password = \Hash::make($request->new_password); $user->password = Hash::make($request->new_password);
$user->save(); $user->save();
return response()->json([ return response()->json([
@@ -69,6 +75,13 @@ class ProfileController extends Controller
]); ]);
} }
public function getCoins(Request $request)
{
return response()->json([
'coins' => $request->user()->coins_balance
]);
}
public function updateAvatar(Request $request) public function updateAvatar(Request $request)
{ {
$request->validate([ $request->validate([
@@ -78,7 +91,7 @@ class ProfileController extends Controller
$user = $request->user(); $user = $request->user();
if ($user->photo_avatar_filename) { if ($user->photo_avatar_filename) {
\Storage::disk("public")->delete( Storage::disk("public")->delete(
"photos_avatars/" . $user->photo_avatar_filename, "photos_avatars/" . $user->photo_avatar_filename,
); );
} }
@@ -87,7 +100,7 @@ class ProfileController extends Controller
$filename = $filename =
str_pad($user->id, 5, "0", STR_PAD_LEFT) . str_pad($user->id, 5, "0", STR_PAD_LEFT) .
"_" . "_" .
\Str::random(10) . Str::random(10) .
"." . "." .
$file->extension(); $file->extension();
$file->storeAs("photos_avatars", $filename, "public"); $file->storeAs("photos_avatars", $filename, "public");
@@ -0,0 +1,129 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Game;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use App\Models\CoinTransaction;
class StatisticsController extends Controller
{
public function getPublicStats()
{
$totalPlayers = User::where('type', 'P')->count();
$totalGames = Game::where('status', 'Ended')->count();
$activeGames = Game::where('status', 'Playing')->count();
return response()->json([
'total_players' => $totalPlayers,
'total_games' => $totalGames,
'active_games' => $activeGames,
]);
}
public function getLeaderboard(Request $request)
{
$type = $request->query('type');
$query = User::where('type', 'P')
->withCount(['wonGames' => function ($query) use ($type) {
if ($type) {
$query->where('type', $type);
}
}])
->orderBy('won_games_count', 'desc')
->take(10);
$leaderboard = $query->get(['id', 'nickname', 'photo_avatar_filename']);
return response()->json($leaderboard);
}
public function getPersonalStats(Request $request)
{
$user = $request->user();
$totalGames = Game::where('status', 'Ended')
->where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})
->count();
$totalWins = Game::where('status', 'Ended')
->where('winner_user_id', $user->id)
->count();
$totalLosses = $totalGames - $totalWins; // Simplest math
$winRate = 0;
if ($totalGames > 0) {
$winRate = round(($totalWins / $totalGames) * 100, 1);
}
return response()->json([
'nickname' => $user->nickname,
'total_games' => $totalGames,
'total_wins' => $totalWins,
'total_losses' => $totalLosses,
'win_rate' => $winRate . '%',
'current_balance' => $user->coins_balance, // Extra info
]);
}
public function getAdminStats()
{
$totalPlayers = User::where('type', 'P')->count();
$totalGames = Game::count();
$totalBlockedUsers = User::where('blocked', true)->count();
$gamesByType = Game::select('type', DB::raw('count(*) as total'))
->groupBy('type')
->get();
$gamesPerMonth = Game::select(
DB::raw("strftime('%Y-%m', began_at) as month"),
DB::raw('count(*) as total')
)
->whereNotNull('began_at')
->groupBy('month')
->orderBy('month', 'desc')
->limit(12)
->get();
$totalCoinsPurchased = \App\Models\CoinTransaction::whereHas('type', function($q) {
$q->where('name', 'Coin purchase');
})
->sum('coins');
$purchasesPerMonth = \App\Models\CoinTransaction::join('coin_transaction_types', 'coin_transactions.coin_transaction_type_id', '=', 'coin_transaction_types.id')
->where('coin_transaction_types.name', 'Coin purchase')
->select(
DB::raw("strftime('%Y-%m', coin_transactions.transaction_datetime) as month"),
DB::raw('SUM(coin_transactions.coins) as total_coins')
)
->groupBy('month')
->orderBy('month', 'desc')
->limit(12)
->get();
return response()->json([
'global_counters' => [
'total_players' => $totalPlayers,
'total_games' => $totalGames,
'blocked_users' => $totalBlockedUsers,
'total_coins_purchased' => (int) $totalCoinsPurchased,
],
'charts' => [
'games_by_type' => $gamesByType,
'games_per_month' => $gamesPerMonth,
'purchases_per_month' => $purchasesPerMonth,
]
]);
}
}
+162 -45
View File
@@ -17,6 +17,8 @@ class UserController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
$this->authorize('viewAny', User::class);
$query = User::query(); $query = User::query();
// Filtros úteis para o Backoffice // Filtros úteis para o Backoffice
@@ -41,6 +43,8 @@ class UserController extends Controller
*/ */
public function store(Request $request) public function store(Request $request)
{ {
$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', 'nickname' => 'required|string|max:20|unique:users,nickname',
@@ -73,7 +77,11 @@ class UserController extends Controller
public function show(User $user) public function show(User $user)
{ {
$currentUser = Auth::user(); $currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
// Admins and owners get full profile, others get limited view
if ($currentUser->type === 'A' || $currentUser->id === $user->id) {
return response()->json($user);
} else {
return response()->json([ return response()->json([
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
@@ -82,8 +90,6 @@ class UserController extends Controller
'type' => $user->type, 'type' => $user->type,
]); ]);
} }
return response()->json($user);
} }
/** /**
@@ -92,12 +98,8 @@ class UserController extends Controller
*/ */
public function update(Request $request, User $user) public function update(Request $request, User $user)
{ {
$currentUser = Auth::user(); $this->authorize('update', $user);
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') {
return response()->json(['message' => 'Unauthorized'], 403);
}
$validated = $request->validate([ $validated = $request->validate([
'name' => 'sometimes|string|max:255', 'name' => 'sometimes|string|max:255',
'nickname' => [ 'nickname' => [
@@ -112,16 +114,8 @@ class UserController extends Controller
Rule::unique('users')->ignore($user->id), Rule::unique('users')->ignore($user->id),
], ],
'password' => 'sometimes|string|min:3', 'password' => 'sometimes|string|min:3',
'blocked' => 'sometimes|boolean',
]); ]);
if (isset($validated['blocked']) && $currentUser->type !== 'A') {
return response()->json(
['message' => 'Only admins can block users'],
403,
);
}
// Lógica de Upload de Foto (Exemplo Básico) // Lógica de Upload de Foto (Exemplo Básico)
// if ($request->hasFile('photo_avatar')) { // if ($request->hasFile('photo_avatar')) {
// $path = $request->file('photo_avatar')->store('avatars', 'public'); // $path = $request->file('photo_avatar')->store('avatars', 'public');
@@ -133,30 +127,52 @@ class UserController extends Controller
return response()->json($user); return response()->json($user);
} }
public function updatePassword(Request $request, User $user)
{
$this->authorize('update', $user);
$validated = $request->validate([
'current_password' => 'required|string',
'new_password' => 'required|string|min:3|confirmed',
]);
// Verificar se a password atual está correta
if (!Hash::check($validated['current_password'], $user->password)) {
return response()->json(['message' => 'Current password is incorrect'], 403);
}
// Atualizar para a nova password
$user->password = Hash::make($validated['new_password']);
$user->save();
return response()->json(['message' => 'Password updated successfully']);
}
/** /**
* DELETE /api/users/{user} * DELETE /api/users/{user}
* Apagar conta (Soft Delete) * Apagar conta (Soft Delete)
*/ */
public function destroy(User $user) public function destroy(User $user)
{ {
$currentUser = Auth::user(); $this->authorize('delete', $user);
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { // Check if user has transactions or games - if so, soft delete
return response()->json(['message' => 'Unauthorized'], 403); $hasTransactions = $user->transactions()->exists();
$hasGames = Game::where(function ($q) use ($user) {
$q->where('player1_user_id', $user->id)
->orWhere('player2_user_id', $user->id);
})->exists();
if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete
$message = 'User account deactivated (soft-deleted due to transaction/game history)';
} else {
$user->forceDelete(); // Hard delete
$message = 'User permanently deleted';
} }
if ($currentUser->id === $user->id && $currentUser->type === 'A') { return response()->json(['message' => $message]);
if ($user->type === 'A') {
return response()->json(
['message' => 'Admins cannot delete their own account'],
403,
);
}
}
$user->delete();
return response()->json(['message' => 'User deleted successfully']);
} }
/** /**
@@ -165,23 +181,124 @@ class UserController extends Controller
*/ */
public function getMatches(Request $request, User $user) public function getMatches(Request $request, User $user)
{ {
$currentUser = Auth::user(); $this->authorize('view', $user);
if ($currentUser->id !== $user->id && $currentUser->type !== 'A') { $outcomeSql = "CASE
return response()->json(['message' => 'Unauthorized'], 403); WHEN is_draw = 1 THEN 'DRAW'
WHEN winner_user_id = {$user->id} THEN 'WIN'
ELSE 'LOSS'
END";
$query = Game::query()
->select('*')
->selectRaw("($outcomeSql) as outcome_text")
->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);
} }
$matches = Game::query() if ($request->has('status')) {
->where(function ($q) use ($user) { $query->where('status', $request->status);
$q->where('player1_user_id', $user->id)->orWhere( }
'player2_user_id',
$user->id, if ($request->filled('outcome')) {
); switch ($request->outcome) {
}) case 'win':
->with(['winner', 'player1', 'player2']) $query->where('winner_user_id', $user->id)
->orderBy('began_at', 'desc') ->where('is_draw', 0);
->paginate(10); break;
case 'loss':
case 'forfeit': // Forfeit treated as loss for now
$query->where('loser_user_id', $user->id)
->where('is_draw', 0);
break;
case 'draw':
$query->where('is_draw', 1);
break;
}
}
if ($request->has('date_from')) {
$query->whereDate('began_at', '>=', $request->date_from);
}
if ($request->has('date_to')) {
$query->whereDate('ended_at', '<=', $request->date_to);
}
$order = $request->get('sort_by', 'began_at');
$direction = $request->get('sort_direction', 'desc');
if ($order === 'outcome') {
$query->orderBy('outcome_text', $direction);
} else {
$query->orderBy($order, $direction);
}
$matches = $query->orderBy($order, $direction)
->paginate(10);
return response()->json($matches); return response()->json($matches);
} }
public function block(User $user)
{
$this->authorize('block', $user);
$user->blocked = true;
$user->save();
// Revoke all active tokens for immediate effect
$user->tokens()->delete();
return response()->json(['message' => 'User blocked successfully']);
}
public function unblock(User $user)
{
$this->authorize('unblock', $user);
$user->blocked = false;
$user->save();
return response()->json(['message' => 'User unblocked successfully']);
}
public function getTransactions(Request $request, User $user)
{
if ($request->user()->id !== $user->id && $request->user()->type !== 'A') {
return response()->json(['message' => 'Forbidden'], 403);
}
$query = $user->transactions()->with('type');
if ($request->has('type')) {
$query->whereHas('type', function ($q) use ($request) {
$q->where('type', $request->type);
});
}
if ($request->has('date_from')) {
$query->whereDate('transaction_datetime', '>=', $request->date_from);
}
if ($request->has('date_to')) {
$query->whereDate('transaction_datetime', '<=', $request->date_to);
}
$transactions = $query->orderBy('transaction_datetime', 'desc')
->paginate(10);
return response()->json($transactions);
}
public function getMyTransactions(Request $request)
{
return $this->getTransactions($request, $request->user());
}
} }
+1 -1
View File
@@ -26,7 +26,7 @@ class RegisterRequest extends FormRequest
'nickname' => 'required|string|max:255', 'nickname' => 'required|string|max:255',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'password' => 'required|string|min:3', 'password' => 'required|string|min:3',
'photo' => 'nullable|string', 'photo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]; ];
} }
} }
+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');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinTransaction extends Model
{
use HasFactory;
protected $table = 'coin_transactions';
public $timestamps = false;
protected $fillable = [
'transaction_datetime',
'user_id',
'match_id',
'game_id',
'coin_transaction_type_id',
'coins',
'custom'
];
protected $casts = [
'transaction_datetime' => 'datetime',
'custom' => 'array',
];
public function type()
{
return $this->belongsTo(CoinTransactionType::class, 'coin_transaction_type_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CoinTransactionType extends Model
{
use HasFactory, SoftDeletes;
protected $table = 'coin_transaction_types';
protected $fillable = ['name', 'type', 'custom'];
protected $casts = [
'custom' => 'array',
];
}
-1
View File
@@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Http\Requests\StoreGameRequest;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
+15
View File
@@ -48,4 +48,19 @@ class User extends Authenticatable
'blocked' => 'boolean', 'blocked' => 'boolean',
'deleted_at' => 'datetime', 'deleted_at' => 'datetime',
]; ];
public function wonGames()
{
return $this->hasMany(Game::class, 'winner_user_id');
}
public function games()
{
return $this->hasMany(Game::class, 'player1_user_id')->orWhere('player2_user_id', $this->id);
}
public function transactions()
{
return $this->hasMany(CoinTransaction::class);
}
} }
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Policies;
use App\Models\Game;
use App\Models\User;
class GamePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**2
* Determine whether the user can view the model.
*/
public function view(User $user, Game $game): bool
{
if ($user->type === 'A') {
return true;
}
if (
$game->player1_user_id === $user->id ||
$game->player2_user_id === $user->id
) {
return true;
}
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
if ($user->type === 'A') {
return false;
}
return true;
}
public function join(User $user, Game $game): bool
{
if ($user->type === 'A') {
return false;
}
if (
$game->status !== 'waiting' ||
$game->player1_user_id === $user->id
) {
return false;
}
return true;
}
public function resign(User $user, Game $game): bool
{
if ($user->type === 'A') {
return false;
}
if (
$game->status !== 'ongoing' ||
($game->player1_user_id !== $user->id &&
$game->player2_user_id !== $user->id)
) {
return false;
}
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Game $game): bool
{
if ($user->type === 'A') {
return false;
}
if (
$game->status !== 'ongoing' ||
($game->player1_user_id !== $user->id &&
$game->player2_user_id !== $user->id)
) {
return false;
}
return true;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Game $game): bool
{
// Only admins can delete games
return $user->type === 'A';
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Game $game): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Game $game): bool
{
return false;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->type === 'A';
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, User $model): bool
{
return $user->id === $model->id || $user->type === 'A';
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->type === 'A';
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, User $model): bool
{
return $user->id === $model->id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, User $model): bool
{
return ($user->type === 'A' && $user->id !== $model->id) || ($user->type === 'P' && $user->id === $model->id);
}
public function block(User $user, User $model): bool
{
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
}
public function unblock(User $user, User $model): bool
{
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, User $model): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, User $model): bool
{
return false;
}
}
+3
View File
@@ -102,12 +102,15 @@
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
-9392
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -4,7 +4,9 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\GameController; use App\Http\Controllers\GameController;
use App\Http\Controllers\MatchController; use App\Http\Controllers\MatchController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StatisticsController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\CoinPurchaseController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(function () { Route::prefix('v1')->group(function () {
@@ -12,19 +14,26 @@ Route::prefix('v1')->group(function () {
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('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
// Authenticated Routes // 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::prefix('users/me')->group(function () { Route::prefix('users/me')->group(function () {
Route::get('/', [ProfileController::class, 'show']); Route::get('/', [ProfileController::class, 'show']);
Route::put('/', [ProfileController::class, 'update']); Route::put('/', [ProfileController::class, 'update']);
Route::patch('/password', [ProfileController::class, 'updatePassword']);
Route::delete('/', [ProfileController::class, 'destroy']); Route::delete('/', [ProfileController::class, 'destroy']);
Route::get('/coins', [ProfileController::class, 'getCoins']);
Route::put('/password', [ Route::put('/password', [
ProfileController::class, ProfileController::class,
'updatePassword', 'updatePassword',
]); ]);
Route::post('/avatar', [ProfileController::class, 'uploadAvatar']); Route::post('/avatar', [ProfileController::class, 'uploadAvatar']);
Route::get('/transactions', [UserController::class, 'getMyTransactions']);
}); });
// User Resources // User Resources
@@ -34,6 +43,7 @@ Route::prefix('v1')->group(function () {
UserController::class, UserController::class,
'getMatches', 'getMatches',
]); ]);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
}); });
// Game Resources // Game Resources
@@ -60,6 +70,7 @@ Route::prefix('v1')->group(function () {
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::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']);
+33 -4
View File
@@ -1,9 +1,38 @@
### Get All Students ### Get All Students (Login)
POST http://localhost:8000/api/login # @name login
content-Type: application/json POST http://localhost:8000/api/v1/login
Content-Type: application/json
Accept: application/json Accept: application/json
{ {
"email": "[email protected]", "email": "[email protected]",
"password": "123" "password": "123"
} }
### Capture the token & id
@token = {{login.response.body.token}}
@id = {{login.response.body.user.id}}
### Get All matches
GET http://localhost:8000/api/v1/matches
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### Get me user
GET http://localhost:8000/api/v1/users/me
Content-Type: application/json
Accept: application/json
Authorization: Bearer {{token}}
### Get me matches
GET http://localhost:8000/api/v1/users/{{id}}/matches
Content-Type: application/json
Accept: application/json
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}}
+1 -1
View File
@@ -12,7 +12,7 @@ post {
body:json { body:json {
{ {
"email":"a1@mail.pt", "email":"p[email protected]",
"password":"123" "password":"123"
} }
} }
+23
View File
@@ -0,0 +1,23 @@
meta {
name: Patch Password
type: http
seq: 3
}
patch {
url: {{api_url}}/users/me
body: json
auth: none
}
headers {
Accept: application/json
Authorization: Bearer {{token}}
}
body:json {
{
"current_password":"123",
"new_password": "456"
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_DOMAIN=localhost:8000 VITE_API_DOMAIN=localhost:8000
VITE_WS_CONNECTION=ws://localhost:3000 VITE_WS_CONNECTION=ws://localhost:3001
+4 -5
View File
@@ -9,13 +9,12 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib"
"composables": "@/composables"
}, },
"registries": {} "iconLibrary": "lucide"
} }
-5208
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -21,6 +21,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.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.6.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+9 -16
View File
@@ -3,8 +3,8 @@
<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">&nbsp;&nbsp;&nbsp; <span class="text-xs" v-if="authStore.currentUser"
({{ 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" />
@@ -17,26 +17,22 @@
</template> </template>
<script setup> <script setup>
import { RouterLink, RouterView } from 'vue-router'; import { RouterLink, RouterView } from 'vue-router'
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner'
import 'vue-sonner/style.css' import 'vue-sonner/style.css'
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue'
import { Toaster } from '@/components/ui/sonner' import { Toaster } from '@/components/ui/sonner'
import NavBar from './components/layout/NavBar.vue'; import NavBar from './components/layout/NavBar.vue'
import { useAuthStore } from './stores/auth'; import { useAuthStore } from './stores/auth'
import { useSocketStore } from './stores/socket'; import { useSocketStore } from './stores/socket'
const authStore = useAuthStore() const authStore = useAuthStore()
const socketStore = useSocketStore() const socketStore = useSocketStore()
const year = new Date().getFullYear() const year = new Date().getFullYear()
const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`) const pageTitle = ref(`DAD ${year}/${String(year + 1).slice(-2)}`)
const logout = () => { const logout = () => {
toast.promise(authStore.logout(), { toast.promise(authStore.logout(), {
loading: 'Calling API', loading: 'Calling API',
success: () => { success: () => {
@@ -44,15 +40,12 @@ const logout = () => {
}, },
error: (data) => `[API] Error - ${data?.response?.data?.message}`, error: (data) => `[API] Error - ${data?.response?.data?.message}`,
}) })
} }
onMounted(async () => { onMounted(async () => {
await authStore.restoreSession() await authStore.restoreSession()
socketStore.handleConnection() socketStore.handleConnection()
}) })
</script> </script>
<style></style>` <style></style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="relative flex items-center justify-center w-64 h-48">
<div
v-if="trumpCard"
class="absolute rotate-90 origin-center transition-all duration-700"
:class="[trumpReveal && 'scale-110']"
>
<div
class="ring-2 ring-amber-400/60 ring-offset-2 rounded-lg transition-all duration-700"
:class="[
trumpReveal &&
'ring-4 ring-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.8)] animate-pulse',
]"
>
<GameCard :suit="trumpCard.suit" :rank="trumpCard.rank" />
</div>
<div
v-if="trumpReveal"
class="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-amber-500/90 text-white text-[10px] font-bold px-3 py-1 rounded-full whitespace-nowrap animate-pulse"
>
TRUMP
</div>
</div>
<div
v-if="!isEmpty && cardsRemaining > 0"
class="absolute -translate-x-8 -translate-y-4 transition-all duration-300"
:class="[cardsRemaining === 0 && 'opacity-0 scale-95', deckPulse && 'scale-105']"
>
<div class="relative">
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-20 translate-x-1 translate-y-1"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-15 translate-x-2 translate-y-2"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-10 translate-x-3 translate-y-3"
></div>
<div class="relative">
<GameCard suit="c" :rank="1" :face-down="true" />
<div
class="absolute -top-2 -right-2 bg-blue-600 text-white text-xs font-bold rounded-full w-8 h-8 flex items-center justify-center shadow-lg border-2 border-white transition-all duration-300"
>
<Transition
enter-active-class="transition-all duration-200"
enter-from-class="scale-150 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition-all duration-200"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-50 opacity-0"
mode="out-in"
>
<span :key="cardsRemaining">{{ cardsRemaining }}</span>
</Transition>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
trumpCard: {
type: Object,
default: null,
},
cardsRemaining: {
type: Number,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
revealTrump: {
type: Boolean,
default: false,
},
})
const deckPulse = ref(false)
const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch(
() => props.cardsRemaining,
() => {
deckPulse.value = true
setTimeout(() => {
deckPulse.value = false
}, 300)
},
)
// Trump reveal animation
watch(
() => props.revealTrump,
(newValue) => {
if (newValue) {
trumpReveal.value = true
setTimeout(() => {
trumpReveal.value = false
}, 3000) // Show for 3 seconds
}
},
)
</script>
@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<div
v-if="isVisible"
class="fixed pointer-events-none z-[9999]"
:style="{
left: currentPosition.x + 'px',
top: currentPosition.y + 'px',
transition: isAnimating ? `all ${duration}ms ease-out` : 'none',
}"
>
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
card: {
type: Object,
required: true,
},
startPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
endPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
duration: {
type: Number,
default: 500,
},
faceDown: {
type: Boolean,
default: true,
},
delay: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['complete'])
const isVisible = ref(false)
const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition }
isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => {
isAnimating.value = true
currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => {
emit('complete')
isVisible.value = false
}, props.duration)
})
}, props.delay)
})
</script>
@@ -0,0 +1,77 @@
<template>
<div
class="min-h-screen bg-linear-to-br from-green-700 via-green-800 to-green-900 grid grid-rows-[auto_auto_1fr_auto] grid-cols-[1fr_3fr_1fr] gap-6 p-8"
>
<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>
<div class="col-start-2 col-end-3 row-start-2 flex justify-center">
<PlayerHand
:cards="opponentHand"
:face-down="true"
:max-cards="maxCards"
/>
</div>
<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"
/>
</div>
<div class="col-start-3 col-end-4 row-start-3 flex items-center justify-center">
<DeckArea :trump-card="trumpCard" :cards-remaining="cardsRemaining" :is-empty="deckIsEmpty" />
</div>
<div class="col-start-2 col-end-3 row-start-4 flex justify-center">
<PlayerHand
:cards="playerHand"
:face-down="false"
:max-cards="maxCards"
@card-clicked="handleCardClick"
/>
</div>
</div>
</template>
<script setup>
import PlayerHand from './PlayerHand.vue'
import PlayArea from './PlayArea.vue'
import DeckArea from './DeckArea.vue'
import ScoreDisplay from './ScoreDisplay.vue'
const props = defineProps({
playerHand: { type: Array, default: () => [] },
opponentHand: { type: Array, default: () => [] },
currentTrick: {
type: Object,
default: () => ({ playerCard: null, opponentCard: null, winner: null }),
},
trumpCard: { type: Object, required: true },
cardsRemaining: { type: Number, default: 0 },
deckIsEmpty: { type: Boolean, default: false },
maxCards: { type: Number, default: 3 },
// REMOVIDO: playableCardIndices (já não precisamos dele)
playerScore: { type: Number, default: 0 },
opponentScore: { type: Number, default: 0 },
currentTurn: {
type: String,
default: 'player',
validator: (value) => ['player', 'opponent'].includes(value),
},
})
const emit = defineEmits(['play-card'])
const handleCardClick = (payload) => {
emit('play-card', payload.card)
}
</script>
+39
View File
@@ -0,0 +1,39 @@
<template>
<div
class="relative w-32 aspect-[2.5/3.5] cursor-pointer transition-all duration-300 ease-in-out hover:scale-105 hover:-translate-y-2 hover:shadow-2xl"
>
<img
:src="cardImage"
:alt="faceDown ? 'Card back' : `${suit} ${rank}`"
class="w-full h-full object-cover rounded-lg shadow-lg select-none"
draggable="false"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
suit: {
type: String,
required: true,
},
rank: {
type: Number,
required: true,
},
faceDown: {
type: Boolean,
default: false,
},
})
const cardImage = computed(() => {
if (props.faceDown) {
return '/cards/semFace.png'
} else {
return `/cards/${props.suit}${props.rank}.png`
}
})
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998] flex items-center justify-center p-4"
@click.self="handleClose"
>
<Transition
enter-active-class="transition-all duration-500 ease-out delay-100"
enter-from-class="opacity-0 scale-75 -translate-y-10"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
>
<div
v-if="isVisible"
class="bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 rounded-2xl shadow-2xl border-2 max-w-md w-full p-8 relative overflow-hidden"
:class="[
winner === 'player'
? 'border-emerald-500/50'
: winner === 'opponent'
? 'border-rose-500/50'
: 'border-gray-600/50',
]"
>
<!-- Confetti Effect (if player wins) -->
<div
v-if="winner === 'player' && showConfetti"
class="absolute inset-0 pointer-events-none"
>
<div
v-for="i in 30"
:key="i"
class="absolute w-2 h-2 animate-confetti"
:style="{
left: Math.random() * 100 + '%',
top: '-10px',
backgroundColor: ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa'][
Math.floor(Math.random() * 5)
],
animationDelay: Math.random() * 2 + 's',
animationDuration: 3 + Math.random() * 2 + 's',
}"
></div>
</div>
<!-- Winner Icon/Badge -->
<div class="flex justify-center mb-6">
<div
class="relative"
:class="[winner === 'player' ? 'animate-bounce' : 'animate-pulse']"
>
<div
v-if="winner === 'player'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/50"
>
<span class="text-5xl">🏆</span>
</div>
<div
v-else-if="winner === 'opponent'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-rose-400 to-rose-600 flex items-center justify-center shadow-lg shadow-rose-500/50"
>
<span class="text-5xl">😔</span>
</div>
<div
v-else
class="w-24 h-24 rounded-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center shadow-lg"
>
<span class="text-5xl">🤝</span>
</div>
</div>
</div>
<!-- Title -->
<h2
class="text-4xl font-black text-center mb-2 bg-clip-text text-transparent bg-gradient-to-r"
:class="[
winner === 'player'
? 'from-emerald-300 to-emerald-500'
: winner === 'opponent'
? 'from-rose-300 to-rose-500'
: 'from-gray-300 to-gray-500',
]"
>
{{ title }}
</h2>
<!-- Subtitle -->
<p class="text-center text-gray-400 mb-8">{{ subtitle }}</p>
<!-- Scores -->
<div class="bg-black/30 rounded-xl p-6 mb-8 space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ playerName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
playerScore > opponentScore
? 'text-emerald-400 drop-shadow-[0_0_12px_rgba(52,211,153,0.6)]'
: 'text-white',
]"
>
{{ displayPlayerScore }}
</span>
</div>
<div class="h-px bg-gray-700"></div>
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ opponentName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
opponentScore > playerScore
? 'text-rose-400 drop-shadow-[0_0_12px_rgba(251,113,133,0.6)]'
: 'text-white',
]"
>
{{ displayOpponentScore }}
</span>
</div>
</div>
<!-- Stats (optional) -->
<div v-if="stats" class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Your Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.playerTricks }}</p>
</div>
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Opponent Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.opponentTricks }}</p>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
v-if="!isLoggingOut"
@click="$emit('play-again')"
class="flex-1 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-bold py-3 px-6 rounded-lg transition-all shadow-lg"
>
Play Again
</button>
<button
v-if="!hideClose"
@click="handleClose"
class="flex-1 font-bold py-3 px-6 rounded-lg transition-all"
:class="[
isLoggingOut
? 'bg-red-600 hover:bg-red-700 text-white w-full'
: 'bg-gray-700 hover:bg-gray-600 text-white',
]"
>
{{ isLoggingOut ? 'Confirm Logout' : 'Close' }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
isVisible: {
type: Boolean,
required: true,
},
winner: {
type: String,
required: true,
validator: (value) => ['player', 'opponent', 'draw'].includes(value),
},
playerScore: {
type: Number,
required: true,
},
opponentScore: {
type: Number,
required: true,
},
playerName: {
type: String,
default: 'You',
},
opponentName: {
type: String,
default: 'Bot',
},
stats: {
type: Object,
default: null,
},
hideClose: {
type: Boolean,
default: false,
},
isLoggingOut: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close', 'play-again'])
const showConfetti = ref(false)
const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => {
if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat'
return 'Draw!'
})
// Computed subtitle
const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw'
})
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => {
const duration = 1000
const steps = 30
const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps
let current = fromValue
let step = 0
const interval = setInterval(() => {
step++
current += increment
if (step >= steps) {
current = toValue
clearInterval(interval)
}
callback(Math.round(current))
}, stepDuration)
}
// Watch for visibility and trigger animations
watch(
() => props.isVisible,
(newValue) => {
if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') {
setTimeout(() => {
showConfetti.value = true
}, 300)
}
// Animate scores counting up
setTimeout(() => {
animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value
})
animateScore(0, props.opponentScore, (value) => {
displayOpponentScore.value = value
})
}, 400)
} else {
showConfetti.value = false
displayPlayerScore.value = 0
displayOpponentScore.value = 0
}
},
{ immediate: true },
)
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti linear forwards;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="flex items-center justify-center h-64 relative">
<div class="relative w-96 h-full flex items-center justify-center">
<!-- Opponent Card Slot -->
<div
class="absolute top-4"
:class="[
winner === 'opponent' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: opponentCard ? (firstPlayer === 'opponent' ? 1 : 2) : 0 }"
>
<div
v-if="!opponentCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">Opponent</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 -translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div
v-if="opponentCard"
:key="`${opponentCard.suit}-${opponentCard.rank}`"
class="rotate-6"
>
<GameCard :suit="opponentCard.suit" :rank="opponentCard.rank" />
</div>
</Transition>
</div>
<!-- Player Card Slot -->
<div
class="absolute bottom-4"
:class="[
winner === 'player' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: playerCard ? (firstPlayer === 'player' ? 1 : 2) : 0 }"
>
<div
v-if="!playerCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">You</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 -rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 -rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div v-if="playerCard" :key="`${playerCard.suit}-${playerCard.rank}`" class="-rotate-6">
<GameCard :suit="playerCard.suit" :rank="playerCard.rank" />
</div>
</Transition>
</div>
</div>
</div>
</template>
<script setup>
import GameCard from './GameCard.vue'
const props = defineProps({
playerCard: {
type: Object,
default: null,
},
opponentCard: {
type: Object,
default: null,
},
winner: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
firstPlayer: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
})
</script>
+108
View File
@@ -0,0 +1,108 @@
<template>
<div class="flex justify-center items-end relative h-40">
<TransitionGroup
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 -translate-y-8 scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100"
>
<div
v-for="(card, index) in cards"
:key="`${card.suit}-${card.rank}-${index}`"
class="absolute transition-all duration-300 ease-out"
:style="getCardStyle(index)"
@mouseenter="hoveredIndex = index"
@mouseleave="hoveredIndex = null"
@click="handleCardClick(card, index)"
>
<div
:class="[
'transition-all duration-300 rounded-lg',
isValid(card)
? 'cursor-pointer hover:shadow-xl ring-2 ring-transparent hover:ring-yellow-400/50'
: 'cursor-not-allowed opacity-60 grayscale brightness-75',
hoveredIndex === index && isValid(card) && 'scale-110 -translate-y-4 z-50',
]"
>
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import GameCard from './GameCard.vue'
import { toast } from 'vue-sonner'
import { useBiscaStore } from '@/stores/bisca'
const props = defineProps({
cards: {
type: Array,
required: true,
default: () => [],
},
faceDown: {
type: Boolean,
default: false,
},
maxCards: {
type: Number,
default: 3,
validator: (value) => [3, 9].includes(value),
},
})
const emit = defineEmits(['card-clicked'])
const store = useBiscaStore()
const hoveredIndex = ref(null)
const isValid = (card) => {
if (props.faceDown) return false
return store.canPlayCard(card)
}
const handleCardClick = (card, index) => {
if (props.faceDown) return
if (store.canPlayCard(card)) {
emit('card-clicked', { card, index })
} else {
toast.warning('Jogada Inválida', {
description: 'Tens de assistir ao naipe jogado!',
duration: 2000,
})
}
}
const overlapPercentage = computed(() => {
return props.maxCards === 3 ? 0.7 : 0.85
})
const cardWidth = 128
const getCardStyle = (index) => {
const totalCards = props.cards.length
const overlapOffset = cardWidth * (1 - overlapPercentage.value)
const totalWidth = cardWidth + (totalCards - 1) * overlapOffset
const startOffset = -totalWidth / 2 + cardWidth / 2
let left = startOffset + index * overlapOffset
if (hoveredIndex.value !== null && hoveredIndex.value !== index) {
if (isValid(props.cards[hoveredIndex.value])) {
if (index < hoveredIndex.value) {
left -= 10
} else if (index > hoveredIndex.value) {
left += 10
}
}
}
return {
left: `calc(50% + ${left}px)`,
zIndex: hoveredIndex.value === index ? 50 : index + 1,
}
}
</script>
@@ -0,0 +1,176 @@
<template>
<div
class="inline-flex bg-gray-900/80 backdrop-blur-sm border border-gray-700/50 shadow-xl rounded-md px-4 py-1.5 items-center gap-4"
>
<!-- Opponent Score (Left) -->
<div class="flex items-center gap-2">
<div class="flex flex-col items-start">
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
opponentName
}}</span>
<span
class="text-2xl font-black tabular-nums transition-all duration-300"
:class="[
opponentScore > playerScore
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
: 'text-white',
scoreChanged === 'opponent' && 'scale-110',
]"
>
{{ displayOpponentScore }}
</span>
</div>
</div>
<!-- Center Turn Indicator -->
<div class="flex flex-col items-center gap-1">
<div
class="px-3 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider transition-all duration-300"
:class="[
currentTurn === 'player'
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40 shadow-[0_0_10px_rgba(52,211,153,0.4)]'
: 'bg-rose-500/20 text-rose-400 border border-rose-500/40 shadow-[0_0_10px_rgba(251,113,133,0.4)]',
turnChanged && 'animate-pulse',
]"
>
{{ currentTurn === 'player' ? 'Your Turn' : "Bot's Turn" }}
</div>
<span v-if="roundNumber" class="text-gray-500 text-[10px] font-medium">
Round {{ roundNumber }}
</span>
</div>
<!-- Player Score (Right) -->
<div class="flex items-center gap-2">
<div class="flex flex-col items-end">
<span class="text-gray-400 text-[10px] uppercase tracking-wide mb-0.5">{{
playerName
}}</span>
<span
class="text-2xl font-black tabular-nums transition-all duration-300"
:class="[
playerScore > opponentScore
? 'text-amber-400 drop-shadow-[0_0_12px_rgba(251,191,36,0.6)]'
: 'text-white',
scoreChanged === 'player' && 'scale-110',
]"
>
{{ displayPlayerScore }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, computed } from 'vue'
const props = defineProps({
playerScore: {
type: Number,
required: true,
},
opponentScore: {
type: Number,
required: true,
},
playerName: {
type: String,
default: 'You',
},
opponentName: {
type: String,
default: 'Bot',
},
currentTurn: {
type: String,
default: 'player',
validator: (value) => ['player', 'opponent'].includes(value),
},
roundNumber: {
type: Number,
default: null,
},
})
const scoreChanged = ref(null)
const turnChanged = ref(false)
const displayPlayerScore = ref(props.playerScore)
const displayOpponentScore = ref(props.opponentScore)
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => {
const duration = 500 // Total animation duration in ms
const steps = 20 // Number of increments
const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps
let current = fromValue
let step = 0
const interval = setInterval(() => {
step++
current += increment
if (step >= steps) {
current = toValue
clearInterval(interval)
}
callback(Math.round(current))
}, stepDuration)
}
// Watch for player score changes and trigger count-up animation
watch(
() => props.playerScore,
(newScore, oldScore) => {
scoreChanged.value = 'player'
if (oldScore !== undefined && newScore !== oldScore) {
animateScore(oldScore, newScore, (value) => {
displayPlayerScore.value = value
})
} else {
displayPlayerScore.value = newScore
}
setTimeout(() => {
scoreChanged.value = null
}, 300)
},
{ immediate: true },
)
// Watch for opponent score changes and trigger count-up animation
watch(
() => props.opponentScore,
(newScore, oldScore) => {
scoreChanged.value = 'opponent'
if (oldScore !== undefined && newScore !== oldScore) {
animateScore(oldScore, newScore, (value) => {
displayOpponentScore.value = value
})
} else {
displayOpponentScore.value = newScore
}
setTimeout(() => {
scoreChanged.value = null
}, 300)
},
{ immediate: true },
)
// Watch for turn changes and trigger pulse animation
watch(
() => props.currentTurn,
() => {
turnChanged.value = true
setTimeout(() => {
turnChanged.value = false
}, 1000)
},
)
</script>
+54 -4
View File
@@ -15,18 +15,30 @@
</li> </li>
</NavigationMenuContent> </NavigationMenuContent>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn"> <NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuLink> <NavigationMenuLink>
<RouterLink to="/login">Login</RouterLink> <RouterLink to="/login">Login</RouterLink>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
<NavigationMenuItem v-else>
<NavigationMenuLink> <NavigationMenuItem v-else class="flex flex-row gap-4">
<a @click.prevent="logoutClickHandler">Logout</a> <NavigationMenuLink v-if="coins > 0" href="/user?tab=transaction-history"
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">
<span class="text-[10px] font-bold text-purple-900">$</span>
</div>
<span class="text-sm font-medium tabular-nums">
{{ coins }} <span class="text-muted-foreground text-xs">Coins</span>
</span>
</NavigationMenuLink> </NavigationMenuLink>
<NavigationMenuLink> <NavigationMenuLink>
<RouterLink to="/user">Profile</RouterLink> <RouterLink to="/user">Profile</RouterLink>
</NavigationMenuLink> </NavigationMenuLink>
<NavigationMenuLink>
<a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
</NavigationMenuLink>
</NavigationMenuItem> </NavigationMenuItem>
</NavigationMenuList> </NavigationMenuList>
</NavigationMenu> </NavigationMenu>
@@ -34,6 +46,7 @@
</template> </template>
<script setup> <script setup>
import { useBiscaStore } from '@/stores/bisca'
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuContent, NavigationMenuContent,
@@ -43,13 +56,50 @@ import {
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 { 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 logoutClickHandler = () => { const logoutClickHandler = () => {
if (biscaStore.isGameRunning) {
const confirmLogout = window.confirm(
'If you leave now this game will count as "FORFEIT". Are you sure you want to logout?'
)
if (!confirmLogout) return
biscaStore.isLoggingOut = true
biscaStore.quitGame()
return
}
emits('logout') emits('logout')
router.push('/login') router.push('/login')
} }
</script>
const fetchUserCoins = async () => {
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) => {
if (isLoggedIn) {
await fetchUserCoins()
} else {
coins.value = 0
}
}, { immediate: true })
</script>
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";
+3
View File
@@ -39,6 +39,9 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
} }
:root { :root {
+1 -1
View File
@@ -1,5 +1,5 @@
import { clsx } from "clsx"; import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge"
export function cn(...inputs) { export function cn(...inputs) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
+1 -1
View File
@@ -15,7 +15,7 @@ const app = createApp(App)
//app.provide('socket', io(wsConnection)) //app.provide('socket', io(wsConnection))
app.provide('serverBaseURL', `http://${apiDomain}`) app.provide('serverBaseURL', `http://${apiDomain}`)
app.provide('apiBaseURL', `http://${apiDomain}/api`) app.provide('apiBaseURL', `http://${apiDomain}/api/v1`)
app.use(createPinia()) app.use(createPinia())
app.use(router) app.use(router)
+342
View File
@@ -0,0 +1,342 @@
<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
@@ -0,0 +1,252 @@
<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
@@ -0,0 +1,275 @@
<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
@@ -0,0 +1,444 @@
<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>
@@ -0,0 +1,142 @@
<template>
<div class="relative min-h-screen overflow-hidden">
<div v-if="store.isGameRunning" 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="store.isGameRunning">
<GameBoard
:trump-card="store.trumpCard"
:cards-remaining="store.deck.length + (store.trumpCard ? 1 : 0)"
:player-hand="store.playerHand"
:opponent-hand="store.opponentHand"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:current-turn="store.currentTurn"
:current-trick="store.table"
@play-card="handlePlayCard"
/>
</div>
<div v-else class="flex h-screen items-center justify-center bg-green-900 text-white">
<div class="animate-pulse text-xl">Preparing game...</div>
</div>
<GameOver
:is-visible="store.isGameOver"
:winner="store.winner || 'draw'"
:player-score="store.playerScore"
:opponent-score="store.opponentScore"
:stats="{ playerTricks: store.playerTricks, opponentTricks: store.opponentTricks }"
:is-logging-out="store.isLoggingOut"
@play-again="handlePlayAgain"
@close="handleCloseModal"
/>
</div>
</template>
<script setup>
import { onMounted, onUnmounted, onBeforeMount } from 'vue'
import { useRouter, onBeforeRouteLeave } from 'vue-router'
import { toast } from 'vue-sonner' // Import Toast for feedback
import { useBiscaStore } from '@/stores/bisca'
import { useAuthStore } from '@/stores/auth'
import GameBoard from '@/components/game/GameBoard.vue'
import GameOver from '@/components/game/GameOver.vue'
const props = defineProps({
gameType: {
type: Number,
default: 3,
},
})
const store = useBiscaStore()
const router = useRouter()
const authStore = useAuthStore()
onBeforeMount(() => {
store.isGameOver = false
})
onMounted(() => {
store.startGame(props.gameType)
window.addEventListener('beforeunload', handleBrowserUnload)
})
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBrowserUnload)
store.isGameOver = false
store.isGameRunning = false
store.isLoggingOut = false
})
const handlePlayCard = (card) => {
store.playerPlayCard(card)
}
const handlePlayAgain = () => {
store.startGame(props.gameType)
}
const handleSurrender = () => {
const confirmSurrender = window.confirm(
'Are you sure you want to surrender? Your opponent will be awarded the win.',
)
if (!confirmSurrender) return
store.quitGame()
toast.error('Game Surrendered', {
description: 'You forfeited the match.',
})
router.push({ name: 'home' })
}
const handleCloseModal = () => {
if (store.isLoggingOut) {
localStorage.removeItem('token')
authStore.logout()
store.$reset()
router.push({ name: 'login' })
return
}
store.isGameRunning = false
store.isGameOver = false
router.push({ name: 'home' })
}
const handleBrowserUnload = (event) => {
if (store.isGameRunning) {
event.preventDefault()
event.returnValue = ''
}
}
onBeforeRouteLeave((to, from, next) => {
if (!store.isGameRunning) {
next()
return
}
const confirmExit = window.confirm(
'⚠️ Game in Progress!\n\nIf you leave now, the game will be cancelled and recorded as a LOSS.\n\nAre you sure you want to leave?',
)
if (confirmExit) {
store.quitGame()
next()
} else {
next(false)
}
})
</script>
+238 -7
View File
@@ -1,11 +1,242 @@
<template>
<div>
</div>
</template>
<script setup> <script setup>
import { ref, onMounted, nextTick } from 'vue'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@/components/ui/card'
import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router'
const apiStore = useAPIStore()
const router = useRouter()
const openGames = ref([])
const selectedMode = ref('9')
const observerTarget = ref(null);
const observer = ref(null);
const isLoading = ref(false);
const pageCounter = ref(1);
const showHostModal = ref(false)
const showJoinModal = ref(false)
const selectedGame = ref(null)
const isReady = ref(false)
const setupObserver = () => {
if (observer.value) observer.value.disconnect();
observer.value = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading.value && openGames.value.length > 0) {
getPendingGames();
}
}, { threshold: 0.1 });
if (observerTarget.value) {
observer.value.observe(observerTarget.value);
}
}
const getPendingGames = async (mode = selectedMode.value) => {
if (isLoading.value) return;
isLoading.value = true;
apiStore.gameQueryParameters.page = pageCounter.value++;
apiStore.gameQueryParameters.filters.sort_direction = "asc"
apiStore.gameQueryParameters.filters.type = mode
apiStore.gameQueryParameters.filters.status = 'Pending'
try {
const response = await apiStore.getGames();
const newGames = response.data.data;
openGames.value = [...openGames.value, ...newGames];
} finally {
isLoading.value = false;
}
}
const clickMode = async (mode) => {
if (selectedMode.value === mode) return;
openGames.value = [];
selectedMode.value = mode;
pageCounter.value = 1;
await getPendingGames();
setupObserver();
}
const startSingle = () => {
router.push({ name: 'bisca' + selectedMode.value });
}
const JoinGameModal = (game) => {
selectedGame.value = game
isReady.value = false
showJoinModal.value = true
}
const hostGameModal = () => {
showHostModal.value = true
}
const toggleReady = () => {
isReady.value = !isReady.value
}
onMounted(async () => {
await getPendingGames();
await nextTick();
setupObserver();
})
</script> </script>
<style scoped></style>` <template>
<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 px-4">
<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">
<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="{
'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>
</button>
<button @click="clickMode('3')"
class="h-full flex items-center justify-center font-semibold transition-all duration-500 ease-in-out"
:class="{
'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>
</button>
</div>
</div>
<div class="flex flex-col items-center gap-5 w-[100vw]">
<Card class="w-full max-w-2xl">
<CardHeader>
<CardTitle class="text-3xl font-bold text-center">
Single Player
</CardTitle>
<CardDescription class="text-center">
Go against one of our bots!
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex justify-center">
<Button @click="startSingle()" size="lg" variant="secondary"
class="hover:bg-purple-500 hover:text-slate-200">
Start Game
</Button>
</div>
</CardContent>
</Card>
<Card class="w-full max-w-2xl">
<CardHeader>
<CardTitle class="text-3xl font-bold text-center">
MultiPlayer
</CardTitle>
<CardDescription class="text-center">
Go one on one with another player!
</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<label class="text-sm font-medium">Open games (oldest first)</label>
<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 yet. Try hosting one!
</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="JoinGameModal(game)"
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 items-center gap-3">
<div
class="flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold bg-green-100 text-green-700">
{{ index + 1 }}
</div>
<div>
<div class="font-medium text-sm">Started: {{ game.began_at }}</div>
<div class="text-xs text-muted-foreground">host: {{
game.player1?.nickname || 'Anonymous' }}</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>
</Card>
</div>
</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">
<CardHeader>
<CardTitle class="text-center">Hosting Game</CardTitle>
</CardHeader>
<CardContent class="flex flex-col items-center gap-6 py-10">
<div class="relative">
<div
class="h-16 w-16 animate-spin rounded-full border-4 border-purple-500 border-t-transparent">
</div>
</div>
<p class="animate-pulse text-lg font-medium text-muted-foreground">Waiting for opponents...</p>
<Button variant="outline" @click="showHostModal = false">Cancel Hosting</Button>
</CardContent>
</Card>
</div>
<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>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
+1 -1
View File
@@ -44,7 +44,7 @@
<div class="text-center text-sm"> <div class="text-center text-sm">
<span class="text-gray-600">Don't have an account? </span> <span class="text-gray-600">Don't have an account? </span>
<a href="#" class="font-medium text-blue-600 hover:text-blue-500"> <a href="/register" class="font-medium text-blue-600 hover:text-blue-500">
Sign up Sign up
</a> </a>
</div> </div>
@@ -0,0 +1,151 @@
<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">Sign up</h2>
<p class="mt-2 text-center text-sm text-gray-600">Enter your data to create your account</p>
</div>
<form class="mt-8 space-y-4" @submit.prevent="handleSubmit">
<div class="flex flex-col items-center justify-center space-y-2">
<label for="avatar-upload"
class="group relative flex h-24 w-24 cursor-pointer items-center justify-center overflow-hidden rounded-full border-2 border-dashed border-gray-300 bg-gray-50 transition hover:border-blue-400 hover:bg-gray-100">
<img v-if="imagePreview" :src="imagePreview" class="h-full w-full object-cover" />
<div v-else class="flex flex-col items-center text-gray-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
<span class="text-[10px] font-medium">UPLOAD</span>
</div>
<div
class="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 transition group-hover:opacity-100">
<span class="text-xs font-semibold text-white">Change</span>
</div>
</label>
<input id="avatar-upload" type="file" class="hidden" accept="image/*" @change="handleFileChange" />
<p class="text-xs text-gray-500">Optional</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
<Input v-model="formData.name" type="text" placeholder="John Doe" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nickname</label>
<Input v-model="formData.nickname" type="text" placeholder="johny123" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<Input v-model="formData.email" type="email" placeholder="[email protected]" />
<p v-if="errors.email" class="mt-1 text-xs text-red-500">{{ errors.email }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
<Input v-model="formData.password" type="password" placeholder="••••••••" />
<p v-if="errors.password" class="mt-1 text-xs text-red-500">{{ errors.password }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
<Input v-model="formData.confirmPassword" type="password" placeholder="••••••••" />
<p v-if="errors.confirmPassword" class="mt-1 text-xs text-red-500">{{ errors.confirmPassword }}</p>
</div>
<div class="pt-4">
<Button type="submit" class="w-full">Create Account</Button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { useRegisterStore } from '@/stores/register'
import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
const registerStore = useRegisterStore()
const router = useRouter()
const formData = ref({
email: '',
nickname: '',
name: '',
password: '',
confirmPassword: '',
photo: ''
})
const imagePreview = ref(null)
const errors = ref({})
const handleFileChange = (event) => {
const file = event.target.files[0]
if (file) {
formData.value.photo = file
imagePreview.value = URL.createObjectURL(file)
}
console.log(formData, file)
}
const validateForm = () => {
errors.value = {} // Reset errors
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,}))$/)) {
errors.value.email = "Please enter a valid email"
isValid = false
}
if (!formData.value.password || formData.value.password.length < 3) {
errors.value.password = "Password must be at least 3 characters"
isValid = false
}
if (formData.value.password !== formData.value.confirmPassword) {
errors.value.confirmPassword = "Passwords do not match"
isValid = false
}
return isValid
}
const handleSubmit = async () => {
if (!validateForm()) {
toast.error("Please fix the errors in the form")
return
}
const data = new FormData()
data.append('email', formData.value.email)
data.append('nickname', formData.value.nickname)
data.append('name', formData.value.name)
data.append('password', formData.value.password)
if (formData.value.photo) {
data.append('photo_avatar_filename', formData.value.photo)
}
const promise = registerStore.register(data)
toast.promise(promise, {
loading: 'Creating your account...',
success: (res) => {
router.push('/login')
return `Registration Successful - ${res.data.user.name}`
},
error: (error) => error.response?.data?.message || "Registration failed"
})
}
</script>
+513 -150
View File
@@ -9,57 +9,55 @@
</div> </div>
<!-- Error State --> <!-- Error State -->
<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"> <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">
<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">
<circle cx="12" cy="12" r="10" stroke-width="2"/> <circle cx="12" cy="12" r="10" stroke-width="2" />
<line x1="12" y1="8" x2="12" y2="12" stroke-width="2"/> <line x1="12" y1="8" x2="12" y2="12" stroke-width="2" />
<line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2"/> <line x1="12" y1="16" x2="12.01" y2="16" stroke-width="2" />
</svg> </svg>
<h3 class="text-2xl mb-2 text-black">Oops! Something went wrong</h3> <h3 class="text-2xl mb-2 text-black">Oops! Something went wrong</h3>
<p class="text-gray-600 mb-6">{{ error }}</p> <p class="text-gray-600 mb-6">{{ error }}</p>
<button @click="retry" class="flex items-center gap-2 px-6 py-3 bg-black text-white cursor-pointer text-base font-medium transition-colors hover:bg-gray-800 mx-auto"> <button @click="retry"
class="flex items-center gap-2 px-6 py-3 bg-black text-white cursor-pointer text-base font-medium transition-colors hover:bg-gray-800 mx-auto">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M1 4v6h6M23 20v-6h-6" stroke-width="2"/> <path d="M1 4v6h6M23 20v-6h-6" stroke-width="2" />
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" stroke-width="2"/> <path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15" stroke-width="2" />
</svg> </svg>
Try Again Try Again
</button> </button>
</div> </div>
<!-- User Profile --> <!-- User Profile -->
<div v-else-if="authStore.currentUser" class="bg-white border border-gray-300 shadow-lg max-w-4xl w-full overflow-hidden"> <div v-else-if="authStore.currentUser"
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 <img v-if="authStore.currentUser.photo_avatar_filename"
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', '')}/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" <div v-else
class="w-24 h-24 border-[3px] border-white object-cover" class="w-24 h-24 border-[3px] border-white bg-white text-black flex items-center justify-center text-4xl font-semibold">
/>
<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">
{{ authStore.currentUser.name.charAt(0).toUpperCase() }} {{ authStore.currentUser.name.charAt(0).toUpperCase() }}
</div> </div>
<button @click="triggerFileInput" class="absolute bottom-0 right-0 w-9 h-9 bg-white border-2 border-black cursor-pointer flex items-center justify-center transition-colors hover:bg-gray-100"> <button @click="triggerFileInput"
class="absolute bottom-0 right-0 w-9 h-9 bg-white border-2 border-black cursor-pointer flex items-center justify-center transition-colors hover:bg-gray-100">
<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">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke-width="1.5"/> <path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
<circle cx="12" cy="13" r="4" stroke-width="1.5"/> stroke-width="1.5" />
<circle cx="12" cy="13" r="4" stroke-width="1.5" />
</svg> </svg>
</button> </button>
<input <input ref="fileInput" type="file" accept="image/*" @change="handleAvatarUpload" class="hidden" />
ref="fileInput"
type="file"
accept="image/*"
@change="handleAvatarUpload"
class="hidden"
/>
</div> </div>
<div> <div>
<h1 class="text-3xl font-semibold mb-2">{{ authStore.currentUser.name }}</h1> <h1 class="text-3xl font-semibold mb-2">{{ authStore.currentUser.name }}</h1>
<p class="text-base opacity-80">@{{ authStore.currentUser.nickname }}</p> <p class="text-base opacity-80">@{{ authStore.currentUser.nickname }}</p>
</div> </div>
<div 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"> <div
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>
@@ -71,26 +69,32 @@
<div class="flex border-b border-gray-300 bg-gray-50"> <div class="flex border-b border-gray-300 bg-gray-50">
<button <button
: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' }]" :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' }]"
@click="activeTab = 'info'" @click="activeTab = 'info'">
>
Profile Information Profile Information
</button> </button>
<button <button
: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' }]" :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' }]"
@click="activeTab = 'edit'" @click="activeTab = 'edit'">
>
Edit Profile Edit Profile
</button> </button>
<button
: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' }]"
@click="activeTab = 'transaction-history'">
Transaction History
</button>
<button
: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' }]"
@click="activeTab = 'game-history'">
Game History
</button>
<button <button
: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' }]" :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' }]"
@click="activeTab = 'password'" @click="activeTab = 'password'">
>
Change Password Change Password
</button> </button>
<button <button
: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' }]" :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' }]"
@click="activeTab = 'delete'" @click="activeTab = 'delete'">
>
Delete Account Delete Account
</button> </button>
</div> </div>
@@ -101,8 +105,8 @@
<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">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/> <path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6"/> <polyline points="22,6 12,13 2,6" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -114,12 +118,13 @@
<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">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/> <path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4"/> <circle cx="12" cy="7" r="4" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Account Type</label> <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Account
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>
@@ -127,14 +132,15 @@
<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">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/> <rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6"/> <line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6"/> <line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10"/> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Member Since</label> <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Member
Since</label>
<p class="text-sm text-black">{{ formatDate(authStore.currentUser.created_at) }}</p> <p class="text-sm text-black">{{ formatDate(authStore.currentUser.created_at) }}</p>
</div> </div>
</div> </div>
@@ -142,29 +148,32 @@
<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">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10" />
<path d="M9 12l2 2 4-4"/> <path d="M9 12l2 2 4-4" />
</svg> </svg>
</div> </div>
<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 :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']"> <span
: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']">
{{ authStore.currentUser.blocked ? 'Blocked' : 'Active' }} {{ authStore.currentUser.blocked ? 'Blocked' : 'Active' }}
</span> </span>
</p> </p>
</div> </div>
</div> </div>
<div v-if="authStore.currentUser.email_verified_at" class="flex items-start gap-4 p-5 bg-white border border-gray-300 transition-shadow hover:shadow-md"> <div v-if="authStore.currentUser.email_verified_at"
class="flex items-start gap-4 p-5 bg-white border border-gray-300 transition-shadow hover:shadow-md">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/> <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01"/> <polyline points="22 4 12 14.01 9 11.01" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Email Verified</label> <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Email
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>
@@ -172,12 +181,13 @@
<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">
<div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600"> <div class="w-10 h-10 flex items-center justify-center flex-shrink-0 text-gray-600">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<polyline points="23 4 23 10 17 10"/> <polyline points="23 4 23 10 17 10" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/> <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Last Updated</label> <label class="block text-xs font-semibold text-gray-500 mb-1 uppercase tracking-wider">Last
Updated</label>
<p class="text-sm text-black">{{ formatDate(authStore.currentUser.updated_at) }}</p> <p class="text-sm text-black">{{ formatDate(authStore.currentUser.updated_at) }}</p>
</div> </div>
</div> </div>
@@ -189,94 +199,258 @@
<form @submit.prevent="updateProfile" class="max-w-lg"> <form @submit.prevent="updateProfile" class="max-w-lg">
<div class="mb-6"> <div class="mb-6">
<label for="name" class="block text-sm font-semibold text-gray-800 mb-2">Name</label> <label for="name" class="block text-sm font-semibold text-gray-800 mb-2">Name</label>
<input <input id="name" v-model="profileForm.name" type="text" required :disabled="updatingProfile"
id="name" class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" />
v-model="profileForm.name"
type="text"
required
:disabled="updatingProfile"
class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="nickname" class="block text-sm font-semibold text-gray-800 mb-2">Nickname</label> <label for="nickname" class="block text-sm font-semibold text-gray-800 mb-2">Nickname</label>
<input <input id="nickname" v-model="profileForm.nickname" type="text" required :disabled="updatingProfile"
id="nickname" class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" />
v-model="profileForm.nickname"
type="text"
required
:disabled="updatingProfile"
class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed"
/>
</div> </div>
<div class="flex gap-4 mt-8"> <div class="flex gap-4 mt-8">
<button type="button" @click="resetProfileForm" :disabled="updatingProfile" class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-white text-black hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"> <button type="button" @click="resetProfileForm" :disabled="updatingProfile"
class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-white text-black hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed">
Cancel Cancel
</button> </button>
<button type="submit" :disabled="updatingProfile" class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-black text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"> <button type="submit" :disabled="updatingProfile"
class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-black text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed">
{{ updatingProfile ? 'Saving...' : 'Save Changes' }} {{ updatingProfile ? 'Saving...' : 'Save Changes' }}
</button> </button>
</div> </div>
<div v-if="profileMessage" :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']"> <div v-if="profileMessage"
: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']">
{{ profileMessage }} {{ profileMessage }}
</div> </div>
</form> </form>
</div> </div>
<!-- Transaction History Tab -->
<div v-if="activeTab === 'transaction-history'" 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">Type</label>
<select v-model="filters.type" @change="resetAndFetch"
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 Types</option>
<option value="credit">Credit (+)</option>
<option value="debit">Debit (-)</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>
<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="[transaction.type === 'credit' ? 'border-green-500/30' : 'border-red-500/30']">
<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"
: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-else>-</span>
</div>
<div class="flex flex-col">
<div class="font-semibold text-sm">
{{ transaction.category }} </div>
<div class="text-xs text-muted-foreground">
{{ new Date(transaction.began_at).toLocaleDateString() }}
{{ new Date(transaction.began_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}}
</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-sm"
:class="transaction.type === 'credit' ? 'text-green-600' : 'text-red-600'">
{{ transaction.type === 'credit' ? '+' : '-' }}{{ transaction.amount }} coins
</div>
<div class="text-[10px] text-muted-foreground uppercase tracking-wider">
{{ transaction.reference }} </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>
<!-- Game History Tab -->
<div v-if="activeTab === 'game-history'" 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">Outcome</label>
<select v-model="filters.outcome" @change="resetAndFetch"
class="bg-background border rounded-md px-3 py-2 text-sm outline-none">
<option value="all">All Results</option>
<option value="win">Win</option>
<option value="loss">Loss</option>
<option value="draw">Draw</option>
<option value="forfeit">Forfeit</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="outcome">Outcome</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="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"
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="game.outcome === 'win' ? 'text-green-500' : 'text-red-500'"
class="font-bold uppercase text-xs">
{{ game.outcome }}
</div>
<div>
<div class="font-semibold text-sm">vs {{ game.opponent }}</div>
<div class="text-xs text-muted-foreground">
{{ game.variant }} {{ game.duration }}
</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-sm">{{ game.amount }} pts</div>
<div class="text-[10px] text-muted-foreground">
{{ new Date(game.began_at).toLocaleDateString() }} {{ new
Date(game.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: {{ game.details.capotes }}</span>
<span>Bandeiras: {{ game.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>
<!-- Change Password Tab --> <!-- 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">
<label for="current_password" class="block text-sm font-semibold text-gray-800 mb-2">Current Password</label> <label for="current_password" class="block text-sm font-semibold text-gray-800 mb-2">Current
<input Password</label>
id="current_password" <input id="current_password" v-model="passwordForm.current_password" type="password" required
v-model="passwordForm.current_password"
type="password"
required
:disabled="updatingPassword" :disabled="updatingPassword"
class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" />
/>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="new_password" class="block text-sm font-semibold text-gray-800 mb-2">New Password</label> <label for="new_password" class="block text-sm font-semibold text-gray-800 mb-2">New Password</label>
<input <input id="new_password" v-model="passwordForm.new_password" type="password" required minlength="8"
id="new_password"
v-model="passwordForm.new_password"
type="password"
required
minlength="8"
:disabled="updatingPassword" :disabled="updatingPassword"
class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" />
/>
<small class="block mt-1 text-xs text-gray-600">Password must be at least 8 characters long</small> <small class="block mt-1 text-xs text-gray-600">Password must be at least 8 characters long</small>
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label for="confirm_password" class="block text-sm font-semibold text-gray-800 mb-2">Confirm New Password</label> <label for="confirm_password" class="block text-sm font-semibold text-gray-800 mb-2">Confirm New
<input Password</label>
id="confirm_password" <input id="confirm_password" v-model="passwordForm.confirm_password" type="password" required
v-model="passwordForm.confirm_password"
type="password"
required
:disabled="updatingPassword" :disabled="updatingPassword"
class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" class="w-full px-3 py-3 border border-gray-300 text-base transition-colors focus:outline-none focus:border-black disabled:bg-gray-100 disabled:cursor-not-allowed" />
/>
</div> </div>
<div class="flex gap-4 mt-8"> <div class="flex gap-4 mt-8">
<button type="button" @click="resetPasswordForm" :disabled="updatingPassword" class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-white text-black hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"> <button type="button" @click="resetPasswordForm" :disabled="updatingPassword"
class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-white text-black hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed">
Cancel Cancel
</button> </button>
<button type="submit" :disabled="updatingPassword" class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-black text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed"> <button type="submit" :disabled="updatingPassword"
class="px-6 py-3 border border-black cursor-pointer text-base font-medium transition-all bg-black text-white hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed">
{{ updatingPassword ? 'Updating...' : 'Change Password' }} {{ updatingPassword ? 'Updating...' : 'Change Password' }}
</button> </button>
</div> </div>
<div v-if="passwordMessage" :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']"> <div v-if="passwordMessage"
: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']">
{{ passwordMessage }} {{ passwordMessage }}
</div> </div>
</form> </form>
@@ -287,10 +461,11 @@
<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">
<svg class="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-6 h-6 text-red-600 flex-shrink-0 mt-0.5" viewBox="0 0 24 24" fill="none"
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/> stroke="currentColor" stroke-width="2">
<line x1="12" y1="9" x2="12" y2="13"/> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="17" x2="12.01" y2="17"/> <line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
<div> <div>
<h3 class="text-lg font-semibold text-red-900 mb-2">Danger Zone</h3> <h3 class="text-lg font-semibold text-red-900 mb-2">Danger Zone</h3>
@@ -299,7 +474,8 @@
</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 }} coins</strong></li> <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>
@@ -312,29 +488,21 @@
Are you absolutely sure? Are you absolutely sure?
</h4> </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">{{ authStore.currentUser.email }}</strong> To confirm deletion, please type your email address: <strong class="text-black">{{
authStore.currentUser.email }}</strong>
</p> </p>
<input <input v-model="deleteConfirmEmail" type="text" placeholder="Enter your email to confirm"
v-model="deleteConfirmEmail" class="w-full px-3 py-3 border border-gray-300 text-base mb-4 focus:outline-none focus:border-red-600" />
type="text"
placeholder="Enter your email to confirm"
class="w-full px-3 py-3 border border-gray-300 text-base mb-4 focus:outline-none focus:border-red-600"
/>
<div class="flex gap-4"> <div class="flex gap-4">
<button <button type="button" @click="activeTab = 'info'"
type="button" class="flex-1 px-6 py-3 border border-gray-300 cursor-pointer text-base font-medium transition-all bg-white text-gray-700 hover:bg-gray-50">
@click="activeTab = 'info'"
class="flex-1 px-6 py-3 border border-gray-300 cursor-pointer text-base font-medium transition-all bg-white text-gray-700 hover:bg-gray-50"
>
Cancel Cancel
</button> </button>
<button <button @click="showDeleteConfirmation = true"
@click="showDeleteConfirmation = true"
:disabled="deleteConfirmEmail !== authStore.currentUser.email" :disabled="deleteConfirmEmail !== authStore.currentUser.email"
class="flex-1 px-6 py-3 border border-red-600 cursor-pointer text-base font-medium transition-all bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600" class="flex-1 px-6 py-3 border border-red-600 cursor-pointer text-base font-medium transition-all bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-red-600">
>
Delete My Account Delete My Account
</button> </button>
</div> </div>
@@ -345,11 +513,9 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div <div v-if="showDeleteConfirmation"
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">
>
<div class="bg-white border border-gray-300 max-w-md w-full shadow-xl"> <div class="bg-white border border-gray-300 max-w-md w-full shadow-xl">
<div class="p-6 border-b border-gray-300"> <div class="p-6 border-b border-gray-300">
<h3 class="text-xl font-semibold text-gray-900">Final Confirmation</h3> <h3 class="text-xl font-semibold text-gray-900">Final Confirmation</h3>
@@ -357,17 +523,19 @@
<div class="p-6"> <div class="p-6">
<div class="flex items-start gap-3 mb-4"> <div class="flex items-start gap-3 mb-4">
<svg class="w-12 h-12 text-red-600 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="w-12 h-12 text-red-600 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<circle cx="12" cy="12" r="10"/> stroke-width="2">
<line x1="15" y1="9" x2="9" y2="15"/> <circle cx="12" cy="12" r="10" />
<line x1="9" y1="9" x2="15" y2="15"/> <line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg> </svg>
<div> <div>
<p class="text-base text-gray-900 font-semibold mb-2"> <p class="text-base text-gray-900 font-semibold mb-2">
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 deleted. You will lose your <strong>{{ authStore.currentUser.coins_balance }} coins</strong> forever. Once you click "Yes, Delete My Account", your account and all associated data will be permanently
deleted. You will lose your <strong>{{ authStore.currentUser.coins_balance }} coins</strong> forever.
</p> </p>
</div> </div>
</div> </div>
@@ -379,18 +547,12 @@
</div> </div>
<div class="p-6 border-t border-gray-300 flex gap-3"> <div class="p-6 border-t border-gray-300 flex gap-3">
<button <button @click="showDeleteConfirmation = false" :disabled="deletingAccount"
@click="showDeleteConfirmation = false" class="flex-1 px-6 py-3 border border-gray-300 cursor-pointer text-base font-medium transition-all bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed">
:disabled="deletingAccount"
class="flex-1 px-6 py-3 border border-gray-300 cursor-pointer text-base font-medium transition-all bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel Cancel
</button> </button>
<button <button @click="deleteAccount" :disabled="deletingAccount"
@click="deleteAccount" class="flex-1 px-6 py-3 border border-red-600 cursor-pointer text-base font-medium transition-all bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
:disabled="deletingAccount"
class="flex-1 px-6 py-3 border border-red-600 cursor-pointer text-base font-medium transition-all bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Yes, Delete My Account Yes, Delete My Account
</button> </button>
</div> </div>
@@ -398,7 +560,8 @@
</div> </div>
<!-- No Data State --> <!-- No Data State -->
<div v-else-if="!authStore.isLoggedIn" class="bg-white border border-gray-300 p-12 text-center shadow-sm max-w-md w-full"> <div v-else-if="!authStore.isLoggedIn"
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>
<p>Redirecting to login...</p> <p>Redirecting to login...</p>
@@ -408,10 +571,10 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, inject, reactive } from 'vue' import { ref, onMounted, inject, reactive, watch, nextTick } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useAPIStore } from '@/stores/api' import { useAPIStore } from '@/stores/api'
import { useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import axios from 'axios' import axios from 'axios'
const authStore = useAuthStore() const authStore = useAuthStore()
@@ -424,7 +587,6 @@ 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: ''
@@ -433,7 +595,6 @@ 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: '',
@@ -443,11 +604,211 @@ 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 games = ref([]);
const observerTarget = ref(null);
const observer = ref(null);
const isLoading = ref(false);
const pagination = ref({
transactions: { page: 1, hasMore: true },
games: { page: 1, hasMore: true }
});
const filters = ref({
type: 'all',
variant: 'all',
outcome: 'all',
startDate: '',
endDate: '',
sort: {
column: 'began_at',
order: 'desc'
}
});
const handleSortChange = (column) => {
if (filters.value.sort.column === column) {
filters.value.sort.order = filters.value.sort.order === 'asc' ? 'desc' : 'asc';
} else {
filters.value.sort.column = column;
filters.value.sort.order = 'desc';
}
resetAndFetch();
};
watch(activeTab, async (newTab) => {
if (newTab !== 'transaction-history' && newTab !== 'game-history') return;
await nextTick();
if (transactions.value.length !== 0 && games.value.length !== 0) return;
await fetchData();
setupObserver();
});
const fetchData = async () => {
const currentTab = activeTab.value;
const isTransaction = currentTab === 'transaction-history';
const isGame = currentTab === 'game-history';
const state = isTransaction ? pagination.value.transactions : pagination.value.games;
if (isLoading.value || !state.hasMore) return;
isLoading.value = true;
try {
let newData = [];
if (isTransaction) {
const apiParams = {
page: state.page,
type: filters.value.type === 'credit' ? 'C' : filters.value.type === 'debit' ? 'D' : undefined,
date_from: filters.value.startDate || undefined,
date_to: filters.value.endDate || undefined,
};
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);
}
if (isGame) {
const apiParams = {
page: state.page,
type: filters.value.variant !== 'all' ? filters.value.variant : undefined,
outcome: filters.value.outcome !== 'all' ? filters.value.outcome : 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,
};
const response = await apiStore.getCurrentUserMatches(apiParams);
const apiGames = response.data.data;
newData = apiGames.map(game => {
const isPlayer1 = game.player1_user_id === authStore.currentUser.id;
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';
return {
id: game.id,
variant: `Type ${game.type}`,
outcome: outcome,
opponent: opponent?.nickname || 'Unknown Player',
amount: isPlayer1 ? 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 (newData.length < 10) state.hasMore = false;
state.page++;
} catch (error) {
console.error("Fetch failed", error);
} finally {
isLoading.value = false;
}
};
const setupObserver = () => {
if (observer.value) observer.value.disconnect();
const scrollContainer = document.querySelector('.custom-scrollbar');
observer.value = new IntersectionObserver(async (entries) => {
const isGame = activeTab.value === 'game-history';
const hasMore = isGame ? pagination.value.games.hasMore : pagination.value.transactions.hasMore;
if (entries[0].isIntersecting && !isLoading.value && hasMore) {
fetchData();
}
}, {
root: scrollContainer,
threshold: 0.1,
rootMargin: '100px'
});
if (observerTarget.value) observer.value.observe(observerTarget.value);
};
const resetAndFetch = async () => {
if (activeTab.value === 'transaction-history') {
transactions.value = [];
pagination.value.transactions = { page: 1, hasMore: true };
}
if (activeTab.value === 'game-history') {
games.value = [];
pagination.value.games = { page: 1, hasMore: true };
}
await nextTick();
await fetchData();
setupObserver();
};
const clearFilters = () => {
const isGameTab = activeTab.value === 'game-history';
const defaultSortColumn = isGameTab ? 'began_at' : 'transaction_datetime';
const isAlreadyDefault =
filters.value.startDate === '' &&
filters.value.endDate === '' &&
filters.value.sort.column === defaultSortColumn &&
filters.value.sort.order === 'desc' &&
(isGameTab
? (filters.value.variant === 'all' && filters.value.outcome === 'all')
: (filters.value.type === 'all')
);
if (isAlreadyDefault) {
return;
}
if (isGameTab) {
filters.value.variant = 'all';
filters.value.outcome = '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', {
@@ -587,13 +948,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
@@ -607,6 +965,11 @@ onMounted(async () => {
router.push('/login') router.push('/login')
return return
} }
const route = useRoute()
if (route.query.tab) {
activeTab.value = route.query.tab;
router.replace({ query: {} });
}
if (!authStore.currentUser) { if (!authStore.currentUser) {
await fetchProfile() await fetchProfile()
+56
View File
@@ -4,18 +4,45 @@ import LaravelPage from '@/pages/testing/LaravelPage.vue'
import WebsocketsPage from '@/pages/testing/WebsocketsPage.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 RegisterPage from '@/pages/register/RegisterPage.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
name: 'home',
component: HomePage, component: HomePage,
}, },
{ {
path: '/login', path: '/login',
name: 'login',
component: LoginPage, component: LoginPage,
}, },
{
path: '/register',
name: 'register',
component: RegisterPage,
},
{
path: '/game/3',
name: 'bisca3',
component: SinglePlayerGamePage,
props: { gameType: 3 },
meta: { requiresAuth: true },
},
{
path: '/game/9',
name: 'bisca9',
component: SinglePlayerGamePage,
props: { gameType: 9 },
meta: { requiresAuth: true },
},
{ {
path: '/user', path: '/user',
component: UserPage, component: UserPage,
@@ -31,9 +58,38 @@ const router = createRouter({
path: 'websockets', path: 'websockets',
component: WebsocketsPage, component: WebsocketsPage,
}, },
{
path: 'animations',
component: TestAnimations,
},
{
path: 'dealing',
component: TestDealing,
},
{
path: 'all-animations',
component: TestAllAnimations,
},
{
path: 'gameboard',
component: TestGameBoard,
},
], ],
}, },
], ],
}) })
router.beforeEach((to, from, next) => {
const requiresAuth = to.matched.some((record) => record.meta.requiresAuth)
if (requiresAuth) {
const token = localStorage.getItem('token')
if (!token) {
return next({ name: 'login' })
}
}
next()
})
export default router export default router
+28 -2
View File
@@ -53,12 +53,10 @@ export const useAPIStore = defineStore('api', () => {
delete axios.defaults.headers.common['Authorization'] delete axios.defaults.headers.common['Authorization']
} }
// Users
const getAuthUser = () => { const getAuthUser = () => {
return axios.get(`${API_BASE_URL}/users/me`) return axios.get(`${API_BASE_URL}/users/me`)
} }
//Games
const getGames = (resetPagination = false) => { const getGames = (resetPagination = false) => {
if (resetPagination) { if (resetPagination) {
gameQueryParameters.value.page = 1 gameQueryParameters.value.page = 1
@@ -78,11 +76,39 @@ export const useAPIStore = defineStore('api', () => {
return axios.get(`${API_BASE_URL}/games?${queryParams}`) return axios.get(`${API_BASE_URL}/games?${queryParams}`)
} }
const getCurrentUserMatches = (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}/matches?${queryParams}`)
}
const getCurrentUserTransactions = (params = {}) => {
const queryParams = new URLSearchParams({
page: params.page || 1,
...(params.type && { type: params.type }),
...(params.date_from && { date_from: params.date_from }),
...(params.date_to && { date_to: params.date_to }),
}).toString()
return axios.get(`${API_BASE_URL}/users/${authStore.currentUserID}/transactions?${queryParams}`)
}
return { return {
postLogin, postLogin,
postLogout, postLogout,
getAuthUser, getAuthUser,
getGames, getGames,
getCurrentUserMatches,
getCurrentUserTransactions,
gameQueryParameters, gameQueryParameters,
} }
}) })

Some files were not shown because too many files have changed in this diff Show More