Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00cb5ba966 | ||
|
|
ceba5178f3 | ||
|
|
0fb64ff30c | ||
|
|
e0ca8e51a6 | ||
|
|
cd25915d92 | ||
|
|
bcfa5c06f7 | ||
|
|
3dcd8df4c7 | ||
|
|
46e3f0eef6 | ||
|
|
7e6766c726 | ||
|
|
1088749386 | ||
|
|
52cb893bd0 | ||
|
|
59fea64df5 | ||
|
|
a09639abcf | ||
|
|
2d2d0149d8 | ||
|
|
894a38bb27 | ||
|
|
432d3c0b88 | ||
|
|
1a7916a836 | ||
|
|
030b00059e | ||
|
|
a804f41956 | ||
|
|
25d21f7cf8 | ||
|
|
c3f8c3fff0 | ||
|
|
23d0ea1343 | ||
|
|
1dc2d25a41 | ||
|
|
7c8fdd6ea2 | ||
|
|
5e90de812b | ||
|
|
ee28333da8 | ||
|
|
e89b60c844 | ||
|
|
b9df0a4d4a | ||
|
|
3a7b1b9794 | ||
|
|
5880960c42 | ||
|
|
ef277a3cfd | ||
|
|
e23ca3d1ff | ||
|
|
e84dd993af | ||
|
|
bcdf542606 | ||
|
|
16f8d3bdfc | ||
|
|
45d8aa0035 | ||
|
|
c0af76abd3 | ||
|
|
ff16a57a97 | ||
|
|
cf238d5451 | ||
|
|
50ae1b7f1f | ||
|
|
5975208877 | ||
|
|
f3e8ffc400 | ||
|
|
1b03558f93 | ||
|
|
881a60a388 | ||
|
|
cf3ca26382 | ||
|
|
58e6615876 | ||
|
|
9bfa06b1ce | ||
|
|
2a438dce26 | ||
|
|
f38f08923f | ||
|
|
5f845b2ba7 | ||
|
|
54b4fc7cb1
|
||
|
|
3d23627433 | ||
|
|
4f391e3638 | ||
|
|
c456c9482f | ||
|
|
754a227608 | ||
|
|
83f92e670d | ||
|
|
59df3e2d37 | ||
|
|
39f55a12c0 | ||
|
|
edecef3af8 | ||
|
|
51f745a3dd
|
||
|
|
4b311c74eb
|
||
|
|
0ecdc30bd4
|
||
|
|
25c819c6e4
|
||
|
|
f60b25f29b
|
||
|
|
52887fc2eb
|
||
|
|
c4078d9b82
|
||
|
|
d29e44c61c
|
||
|
|
8f7102b335
|
||
|
|
a34027889f
|
||
|
|
87e0dc2518
|
||
|
|
79be3544cb
|
||
|
|
eafb4a4d1c
|
||
|
|
08c20acdf4
|
||
|
|
ab231ee94f
|
||
|
|
ceb3d735ad
|
||
|
|
5924d08f73
|
||
|
|
3ec6f8971c
|
||
|
|
e68f1357cc
|
||
|
|
cfda949ea6
|
||
|
|
c68cda350e
|
||
|
|
b7daa4610b | ||
|
|
329bbcc1d3 | ||
|
|
b35cac3b2c | ||
|
|
b5c3c552da | ||
|
|
14dd4b3a4e | ||
|
|
0d0f7569a6 | ||
|
|
09c4732e19 | ||
|
|
d4361121d8
|
||
|
|
ef0bdab0d3 | ||
|
|
5295d0620f | ||
|
|
d8cdbbf2ad | ||
|
|
160489b080 | ||
|
|
a0bbd8a69d | ||
|
|
943a5dd898 | ||
|
|
8912029686 | ||
|
|
0bf96fc948 | ||
|
|
79310fa63e | ||
|
|
83adbcf2f1 | ||
|
|
00666a16b2 | ||
|
|
7a6fbb4ba3 | ||
|
|
f3f6c6e564 | ||
|
|
d8e20dfc67 | ||
|
|
1939822e9b | ||
|
|
c0b77aebb1 | ||
|
|
ea15f5e28a | ||
|
|
8505d207e6 | ||
|
|
e54ec58f0c | ||
|
|
e833e403f7 | ||
|
|
5c1fc11975 | ||
|
|
b59640c858 | ||
|
|
e8ccea4e42 | ||
|
|
2b161e2099
|
@@ -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
|
||||||
@@ -22,3 +22,5 @@
|
|||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
composer.lock
|
||||||
@@ -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([
|
||||||
@@ -47,12 +54,24 @@ class AuthController extends Controller
|
|||||||
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,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CoinPurchase;
|
||||||
|
use App\Models\CoinTransaction;
|
||||||
|
use App\Models\CoinTransactionType;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class CoinPurchaseController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'euros' => 'required|numeric|min:1|max:100',
|
||||||
|
'payment_type' => 'required|in:MBWAY,MB,VISA,PAYPAL',
|
||||||
|
'payment_reference' => [
|
||||||
|
'required',
|
||||||
|
function ($attribute, $value, $fail) use ($request) {
|
||||||
|
$type = $request->payment_type;
|
||||||
|
$regex = null;
|
||||||
|
|
||||||
|
switch ($type) {
|
||||||
|
case 'MBWAY': $regex = '/^9[0-9]{8}$/'; break;
|
||||||
|
case 'MB': $regex = '/^[0-9]{5}-[0-9]{9}$/'; break;
|
||||||
|
case 'VISA': $regex = '/^4[0-9]{15}$/'; break;
|
||||||
|
case 'PAYPAL':
|
||||||
|
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$fail("Invalid PayPal email.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($regex && !preg_match($regex, $value)) {
|
||||||
|
$fail("Invalid format for $type.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$coinsAmount = $validated['euros'] * 10;
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($user, $validated, $coinsAmount) {
|
||||||
|
|
||||||
|
$type = CoinTransactionType::firstOrCreate(['name' => 'Coin purchase'], ['type' => 'C']);
|
||||||
|
|
||||||
|
$transaction = CoinTransaction::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'coin_transaction_type_id' => $type->id,
|
||||||
|
'transaction_datetime' => now(),
|
||||||
|
'coins' => $coinsAmount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$purchase = CoinPurchase::create([
|
||||||
|
'purchase_datetime' => now(),
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'coin_transaction_id' => $transaction->id,
|
||||||
|
'euros' => $validated['euros'],
|
||||||
|
'payment_type' => $validated['payment_type'],
|
||||||
|
'payment_reference' => $validated['payment_reference'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user->increment('coins_balance', $coinsAmount);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Purchase successful',
|
||||||
|
'balance' => $user->coins_balance,
|
||||||
|
'transaction_id' => $transaction->id,
|
||||||
|
'purchase' => $purchase,
|
||||||
|
], 201);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,16 +4,27 @@ 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"]);
|
||||||
|
|
||||||
|
// Se não for Admin, mostra apenas os jogos do utilizador
|
||||||
|
if ($user->type !== 'A') {
|
||||||
|
$query->where(function ($q) use ($user) {
|
||||||
|
$q->where('player1_user_id', $user->id)
|
||||||
|
->orWhere('player2_user_id', $user->id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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,124 +41,166 @@ class GameController extends Controller
|
|||||||
$query->where("status", $request->status);
|
$query->where("status", $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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");
|
$query->orderBy("began_at", "desc");
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($query->paginate(15));
|
return response()->json($query->paginate(15));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request)
|
// --- MÉTODOS ADICIONADOS ---
|
||||||
|
|
||||||
|
// Host a new multiplayer Game
|
||||||
|
public function host(Request $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$request->validate([
|
||||||
"type" => "required|in:3,9",
|
'type' => 'required|in:3,9'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = Auth::user();
|
$user = $request->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();
|
|
||||||
|
|
||||||
|
// Check for active game
|
||||||
$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",
|
|
||||||
$user->id,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
->whereIn("status", ["Pending", "Playing"])
|
|
||||||
->exists();
|
|
||||||
|
|
||||||
if ($activeMatch || $activeGame) {
|
|
||||||
return response()->json(
|
|
||||||
["message" => "You already have an active game or match."],
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$game = Game::create([
|
|
||||||
"type" => $validated["type"],
|
|
||||||
"status" => "Pending",
|
|
||||||
"player1_user_id" => $user->id,
|
|
||||||
"player2_user_id" => null,
|
|
||||||
"winner_user_id" => null,
|
|
||||||
"loser_user_id" => null,
|
|
||||||
"began_at" => now(),
|
|
||||||
"player1_points" => 0,
|
|
||||||
"player2_points" => 0,
|
|
||||||
"custom" => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json(
|
|
||||||
[
|
|
||||||
"message" => "Multiplayer game room created",
|
|
||||||
"game" => $game,
|
|
||||||
],
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function join(Game $game)
|
|
||||||
{
|
|
||||||
$user = Auth::user();
|
|
||||||
|
|
||||||
if ($game->player1_user_id == $user->id) {
|
|
||||||
return response()->json(
|
|
||||||
[
|
|
||||||
"message" => "Waiting for opponent...",
|
|
||||||
"game" => $game,
|
|
||||||
],
|
|
||||||
200,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($game->status !== "Pending") {
|
|
||||||
return response()->json(
|
|
||||||
["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);
|
->orWhere("player2_user_id", $user->id);
|
||||||
})
|
})
|
||||||
->whereIn("status", ["Pending", "Playing"])
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
->exists();
|
->exists();
|
||||||
|
|
||||||
if ($activeGame) {
|
if ($activeGame) {
|
||||||
return response()->json(
|
return response()->json(["message" => "You already have an active game."], 400);
|
||||||
["message" => "You are already in another active game."],
|
}
|
||||||
400,
|
|
||||||
);
|
$game = Game::create([
|
||||||
|
'type' => $request->type,
|
||||||
|
'status' => 'Pending',
|
||||||
|
'player1_user_id' => $user->id,
|
||||||
|
'player2_user_id' => self::GHOST_ID, // Ghost ID = 1 (Waiting for player)
|
||||||
|
'began_at' => now(),
|
||||||
|
'player1_points' => 0,
|
||||||
|
'player2_points' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($game, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// List open games (Available to join)
|
||||||
|
public function open(Request $request)
|
||||||
|
{
|
||||||
|
$type = $request->query('type', '9');
|
||||||
|
$GHOST_ID = 1;
|
||||||
|
|
||||||
|
$games = Game::where('status', 'Pending')
|
||||||
|
->where('player2_user_id', $GHOST_ID) // Games waiting for P2
|
||||||
|
->where('type', $type)
|
||||||
|
->with('player1')
|
||||||
|
->orderBy('began_at', 'asc')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return response()->json($games);
|
||||||
|
}
|
||||||
|
// ---------------------------
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$this->authorize('create', Game::class);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
"type" => "required|in:3,9",
|
||||||
|
"player2_user_id" => "nullable|integer|exists:users,id",
|
||||||
|
"match_id" => "nullable|integer|exists:matches,id",
|
||||||
|
"status" => "nullable|in:Pending,Playing",
|
||||||
|
"began_at" => "nullable|date"
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = Auth::user();
|
||||||
|
|
||||||
|
$activeGame = Game::where(function ($q) use ($user) {
|
||||||
|
$q->where("player1_user_id", $user->id)
|
||||||
|
->orWhere("player2_user_id", $user->id);
|
||||||
|
})
|
||||||
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($activeGame) {
|
||||||
|
return response()->json(["message" => "You already have an active game."], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$player2Id = $request->input('player2_user_id', self::GHOST_ID);
|
||||||
|
|
||||||
|
$initialStatus = $request->input('status', 'Pending');
|
||||||
|
|
||||||
|
// If specific player 2 is provided, auto-start game
|
||||||
|
if ($player2Id !== self::GHOST_ID && !$request->has('status')) {
|
||||||
|
$initialStatus = 'Playing';
|
||||||
|
}
|
||||||
|
|
||||||
|
$game = Game::create([
|
||||||
|
"type" => $validated["type"],
|
||||||
|
"status" => $initialStatus,
|
||||||
|
"player1_user_id" => $user->id,
|
||||||
|
"player2_user_id" => $player2Id,
|
||||||
|
"winner_user_id" => null,
|
||||||
|
"loser_user_id" => null,
|
||||||
|
"match_id" => $request->input('match_id', null),
|
||||||
|
"began_at" => $request->input('began_at', now()),
|
||||||
|
"player1_points" => 0,
|
||||||
|
"player2_points" => 0,
|
||||||
|
"custom" => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
"message" => "Game created",
|
||||||
|
"game" => $game,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function join(Request $request, Game $game)
|
||||||
|
{
|
||||||
|
// Prevent joining if already full or started
|
||||||
|
if ($game->status !== 'Pending' || $game->player2_user_id !== self::GHOST_ID) {
|
||||||
|
return response()->json(['message' => 'Game is not available'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent joining your own game
|
||||||
|
if ($game->player1_user_id === $request->user()->id) {
|
||||||
|
return response()->json(['message' => 'Cannot join your own game'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if joining user has another active game
|
||||||
|
$activeGame = Game::where(function ($q) use ($request) {
|
||||||
|
$q->where("player1_user_id", $request->user()->id)
|
||||||
|
->orWhere("player2_user_id", $request->user()->id);
|
||||||
|
})
|
||||||
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($activeGame) {
|
||||||
|
return response()->json(["message" => "You already have an active game."], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$game->update([
|
$game->update([
|
||||||
"status" => "Playing",
|
'player2_user_id' => $request->user()->id,
|
||||||
"player2_user_id" => $user->id,
|
'status' => 'Playing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json(
|
return response()->json($game);
|
||||||
[
|
|
||||||
"message" => "Joined successfully! Game starts now.",
|
|
||||||
"game" => $game,
|
|
||||||
],
|
|
||||||
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 +223,40 @@ 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Game $game)
|
||||||
|
{
|
||||||
|
// Ensure only the host can delete, and ONLY if it's still Pending
|
||||||
|
if ($game->player1_user_id !== auth()->id()) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($game->status !== 'Pending') {
|
||||||
|
return response()->json(['message' => 'Cannot delete a game in progress'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$game->delete();
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,26 +3,36 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\MatchGame;
|
use App\Models\MatchGame;
|
||||||
use App\Models\Game;
|
use App\Models\CoinTransaction;
|
||||||
|
use App\Models\CoinTransactionType;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use App\Http\Requests\StoreMatchRequest;
|
use App\Http\Requests\StoreMatchRequest;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class MatchController extends Controller
|
class MatchController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$query = MatchGame::query()->with(['winner', 'player1', 'player2']);
|
$query = MatchGame::query()->with(["winner", "player1", "player2"]);
|
||||||
|
|
||||||
if ($request->has('type')) {
|
if ($request->has("type")) {
|
||||||
$query->where('type', $request->type);
|
$query->where("type", $request->type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->has('status')) {
|
if ($request->has("status")) {
|
||||||
$query->where('status', $request->status);
|
$query->where("status", $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
$query->orderBy('began_at', 'desc');
|
if ($request->has("pot") && in_array($request->pot, ["asc", "desc"])) {
|
||||||
|
$query->orderBy("stake", $request->pot);
|
||||||
|
} elseif (
|
||||||
|
$request->has("sort_direction") &&
|
||||||
|
in_array($request->sort_direction, ["asc", "desc"])
|
||||||
|
) {
|
||||||
|
$query->orderBy("began_at", $request->sort_direction);
|
||||||
|
} else {
|
||||||
|
$query->orderBy("began_at", "desc");
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json($query->paginate(15));
|
return response()->json($query->paginate(15));
|
||||||
}
|
}
|
||||||
@@ -30,105 +40,334 @@ class MatchController extends Controller
|
|||||||
public function store(StoreMatchRequest $request)
|
public function store(StoreMatchRequest $request)
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$user = Auth::user();
|
$user = $request->user();
|
||||||
|
$stake = $validated['stake'];
|
||||||
|
|
||||||
$activeMatch = MatchGame::where(function ($q) use ($user) {
|
if ($user->coins_balance < $stake) {
|
||||||
$q->where('player1_user_id', $user->id)
|
return response()->json(
|
||||||
->orWhere('player2_user_id', $user->id);
|
["message" => "Insufficient coin balance"],
|
||||||
})->whereIn('status', ['Pending', 'Playing'])->exists();
|
400,
|
||||||
|
);
|
||||||
$activeGame = Game::where(function ($q) use ($user) {
|
|
||||||
$q->where('player1_user_id', $user->id)
|
|
||||||
->orWhere('player2_user_id', $user->id);
|
|
||||||
})->whereIn('status', ['Pending', 'Playing'])->exists();
|
|
||||||
|
|
||||||
if ($activeMatch || $activeGame) {
|
|
||||||
return response()->json(['message' => 'You already have an active game or match.'], 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$activeMatch = MatchGame::where(function ($q) use ($user) {
|
||||||
|
$q->where("player1_user_id", $user->id)->orWhere(
|
||||||
|
"player2_user_id",
|
||||||
|
$user->id,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($activeMatch) {
|
||||||
|
return response()->json(['message' => 'You already have an active match.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($validated, $user, $stake) {
|
||||||
|
$GHOST_ID = 1;
|
||||||
|
|
||||||
|
$user->decrement("coins_balance", $stake);
|
||||||
|
|
||||||
|
$stakeType = CoinTransactionType::firstOrCreate(
|
||||||
|
["name" => "Match stake"],
|
||||||
|
["type" => "D"], // Debit
|
||||||
|
);
|
||||||
|
|
||||||
$match = MatchGame::create([
|
$match = MatchGame::create([
|
||||||
'type' => $validated['type'],
|
"type" => $validated["type"],
|
||||||
'status' => 'Pending',
|
"status" => "Pending",
|
||||||
'player1_user_id' => $user->id,
|
"player1_user_id" => $user->id,
|
||||||
'player2_user_id' => $user->id,
|
"player2_user_id" => $GHOST_ID,
|
||||||
'winner_user_id' => $user->id,
|
"winner_user_id" => $GHOST_ID,
|
||||||
'loser_user_id' => $user->id,
|
"loser_user_id" => $GHOST_ID,
|
||||||
'stake' => $validated['stake'],
|
"stake" => $stake,
|
||||||
'began_at' => now(),
|
"began_at" => now(),
|
||||||
'player1_marks' => 0,
|
"player1_marks" => 0,
|
||||||
'player2_marks' => 0,
|
"player2_marks" => 0,
|
||||||
'player1_points' => 0,
|
"player1_points" => 0,
|
||||||
'player2_points' => 0,
|
"player2_points" => 0,
|
||||||
'custom' => null
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
CoinTransaction::create([
|
||||||
'message' => 'Match created successfully',
|
"user_id" => $user->id,
|
||||||
'match' => $match
|
"match_id" => $match->id,
|
||||||
], 201);
|
"coin_transaction_type_id" => $stakeType->id,
|
||||||
|
"transaction_datetime" => now(),
|
||||||
|
"coins" => -$stake,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
"message" => "Match created successfully",
|
||||||
|
"match" => $match,
|
||||||
|
"balance" => $user->coins_balance,
|
||||||
|
],
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(MatchGame $match)
|
public function show(MatchGame $match)
|
||||||
{
|
{
|
||||||
$match->load(['player1', 'player2', 'winner']);
|
$match->load(["player1", "player2", "winner"]);
|
||||||
return response()->json($match);
|
return response()->json($match);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function join(MatchGame $match)
|
public function join(MatchGame $match)
|
||||||
{
|
{
|
||||||
$user = Auth::user();
|
$user = request()->user();
|
||||||
|
|
||||||
if ($match->player1_user_id == $user->id) {
|
if ($match->player1_user_id == $user->id) {
|
||||||
return response()->json(['message' => 'Waiting for opponent...', 'match' => $match]);
|
return response()->json(
|
||||||
|
["message" => "You cannot join your own match"],
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($match->status !== "Pending") {
|
||||||
|
return response()->json(
|
||||||
|
["message" => "Match is full or started"],
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($match->status !== 'Pending') {
|
if ($user->coins_balance < $match->stake) {
|
||||||
return response()->json(['message' => 'Match is full or started'], 400);
|
return response()->json(
|
||||||
|
["message" => "Insufficient coin balance to join this match"],
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$activeMatch = MatchGame::where(function ($q) use ($user) {
|
$activeMatch = MatchGame::where(function ($q) use ($user) {
|
||||||
$q->where('player1_user_id', $user->id)
|
$q->where("player1_user_id", $user->id)->orWhere(
|
||||||
->orWhere('player2_user_id', $user->id);
|
"player2_user_id",
|
||||||
})->whereIn('status', ['Pending', 'Playing'])->exists();
|
$user->id,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
|
->exists();
|
||||||
|
|
||||||
if ($activeMatch) {
|
if ($activeMatch) {
|
||||||
return response()->json(['message' => 'You are already in another active match.'], 400);
|
return response()->json(
|
||||||
|
["message" => "You are already in another active match."],
|
||||||
|
400,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($match, $user) {
|
||||||
|
$user->decrement("coins_balance", $match->stake);
|
||||||
|
|
||||||
|
$stakeType = CoinTransactionType::firstOrCreate(
|
||||||
|
["name" => "Match stake"],
|
||||||
|
["type" => "D"],
|
||||||
|
);
|
||||||
|
|
||||||
|
CoinTransaction::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'match_id' => $match->id,
|
||||||
|
'coin_transaction_type_id' => $stakeType->id,
|
||||||
|
'transaction_datetime' => now(),
|
||||||
|
'coins' => -$match->stake,
|
||||||
|
]);
|
||||||
|
|
||||||
$match->update([
|
$match->update([
|
||||||
'status' => 'Playing',
|
"status" => "Playing",
|
||||||
'player2_user_id' => $user->id
|
"player2_user_id" => $user->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'message' => 'Joined match successfully!',
|
"message" => "Joined match successfully!",
|
||||||
'match' => $match
|
"match" => $match,
|
||||||
|
"balance" => $user->coins_balance,
|
||||||
]);
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, MatchGame $match)
|
public function update(Request $request, MatchGame $match)
|
||||||
{
|
{
|
||||||
if ($match->status == 'Ended') {
|
if ($match->status == "Ended") {
|
||||||
return response()->json(['message' => 'Match already ended'], 400);
|
return response()->json(["message" => "Match already ended"], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'status' => 'in:Playing,Ended,Interrupted',
|
"status" => "in:Playing,Ended,Interrupted",
|
||||||
'winner_user_id' => 'exists:users,id',
|
"winner_user_id" => "exists:users,id",
|
||||||
'loser_user_id' => 'exists:users,id',
|
"loser_user_id" => "exists:users,id",
|
||||||
'player1_marks' => 'integer',
|
"player1_marks" => "integer",
|
||||||
'player2_marks' => 'integer',
|
"player2_marks" => "integer",
|
||||||
'player1_points' => 'integer',
|
"player1_points" => "integer",
|
||||||
'player2_points' => 'integer',
|
"player2_points" => "integer",
|
||||||
'total_time' => 'numeric'
|
"total_time" => "numeric",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($match, $data) {
|
||||||
if (isset($data['status']) && $data['status'] === 'Ended') {
|
if (isset($data['status']) && $data['status'] === 'Ended') {
|
||||||
$data['ended_at'] = now();
|
$data['ended_at'] = now();
|
||||||
}
|
}
|
||||||
|
|
||||||
$match->update($data);
|
$match->update($data);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$match->status === "Ended" &&
|
||||||
|
$match->stake > 0 &&
|
||||||
|
$match->winner_user_id
|
||||||
|
) {
|
||||||
|
$payoutType = CoinTransactionType::firstOrCreate(
|
||||||
|
["name" => "Match payout"],
|
||||||
|
["type" => "C"], // 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);
|
return response()->json($match);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function host(Request $request)
|
||||||
|
{
|
||||||
|
$request->validate([
|
||||||
|
"type" => "required|in:3,9",
|
||||||
|
"stake" => "required|integer|min:0",
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
$stake = $request->stake;
|
||||||
|
|
||||||
|
if ($user->coins_balance < $stake) {
|
||||||
|
return response()->json(
|
||||||
|
["message" => "Insufficient coin balance"],
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeMatch = MatchGame::where(function ($q) use ($user) {
|
||||||
|
$q->where("player1_user_id", $user->id)->orWhere(
|
||||||
|
"player2_user_id",
|
||||||
|
$user->id,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->whereIn("status", ["Pending", "Playing"])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($activeMatch) {
|
||||||
|
return response()->json(
|
||||||
|
["message" => "You already have an active match."],
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($request, $user, $stake) {
|
||||||
|
$GHOST_ID = 1;
|
||||||
|
|
||||||
|
$user->decrement("coins_balance", $stake);
|
||||||
|
|
||||||
|
$stakeType = CoinTransactionType::firstOrCreate(
|
||||||
|
["name" => "Match stake"],
|
||||||
|
["type" => "D"], // Debit
|
||||||
|
);
|
||||||
|
|
||||||
|
$match = MatchGame::create([
|
||||||
|
'type' => $request->type,
|
||||||
|
'status' => 'Pending',
|
||||||
|
'player1_user_id' => $user->id,
|
||||||
|
'player2_user_id' => $GHOST_ID,
|
||||||
|
'winner_user_id' => null,
|
||||||
|
'loser_user_id' => null,
|
||||||
|
'stake' => $stake,
|
||||||
|
'began_at' => now(),
|
||||||
|
'player1_marks' => 0, 'player2_marks' => 0,
|
||||||
|
'player1_points' => 0, 'player2_points' => 0,
|
||||||
|
|
||||||
|
]);
|
||||||
|
|
||||||
|
CoinTransaction::create([
|
||||||
|
"user_id" => $user->id,
|
||||||
|
"match_id" => $match->id,
|
||||||
|
"coin_transaction_type_id" => $stakeType->id,
|
||||||
|
"transaction_datetime" => now(),
|
||||||
|
"coins" => -$stake,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($match, 201);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function open(Request $request)
|
||||||
|
{
|
||||||
|
$query = MatchGame::where('status', 'Pending')
|
||||||
|
->where(function($q) {
|
||||||
|
$q->whereNull('player2_user_id')
|
||||||
|
->orWhere('player2_user_id', 1); // 1 = Ghost ID
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($request->has('type')) {
|
||||||
|
$query->where('type', $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches = $query->with('player1:id,nickname')
|
||||||
|
->orderBy('began_at', 'desc')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
return response()->json($matches);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FIX: Safely Delete Match by removing dependencies first ---
|
||||||
|
public function destroy(MatchGame $match)
|
||||||
|
{
|
||||||
|
$user = request()->user();
|
||||||
|
|
||||||
|
if ($match->player1_user_id !== $user->id) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($match->status !== 'Pending') {
|
||||||
|
return response()->json(['message' => 'Cannot cancel a match that has already started'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($match, $user) {
|
||||||
|
// 1. Delete associated Games first (The FK constraint cause)
|
||||||
|
DB::table('games')->where('match_id', $match->id)->delete();
|
||||||
|
|
||||||
|
// 2. Unlink existing Coin Transactions (Set match_id to NULL)
|
||||||
|
CoinTransaction::where('match_id', $match->id)->update(['match_id' => null]);
|
||||||
|
|
||||||
|
// 3. Process Refund
|
||||||
|
if ($match->stake > 0) {
|
||||||
|
$user->increment('coins_balance', $match->stake);
|
||||||
|
|
||||||
|
$refundType = CoinTransactionType::firstOrCreate(
|
||||||
|
['name' => 'Refund'],
|
||||||
|
['type' => 'C']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create Refund Transaction with NO LINK to the deleted match
|
||||||
|
CoinTransaction::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'match_id' => null, // IMPORTANT: Must be null
|
||||||
|
'coin_transaction_type_id' => $refundType->id,
|
||||||
|
'transaction_datetime' => now(),
|
||||||
|
'coins' => $match->stake,
|
||||||
|
'custom' => "Refund for Match #{$match->id}"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Finally delete the match
|
||||||
|
$match->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Match canceled and refunded',
|
||||||
|
'balance' => $user->coins_balance
|
||||||
|
]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,136 @@
|
|||||||
|
<?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');
|
||||||
|
|
||||||
|
$leaderboard = User::where('type', 'P')
|
||||||
|
->withCount(['wonGames' => function ($query) use ($type) {
|
||||||
|
if ($type) {
|
||||||
|
$query->where('type', $type);
|
||||||
|
}
|
||||||
|
}])
|
||||||
|
->orderBy('won_games_count', 'desc')
|
||||||
|
->paginate(10);
|
||||||
|
|
||||||
|
$leaderboard->through(function ($user) {
|
||||||
|
return [
|
||||||
|
'id' => $user->id,
|
||||||
|
'nickname' => $user->nickname,
|
||||||
|
'photo_avatar_filename' => $user->photo_avatar_filename,
|
||||||
|
'won_games_count' => $user->won_games_count,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($leaderboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\CoinTransaction;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TransactionController extends Controller
|
||||||
|
{
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
|
||||||
|
$this->authorize('viewAny', CoinTransaction::class);
|
||||||
|
|
||||||
|
$query = CoinTransaction::with('user:id,nickname,email', 'type:id,name');
|
||||||
|
|
||||||
|
if ($request->has('sort_coins')) {
|
||||||
|
$direction = $request->get('sort_coins') === 'asc' ? 'asc' : 'desc';
|
||||||
|
$query->orderBy('coins', $direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateDirection = $request->get('sort_datetime') === 'asc' ? 'asc' : 'desc';
|
||||||
|
$query->orderBy('transaction_datetime', $dateDirection);
|
||||||
|
|
||||||
|
|
||||||
|
$transactions = $query->paginate(15);
|
||||||
|
|
||||||
|
return response()->json($transactions);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Game;
|
use App\Models\Game;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\MatchGame;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -17,18 +18,23 @@ 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
|
if ($request->has("type")) {
|
||||||
if ($request->has('type')) {
|
$query->where("type", $request->type);
|
||||||
$query->where('type', $request->type);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->has('search')) {
|
if ($request->has("blocked")) {
|
||||||
|
$query->where("blocked", $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has("search")) {
|
||||||
$query->where(function ($q) use ($request) {
|
$query->where(function ($q) use ($request) {
|
||||||
$q->where('name', 'like', '%'.$request->search.'%')
|
$q->where("name", "like", "%" . $request->search . "%")
|
||||||
->orWhere('email', 'like', '%'.$request->search.'%')
|
->orWhere("email", "like", "%" . $request->search . "%")
|
||||||
->orWhere('nickname', 'like', '%'.$request->search.'%');
|
->orWhere("nickname", "like", "%" . $request->search . "%");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,15 +47,16 @@ 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',
|
"email" => "required|email|unique:users,email",
|
||||||
'email' => 'required|email|unique:users,email',
|
"password" => "required|string|min:3",
|
||||||
'password' => 'required|string|min:3',
|
"type" => "required|in:A,U",
|
||||||
'type' => 'required|in:A,U',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$validated['password'] = Hash::make($validated['password']);
|
$validated["password"] = Hash::make($validated["password"]);
|
||||||
|
|
||||||
$user = User::create($validated);
|
$user = User::create($validated);
|
||||||
|
|
||||||
@@ -73,17 +80,19 @@ 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,
|
||||||
'nickname' => $user->nickname,
|
"nickname" => $user->nickname,
|
||||||
'photo_avatar_filename' => $user->photo_avatar_filename,
|
"photo_avatar_filename" => $user->photo_avatar_filename,
|
||||||
'type' => $user->type,
|
"type" => $user->type,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,36 +101,24 @@ 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" => [
|
||||||
'sometimes',
|
"sometimes",
|
||||||
'string',
|
"string",
|
||||||
'max:20',
|
"max:20",
|
||||||
Rule::unique('users')->ignore($user->id),
|
Rule::unique("users")->ignore($user->id),
|
||||||
],
|
],
|
||||||
'email' => [
|
"email" => [
|
||||||
'sometimes',
|
"sometimes",
|
||||||
'email',
|
"email",
|
||||||
Rule::unique('users')->ignore($user->id),
|
Rule::unique("users")->ignore($user->id),
|
||||||
],
|
],
|
||||||
'password' => 'sometimes|string|min:3',
|
"password" => "sometimes|string|min:3",
|
||||||
'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,55 +130,253 @@ 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(
|
||||||
if ($currentUser->id === $user->id && $currentUser->type === 'A') {
|
"player2_user_id",
|
||||||
if ($user->type === 'A') {
|
$user->id,
|
||||||
return response()->json(
|
|
||||||
['message' => 'Admins cannot delete their own account'],
|
|
||||||
403,
|
|
||||||
);
|
);
|
||||||
}
|
})->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";
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->delete();
|
return response()->json(["message" => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['message' => 'User deleted successfully']);
|
/**
|
||||||
|
* GET /api/users/{user}/games
|
||||||
|
* Histórico de Partidas Individuais (Cartas)
|
||||||
|
*/
|
||||||
|
public function getGames(Request $request, User $user)
|
||||||
|
{
|
||||||
|
$this->authorize("view", $user);
|
||||||
|
|
||||||
|
// Define a query base para GAMES
|
||||||
|
$query = \App\Models\Game::query()
|
||||||
|
->where(function ($q) use ($user) {
|
||||||
|
$q->where("player1_user_id", $user->id)->orWhere(
|
||||||
|
"player2_user_id",
|
||||||
|
$user->id,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->with(["winner", "player1", "player2"]);
|
||||||
|
|
||||||
|
if ($request->has("type")) {
|
||||||
|
$query->where("type", $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has("status")) {
|
||||||
|
$query->where("status", $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date filtering
|
||||||
|
if ($request->has("date_from")) {
|
||||||
|
$query->whereDate("began_at", ">=", $request->date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has("date_to")) {
|
||||||
|
$query->whereDate("began_at", "<=", $request->date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible sorting
|
||||||
|
$sortColumn = $request->get("sort_by", "began_at");
|
||||||
|
$sortDirection = $request->get("sort_direction", "desc");
|
||||||
|
|
||||||
|
// Whitelist sortable columns for security
|
||||||
|
$allowedColumns = [
|
||||||
|
"began_at",
|
||||||
|
"ended_at",
|
||||||
|
"total_time",
|
||||||
|
"total_points",
|
||||||
|
];
|
||||||
|
if (in_array($sortColumn, $allowedColumns)) {
|
||||||
|
$query->orderBy($sortColumn, $sortDirection);
|
||||||
|
} else {
|
||||||
|
$query->orderBy("began_at", $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
$games = $query->paginate(10);
|
||||||
|
|
||||||
|
$games->getCollection()->transform(function ($game) use ($user) {
|
||||||
|
if ($game->winner_user_id == $user->id) {
|
||||||
|
$game->outcome = "win";
|
||||||
|
} elseif ($game->is_draw) {
|
||||||
|
$game->outcome = "draw";
|
||||||
|
} else {
|
||||||
|
$game->outcome = "loss";
|
||||||
|
}
|
||||||
|
return $game;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($games);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/users/{user}/matches
|
* GET /api/users/{user}/matches
|
||||||
* Histórico de Jogos Multiplayer do Utilizador
|
* Histórico de Matches (Apostas / Marcas)
|
||||||
*/
|
*/
|
||||||
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') {
|
$query = MatchGame::query()
|
||||||
return response()->json(['message' => 'Unauthorized'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$matches = Game::query()
|
|
||||||
->where(function ($q) use ($user) {
|
->where(function ($q) use ($user) {
|
||||||
$q->where('player1_user_id', $user->id)->orWhere(
|
$q->where("player1_user_id", $user->id)->orWhere(
|
||||||
'player2_user_id',
|
"player2_user_id",
|
||||||
$user->id,
|
$user->id,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
->with(['winner', 'player1', 'player2'])
|
->with(["winner", "player1", "player2"]);
|
||||||
->orderBy('began_at', 'desc')
|
|
||||||
->paginate(10);
|
if ($request->has("type")) {
|
||||||
|
$query->where("type", $request->type);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has("status")) {
|
||||||
|
$query->where("status", $request->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date filtering
|
||||||
|
if ($request->has("date_from")) {
|
||||||
|
$query->whereDate("began_at", ">=", $request->date_from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->has("date_to")) {
|
||||||
|
$query->whereDate("began_at", "<=", $request->date_to);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible sorting
|
||||||
|
$sortColumn = $request->get("sort_by", "began_at");
|
||||||
|
$sortDirection = $request->get("sort_direction", "desc");
|
||||||
|
|
||||||
|
// Whitelist sortable columns for security
|
||||||
|
$allowedColumns = ["began_at", "ended_at", "stake", "total_time"];
|
||||||
|
if (in_array($sortColumn, $allowedColumns)) {
|
||||||
|
$query->orderBy($sortColumn, $sortDirection);
|
||||||
|
} else {
|
||||||
|
$query->orderBy("began_at", $sortDirection);
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches = $query->paginate(10);
|
||||||
|
|
||||||
|
$matches->getCollection()->transform(function ($match) use ($user) {
|
||||||
|
if ($match->winner_user_id == $user->id) {
|
||||||
|
$match->outcome = "win";
|
||||||
|
} elseif (
|
||||||
|
$match->winner_user_id &&
|
||||||
|
$match->winner_user_id != $user->id
|
||||||
|
) {
|
||||||
|
$match->outcome = "loss";
|
||||||
|
} else {
|
||||||
|
$match->outcome = "interrupted"; // Matches geralmente não empatam (alguém chega a 4 marcas)
|
||||||
|
}
|
||||||
|
return $match;
|
||||||
|
});
|
||||||
|
|
||||||
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class CoinTransactionType extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
// FIX: Disable auto-timestamps because the table doesn't have updated_at/created_at columns
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'type',
|
||||||
|
'description' // Include description if your table has it
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|
||||||
@@ -30,11 +29,22 @@ class Game extends Model
|
|||||||
'match_id'
|
'match_id'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'began_at' => 'datetime',
|
||||||
|
'ended_at' => 'datetime',
|
||||||
|
'is_draw' => 'boolean',
|
||||||
|
];
|
||||||
|
|
||||||
public function winner()
|
public function winner()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, "winner_user_id");
|
return $this->belongsTo(User::class, "winner_user_id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loser()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, "loser_user_id");
|
||||||
|
}
|
||||||
|
|
||||||
public function player1()
|
public function player1()
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'player1_user_id');
|
return $this->belongsTo(User::class, 'player1_user_id');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\CoinTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
|
class CoinTransactionPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user can view any models.
|
||||||
|
*/
|
||||||
|
public function viewAny(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->type === 'A' ? true : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($game->status === "Pending") {
|
||||||
|
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 !== "Pending" ||
|
||||||
|
$game->player1_user_id === $user->id
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($game->player2_user_id !== null && $game->player2_user_id !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resign(User $user, Game $game): bool
|
||||||
|
{
|
||||||
|
if ($user->type === "A") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
$game->status !== "Playing" ||
|
||||||
|
($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 (!in_array($game->status, ['Playing', 'Pending'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ 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 App\Http\Controllers\TransactionController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1')->group(function () {
|
Route::prefix('v1')->group(function () {
|
||||||
@@ -12,54 +15,65 @@ 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
|
||||||
Route::prefix('/users')->group(function () {
|
Route::prefix('/users')->group(function () {
|
||||||
Route::get('/{user}', [UserController::class, 'show']);
|
Route::get('/{user}', [UserController::class, 'show']);
|
||||||
Route::get('/{user}/matches', [
|
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
|
||||||
UserController::class,
|
Route::get('/{user}/games', [UserController::class, 'getGames']);
|
||||||
'getMatches',
|
Route::get('/{user}/matches', [UserController::class, 'getMatches']);
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Game Resources
|
|
||||||
Route::prefix('games')->group(function () {
|
Route::prefix('games')->group(function () {
|
||||||
Route::apiResource('/', GameController::class)->parameters([
|
Route::post('host', [GameController::class, 'host']);
|
||||||
'' => 'game',
|
Route::get('open', [GameController::class, 'open']);
|
||||||
]);
|
Route::get('/', [GameController::class, 'index']);
|
||||||
Route::post('/{game}/join', [GameController::class, 'join']);
|
Route::get('/{game}', [GameController::class, 'show']);
|
||||||
|
Route::post('/', [GameController::class, 'store']);
|
||||||
|
Route::put('/{game}', [GameController::class, 'update']);
|
||||||
|
Route::delete('/{game}', [GameController::class, 'destroy']);
|
||||||
Route::post('/{game}/resign', [GameController::class, 'resign']);
|
Route::post('/{game}/resign', [GameController::class, 'resign']);
|
||||||
|
Route::post('/{game}/join', [GameController::class, 'join']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Match Resources
|
|
||||||
Route::prefix('matches')->group(function () {
|
Route::prefix('matches')->group(function () {
|
||||||
Route::apiResource('/', MatchController::class)->parameters([
|
Route::post('host', [MatchController::class, 'host']);
|
||||||
'' => 'match',
|
Route::get('open', [MatchController::class, 'open']);
|
||||||
]);
|
Route::post('/{match}/join', [MatchController::class, 'join']);
|
||||||
Route::post('/{match}/join', [
|
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit
|
||||||
MatchController::class,
|
Route::delete('/{match}', [MatchController::class, 'destroy']);
|
||||||
'join',
|
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin Routes
|
// Admin Routes
|
||||||
Route::middleware('user.type:A')
|
Route::middleware('user.type:A')
|
||||||
->prefix('admin')
|
->prefix('admin')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
|
Route::get('/statistics', [StatisticsController::class, 'getAdminStats']);
|
||||||
|
Route::get('/transactions', [TransactionController::class, 'index']);
|
||||||
|
Route::get('/games', [GameController::class, 'index']);
|
||||||
|
Route::get('/matches', [MatchController::class, 'index']);
|
||||||
Route::prefix('users')->group(function () {
|
Route::prefix('users')->group(function () {
|
||||||
Route::get('/', [UserController::class, 'index']);
|
Route::get('/', [UserController::class, 'index']);
|
||||||
Route::post('/', [UserController::class, 'store']);
|
Route::post('/', [UserController::class, 'store']);
|
||||||
|
|||||||
@@ -1,9 +1,84 @@
|
|||||||
### 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": "p[email protected]",
|
"email": "a1@mail.pt",
|
||||||
"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/games
|
||||||
|
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 coins
|
||||||
|
GET http://localhost:8000/api/v1/users/me/coins
|
||||||
|
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}}
|
||||||
|
|
||||||
|
### post coin-purchases
|
||||||
|
POST http://localhost:8000/api/v1/coin-purchases
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
{
|
||||||
|
"euros": 1,
|
||||||
|
"payment_type": "MBWAY",
|
||||||
|
"payment_reference": "912345678"
|
||||||
|
}
|
||||||
|
|
||||||
|
### Get leaderboard
|
||||||
|
GET http://localhost:8000/api/v1/leaderboard
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
### Get me stats
|
||||||
|
GET http://localhost:8000/api/v1/statistics/me
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### Get public stats
|
||||||
|
GET http://localhost:8000/api/v1/statistics/public
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
|
||||||
|
### Get admin stats
|
||||||
|
GET http://localhost:8000/api/v1/admin/statistics
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
### Get admin stats
|
||||||
|
GET http://localhost:8000/api/v1/admin/users
|
||||||
|
Content-Type: application/json
|
||||||
|
Accept: application/json
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
@@ -12,7 +12,7 @@ post {
|
|||||||
|
|
||||||
body:json {
|
body:json {
|
||||||
{
|
{
|
||||||
"email":"a1@mail.pt",
|
"email":"p[email protected]",
|
||||||
"password":"123"
|
"password":"123"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
meta {
|
||||||
|
name: Create Game
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
url: {{api_url}}/games
|
||||||
|
body: none
|
||||||
|
auth: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
settings {
|
||||||
|
encodeUrl: true
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -16,14 +16,15 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.17",
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
"@tanstack/vue-table": "^8.21.3",
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
"@vueuse/core": "^14.0.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"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.7.0",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"tailwindcss": "^4.1.17",
|
"tailwindcss": "^4.1.17",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 242 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 312 KiB |
@@ -3,8 +3,7 @@
|
|||||||
<nav class="max-w-full p-5 flex flex-row justify-between align-middle">
|
<nav class="max-w-full p-5 flex flex-row justify-between align-middle">
|
||||||
<div class="align-middle text-xl">
|
<div class="align-middle text-xl">
|
||||||
<RouterLink to="/"> {{ pageTitle }} </RouterLink>
|
<RouterLink to="/"> {{ pageTitle }} </RouterLink>
|
||||||
<span class="text-xs" v-if="authStore.currentUser">
|
<span class="text-xs" v-if="authStore.currentUser"> ({{ authStore.currentUser?.name }})
|
||||||
({{ authStore.currentUser?.name }})
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
|
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
|
||||||
@@ -17,26 +16,21 @@
|
|||||||
</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 pageTitle = ref(`DAD 2025/26`)
|
||||||
const year = new Date().getFullYear()
|
|
||||||
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 +38,13 @@ 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()
|
// REMOVED - The WebSocket for multiplayer is handled separately in the WebSocketStore
|
||||||
|
// socketStore.handleConnection()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style></style>`
|
<style></style>
|
||||||
|
|||||||
@@ -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,83 @@
|
|||||||
|
<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"
|
||||||
|
:player-name="playerName"
|
||||||
|
:opponent-name="opponentName"
|
||||||
|
: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 },
|
||||||
|
playerName: { type: String, default: 'You' },
|
||||||
|
opponentName: { type: String, default: 'Bot' },
|
||||||
|
currentTurn: {
|
||||||
|
type: String,
|
||||||
|
default: 'player',
|
||||||
|
validator: (value) => ['player', 'opponent'].includes(value),
|
||||||
|
},
|
||||||
|
isMyTurn: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['play-card'])
|
||||||
|
|
||||||
|
const handleCardClick = (payload) => {
|
||||||
|
if (!props.isMyTurn) return
|
||||||
|
emit('play-card', payload.card)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<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),
|
||||||
|
},
|
||||||
|
isDisabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
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,177 @@
|
|||||||
|
<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',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- You can replace the above line with the following if you want to show names instead -->
|
||||||
|
{{ currentTurn === 'player' ? playerName + "'s Turn" : opponentName + "'s Turn" }}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
@@ -2,31 +2,29 @@
|
|||||||
<div>
|
<div>
|
||||||
<NavigationMenu>
|
<NavigationMenu>
|
||||||
<NavigationMenuList class="justify-around gap-20">
|
<NavigationMenuList class="justify-around gap-20">
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger>Testing</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent>
|
|
||||||
<li>
|
|
||||||
<NavigationMenuLink as-child>
|
|
||||||
<RouterLink to="/testing/laravel">Laravel</RouterLink>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
<NavigationMenuLink as-child>
|
|
||||||
<RouterLink to="/testing/websockets">Web Sockets</RouterLink>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</li>
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
<NavigationMenuItem v-if="!userLoggedIn">
|
<NavigationMenuItem v-if="!userLoggedIn">
|
||||||
<NavigationMenuLink>
|
<NavigationMenuLink>
|
||||||
<RouterLink to="/login">Login</RouterLink>
|
<RouterLink to="/login">Login</RouterLink>
|
||||||
</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="userStore.coins > 0" href="/coins-purchase"
|
||||||
|
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">
|
||||||
|
{{ userStore.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 +32,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { useBiscaStore } from '@/stores/bisca'
|
||||||
import {
|
import {
|
||||||
NavigationMenu,
|
NavigationMenu,
|
||||||
NavigationMenuContent,
|
NavigationMenuContent,
|
||||||
@@ -42,14 +41,42 @@ import {
|
|||||||
NavigationMenuList,
|
NavigationMenuList,
|
||||||
NavigationMenuTrigger,
|
NavigationMenuTrigger,
|
||||||
} from '@/components/ui/navigation-menu'
|
} from '@/components/ui/navigation-menu'
|
||||||
import router from '@/router';
|
import router from '@/router'
|
||||||
|
import { 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 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => userLoggedIn,
|
||||||
|
async (isLoggedIn) => {
|
||||||
|
if (isLoggedIn) {
|
||||||
|
await userStore.fetchCoins()
|
||||||
|
} else {
|
||||||
|
userStore.coins = 0
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { Label } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
for: { type: String, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
data-slot="label"
|
||||||
|
v-bind="delegatedProps"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Label } from "./Label.vue";
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
import { reactiveOmit } from "@vueuse/core";
|
||||||
|
import { SwitchRoot, SwitchThumb, useForwardPropsEmits } from "reka-ui";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
defaultValue: { type: Boolean, required: false },
|
||||||
|
modelValue: { type: [Boolean, null], required: false },
|
||||||
|
disabled: { type: Boolean, required: false },
|
||||||
|
id: { type: String, required: false },
|
||||||
|
value: { type: String, required: false },
|
||||||
|
asChild: { type: Boolean, required: false },
|
||||||
|
as: { type: null, required: false },
|
||||||
|
name: { type: String, required: false },
|
||||||
|
required: { type: Boolean, required: false },
|
||||||
|
class: { type: null, required: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emits = defineEmits(["update:modelValue"]);
|
||||||
|
|
||||||
|
const delegatedProps = reactiveOmit(props, "class");
|
||||||
|
|
||||||
|
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
v-slot="slotProps"
|
||||||
|
data-slot="switch"
|
||||||
|
v-bind="forwarded"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot name="thumb" v-bind="slotProps" />
|
||||||
|
</SwitchThumb>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as Switch } from "./Switch.vue";
|
||||||
@@ -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,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));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
<template>
|
||||||
|
<div class="relative min-h-screen overflow-hidden bg-green-900">
|
||||||
|
<div v-if="shouldShowBoard && !gameOver" class="absolute top-4 right-4 z-50">
|
||||||
|
<button @click="handleSurrender"
|
||||||
|
class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all transform hover:scale-105 flex items-center gap-2">
|
||||||
|
<span>🏳️</span> Surrender
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!wsStore.connected && hasConnectedOnce"
|
||||||
|
class="absolute top-4 left-4 z-50 flex items-center gap-2 bg-gray-800 px-4 py-2 rounded-lg border border-gray-700 shadow-xl">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse"></div>
|
||||||
|
<span class="text-white text-sm">Reconnecting...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="shouldShowBoard && !gameOver && isMyTurn" class="absolute top-4 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<div class="bg-gray-800 px-6 py-3 rounded-lg border-2 shadow-xl transition-colors duration-300"
|
||||||
|
:class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-6 h-6" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke-width="2" />
|
||||||
|
<polyline points="12 6 12 12 16 14" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<span :class="['text-3xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">
|
||||||
|
{{ timeLeft }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="shouldShowBoard">
|
||||||
|
<GameBoard :trump-card="trumpCard" :cards-remaining="deckCount + (trumpCard ? 1 : 0)" :player-hand="visibleHand"
|
||||||
|
:opponent-hand="opponentHand" :player-score="playerScore" :opponent-score="opponentScore"
|
||||||
|
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :current-turn="currentTurnLabel"
|
||||||
|
:current-trick="displayedTrick" :is-my-turn="isMyTurn" @play-card="handlePlayCard" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!gameOver" class="flex h-screen items-center justify-center bg-green-900 text-white">
|
||||||
|
<div class="text-center p-8 bg-green-800/50 rounded-2xl border border-green-700 shadow-2xl backdrop-blur-sm">
|
||||||
|
<div class="relative mx-auto mb-6 h-16 w-16">
|
||||||
|
<div class="absolute inset-0 rounded-full border-4 border-green-600"></div>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-2xl font-bold mb-2">
|
||||||
|
Waiting for Opponent...
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-green-100 mb-8 max-w-sm">
|
||||||
|
Share the game code or wait for a player to join your lobby.
|
||||||
|
</p>
|
||||||
|
<div class="bg-black/20 p-3 rounded mb-6 font-mono text-sm border border-white/10 text-green-100">
|
||||||
|
Match ID: <span class="font-bold text-white">{{ matchState.id }}</span>
|
||||||
|
</div>
|
||||||
|
<button @click="handleCloseModal"
|
||||||
|
class="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-lg font-semibold transition-all shadow-lg hover:shadow-red-500/20">
|
||||||
|
Cancel & Return Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<GameOver :is-visible="gameOver" :winner="winnerResult" :player-score="playerScore" :opponent-score="opponentScore"
|
||||||
|
:player-name="myDisplayName" :opponent-name="opponentDisplayName" :stats="{ playerTricks: 0, opponentTricks: 0 }"
|
||||||
|
:is-logging-out="false" @play-again="handleCloseModal" @close="handleCloseModal" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch, inject } from 'vue'
|
||||||
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { useSocketStore } from '@/stores/socket'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import axios from 'axios'
|
||||||
|
import GameBoard from '@/components/game/GameBoard.vue'
|
||||||
|
import GameOver from '@/components/game/GameOver.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const wsStore = useSocketStore()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const API_BASE_URL = inject('apiBaseURL')
|
||||||
|
|
||||||
|
const gameId = ref(route.params.id)
|
||||||
|
const visibleHand = ref([])
|
||||||
|
const gameOver = ref(false)
|
||||||
|
const isDealing = ref(false)
|
||||||
|
const hasInitialDealHappened = ref(false)
|
||||||
|
const winnerResult = ref('draw')
|
||||||
|
const timeLeft = ref(20)
|
||||||
|
const timerInterval = ref(null)
|
||||||
|
const hasConnectedOnce = ref(false)
|
||||||
|
|
||||||
|
// --- STATE ---
|
||||||
|
const matchState = computed(() => wsStore.gameState || { id: gameId.value })
|
||||||
|
|
||||||
|
const gameMetadata = ref({
|
||||||
|
p1_id: null,
|
||||||
|
p1_name: 'Player 1',
|
||||||
|
p2_id: null,
|
||||||
|
p2_name: 'Opponent'
|
||||||
|
})
|
||||||
|
|
||||||
|
const displayedTrick = ref({ playerCard: null, opponentCard: null })
|
||||||
|
const trickClearTimer = ref(null)
|
||||||
|
|
||||||
|
// --- COMPUTED ---
|
||||||
|
|
||||||
|
const myUserId = computed(() => authStore.currentUser?.id)
|
||||||
|
|
||||||
|
const shouldShowBoard = computed(() => {
|
||||||
|
const s = wsStore.gameState;
|
||||||
|
if (s && s.player2 && s.player2.id > 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
|
||||||
|
const amIHost = computed(() => {
|
||||||
|
if (gameMetadata.value.p1_id) {
|
||||||
|
return String(gameMetadata.value.p1_id) === String(myUserId.value)
|
||||||
|
}
|
||||||
|
const p1Id = wsStore.gameState?.player1_user_id || wsStore.gameState?.player1?.id
|
||||||
|
return String(p1Id) === String(myUserId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const myDisplayName = computed(() => amIHost.value ? gameMetadata.value.p1_name : gameMetadata.value.p2_name)
|
||||||
|
const opponentDisplayName = computed(() => amIHost.value ? gameMetadata.value.p2_name : gameMetadata.value.p1_name)
|
||||||
|
|
||||||
|
const mappedTrick = computed(() => {
|
||||||
|
const table = wsStore.gameState?.table
|
||||||
|
if (!table) return { playerCard: null, opponentCard: null }
|
||||||
|
if (amIHost.value) {
|
||||||
|
return { playerCard: table.playerCard, opponentCard: table.opponentCard }
|
||||||
|
} else {
|
||||||
|
return { playerCard: table.opponentCard, opponentCard: table.playerCard }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTurnLabel = computed(() => {
|
||||||
|
const turnId = wsStore.gameState?.currentTurn
|
||||||
|
if (!turnId) return 'player'
|
||||||
|
return String(turnId) === String(myUserId.value) ? 'player' : 'opponent'
|
||||||
|
})
|
||||||
|
|
||||||
|
const trumpCard = computed(() => wsStore.gameState?.trumpCard || null)
|
||||||
|
const deckCount = computed(() => wsStore.gameState?.deckCount || 0)
|
||||||
|
const playerScore = computed(() => wsStore.gameState?.points || 0)
|
||||||
|
const opponentScore = computed(() => wsStore.gameState?.opponent?.points || 0)
|
||||||
|
const serverHand = computed(() => wsStore.gameState?.hand || [])
|
||||||
|
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
|
||||||
|
const opponentHand = computed(() => {
|
||||||
|
const count = wsStore.gameState?.opponent?.cardCount || 0
|
||||||
|
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- WATCHERS ---
|
||||||
|
|
||||||
|
watch(() => wsStore.connected, (isConnected) => {
|
||||||
|
if (isConnected) {
|
||||||
|
hasConnectedOnce.value = true
|
||||||
|
wsStore.joinGame(gameId.value)
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => wsStore.gameState, async (newState, oldState) => {
|
||||||
|
const oldP2 = oldState?.player2?.id;
|
||||||
|
const newP2 = newState?.player2?.id;
|
||||||
|
if (newP2 && newP2 > 1 && newP2 !== oldP2) {
|
||||||
|
await fetchGameMetadata();
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(mappedTrick, (newVal) => {
|
||||||
|
if (newVal.playerCard || newVal.opponentCard) {
|
||||||
|
if (trickClearTimer.value) clearTimeout(trickClearTimer.value)
|
||||||
|
displayedTrick.value = { ...newVal }
|
||||||
|
} else {
|
||||||
|
const currentlyShowing = displayedTrick.value.playerCard || displayedTrick.value.opponentCard
|
||||||
|
if (currentlyShowing) {
|
||||||
|
trickClearTimer.value = setTimeout(() => {
|
||||||
|
displayedTrick.value = { playerCard: null, opponentCard: null }
|
||||||
|
}, 1500)
|
||||||
|
} else {
|
||||||
|
displayedTrick.value = { playerCard: null, opponentCard: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// --- FIX: TIMER GHOSTING ---
|
||||||
|
const startTimer = () => {
|
||||||
|
if (timerInterval.value) clearInterval(timerInterval.value)
|
||||||
|
|
||||||
|
if (!shouldShowBoard.value && wsStore.gameState?.status !== 'playing') {
|
||||||
|
timeLeft.value = 20;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORCE RESET to avoid showing old time for 1 frame
|
||||||
|
timeLeft.value = 20;
|
||||||
|
|
||||||
|
const startAt = wsStore.gameState?.turnStartedAt ? new Date(wsStore.gameState.turnStartedAt).getTime() : Date.now()
|
||||||
|
|
||||||
|
// Define update function
|
||||||
|
const update = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
const elapsed = Math.floor((now - startAt) / 1000)
|
||||||
|
timeLeft.value = Math.max(0, 20 - elapsed)
|
||||||
|
if (timeLeft.value <= 0) clearInterval(timerInterval.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EXECUTE IMMEDIATELY
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Then set interval
|
||||||
|
timerInterval.value = setInterval(update, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => wsStore.gameState?.currentTurn, (newTurn) => {
|
||||||
|
if (newTurn) startTimer()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const animateDeal = async (cards) => {
|
||||||
|
if (isDealing.value) return
|
||||||
|
isDealing.value = true
|
||||||
|
visibleHand.value = []
|
||||||
|
for (const card of cards) {
|
||||||
|
visibleHand.value.push(card)
|
||||||
|
await new Promise(r => setTimeout(r, 150))
|
||||||
|
}
|
||||||
|
isDealing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(serverHand, (newHand) => {
|
||||||
|
if (visibleHand.value.length === 0 && newHand.length > 0 && !hasInitialDealHappened.value) {
|
||||||
|
animateDeal(newHand)
|
||||||
|
hasInitialDealHappened.value = true
|
||||||
|
} else if (!isDealing.value) {
|
||||||
|
visibleHand.value = [...newHand]
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
// --- METHODS ---
|
||||||
|
|
||||||
|
const fetchGameMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_BASE_URL}/games/${gameId.value}`)
|
||||||
|
const game = response.data.data || response.data
|
||||||
|
gameMetadata.value = {
|
||||||
|
p1_id: game.player1_user_id,
|
||||||
|
p1_name: game.player1?.nickname || 'Host',
|
||||||
|
p2_id: game.player2_user_id,
|
||||||
|
p2_name: game.player2?.nickname || 'Opponent'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load metadata", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayCard = (card) => {
|
||||||
|
if (!isMyTurn.value || gameOver.value) return
|
||||||
|
visibleHand.value = visibleHand.value.filter(c => c.id !== card.id)
|
||||||
|
wsStore.playCard(gameId.value, card.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBeforeUnload = (event) => {
|
||||||
|
if (shouldShowBoard.value && !gameOver.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- FIX: SURRENDER LOGIC ---
|
||||||
|
const handleSurrender = () => {
|
||||||
|
if (confirm('Are you sure you want to surrender? You will lose.')) {
|
||||||
|
wsStore.surrender(gameId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCloseModal = async () => {
|
||||||
|
// 1. If game is still in Lobby (Pending) and I am the Host, DELETE it.
|
||||||
|
if (!shouldShowBoard.value && amIHost.value && !gameOver.value) {
|
||||||
|
try {
|
||||||
|
// This calls Route::delete('/{game}', ...) in your api.php
|
||||||
|
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`);
|
||||||
|
console.log("Lobby cancelled and deleted.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to delete pending game:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notify server of voluntary exit (to skip the 10s reconnect timer)
|
||||||
|
if (wsStore.socket) {
|
||||||
|
wsStore.socket.emit('notify_disconnect');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Disconnect and go home
|
||||||
|
wsStore.disconnect();
|
||||||
|
router.push({ name: 'home' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- LIFECYCLE ---
|
||||||
|
|
||||||
|
onBeforeRouteLeave(async (to, from, next) => {
|
||||||
|
// CASE A: Game is Pending (Waiting Room)
|
||||||
|
if (!shouldShowBoard.value && !gameOver.value) {
|
||||||
|
if (amIHost.value) {
|
||||||
|
// Ask for confirmation to close the lobby
|
||||||
|
const confirmClose = window.confirm("This will cancel the lobby. Are you sure?");
|
||||||
|
if (confirmClose) {
|
||||||
|
try {
|
||||||
|
// Delete the game from DB so it doesn't get stuck as "Pending"
|
||||||
|
await axios.delete(`${API_BASE_URL}/games/${gameId.value}`);
|
||||||
|
} catch (e) { console.error(e) }
|
||||||
|
|
||||||
|
if (wsStore.socket) wsStore.socket.emit('notify_disconnect');
|
||||||
|
wsStore.disconnect();
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
next(false); // Stay on page
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// If I am NOT host (just a guest waiting), just leave
|
||||||
|
if (wsStore.socket) wsStore.socket.emit('notify_disconnect');
|
||||||
|
wsStore.disconnect();
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASE B: Game is Over (Standard exit)
|
||||||
|
if (gameOver.value) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASE C: Game is Active/Playing (The Surrender Logic we built before)
|
||||||
|
const answer = window.confirm(
|
||||||
|
"If you leave, you will be register has 'losing' or 'surrendered', Are you sure you want to leave?"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (answer) {
|
||||||
|
// ... (Your existing surrender logic) ...
|
||||||
|
try { await wsStore.surrender(gameId.value); } catch (e) { }
|
||||||
|
|
||||||
|
if (wsStore.socket) {
|
||||||
|
wsStore.socket.emit('notify_disconnect', () => {
|
||||||
|
wsStore.disconnect();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
setTimeout(() => { if (wsStore.connected) { wsStore.disconnect(); next(); } }, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wsStore.disconnect();
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
next(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchGameMetadata()
|
||||||
|
wsStore.connect()
|
||||||
|
|
||||||
|
wsStore.onTrickEnd((result) => {
|
||||||
|
const winnerName = result.winnerId == authStore.currentUser?.id ? "You" : "Opponent"
|
||||||
|
toast.success(`${winnerName} won the trick!`, { position: 'top-center', duration: 1500 })
|
||||||
|
})
|
||||||
|
|
||||||
|
wsStore.onGameOver((result) => {
|
||||||
|
gameOver.value = true
|
||||||
|
if (result.isDraw) winnerResult.value = 'draw'
|
||||||
|
else winnerResult.value = result.winnerId == authStore.currentUser?.id ? 'player' : 'opponent'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
|
||||||
|
if (timerInterval.value) clearInterval(timerInterval.value)
|
||||||
|
wsStore.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed, inject, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSocketStore } from '@/stores/socket'
|
||||||
|
import { Coins, Trophy, Frown } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import GameBoard from '@/components/game/GameBoard.vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
|
const API_BASE_URL = inject('apiBaseURL')
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const wsStore = useSocketStore()
|
||||||
|
|
||||||
|
// --- FIX: Flag to prevent double cancellation ---
|
||||||
|
const isCancelling = ref(false)
|
||||||
|
|
||||||
|
const matchState = ref({
|
||||||
|
id: route.params.id,
|
||||||
|
wager: 0,
|
||||||
|
myMarks: 0,
|
||||||
|
oppMarks: 0,
|
||||||
|
currentGameId: null,
|
||||||
|
player1: null,
|
||||||
|
player2: null,
|
||||||
|
status: 'Pending'
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeLeft = ref(20)
|
||||||
|
const timerInterval = ref(null)
|
||||||
|
const showSurrenderModal = ref(false)
|
||||||
|
const showTrickResult = ref(false)
|
||||||
|
const lastTrickWinner = ref(null)
|
||||||
|
const showRoundModal = ref(false)
|
||||||
|
const roundData = ref({ winnerId: null, marksAdded: 0, myPoints: 0, oppPoints: 0, reason: '' })
|
||||||
|
|
||||||
|
const myUserId = computed(() => authStore.currentUser?.id)
|
||||||
|
const amIHost = computed(() => matchState.value.player1?.id === myUserId.value)
|
||||||
|
const isPending = computed(() => !matchState.value.player2 || matchState.value.player2.id <= 1)
|
||||||
|
const matchOver = computed(() => matchState.value.myMarks >= 4 || matchState.value.oppMarks >= 4)
|
||||||
|
const iWonMatch = computed(() => matchState.value.myMarks > matchState.value.oppMarks)
|
||||||
|
const isMyTurn = computed(() => String(wsStore.gameState?.currentTurn) === String(myUserId.value))
|
||||||
|
const currentTurnLabel = computed(() => isMyTurn.value ? 'player' : 'opponent')
|
||||||
|
|
||||||
|
const myDisplayName = computed(() => amIHost.value ? matchState.value.player1?.nickname : matchState.value.player2?.nickname)
|
||||||
|
const opponentDisplayName = computed(() => amIHost.value ? matchState.value.player2?.nickname : matchState.value.player1?.nickname)
|
||||||
|
|
||||||
|
const opponentHand = computed(() => {
|
||||||
|
const count = wsStore.gameState?.opponent?.cardCount || 0
|
||||||
|
return Array.from({ length: count }, (_, i) => ({ id: `opp-${i}`, suit: 'back', rank: 0 }))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Timer
|
||||||
|
watch(() => wsStore.gameState?.currentTurn, () => { startTimer() }, { deep: true })
|
||||||
|
const startTimer = () => {
|
||||||
|
if (timerInterval.value) clearInterval(timerInterval.value);
|
||||||
|
timeLeft.value = 20;
|
||||||
|
if(!wsStore.gameState) return;
|
||||||
|
timerInterval.value = setInterval(() => {
|
||||||
|
if(timeLeft.value > 0) timeLeft.value--;
|
||||||
|
else clearInterval(timerInterval.value);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePlayCard = (card) => {
|
||||||
|
if (wsStore.socket) wsStore.socket.emit('game:play-card', { matchId: matchState.value.id, cardId: card.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSurrenderModal = () => { showSurrenderModal.value = true }
|
||||||
|
const confirmSurrenderRound = () => {
|
||||||
|
showSurrenderModal.value = false;
|
||||||
|
if (wsStore.socket) wsStore.socket.emit('game:surrender', { matchId: matchState.value.id });
|
||||||
|
}
|
||||||
|
const confirmSurrenderMatch = () => {
|
||||||
|
showSurrenderModal.value = false;
|
||||||
|
if (wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
|
||||||
|
}
|
||||||
|
const closeRoundModal = () => { showRoundModal.value = false; }
|
||||||
|
|
||||||
|
const updateLocalState = (data) => {
|
||||||
|
if (!data) return
|
||||||
|
if (data.player1) matchState.value.player1 = data.player1
|
||||||
|
if (data.player2) matchState.value.player2 = data.player2
|
||||||
|
if (data.stake) matchState.value.wager = data.stake
|
||||||
|
matchState.value.status = data.status
|
||||||
|
|
||||||
|
if (data.marks) {
|
||||||
|
const myId = authStore.currentUser?.id;
|
||||||
|
const oppId = amIHost.value ? matchState.value.player2?.id : matchState.value.player1?.id;
|
||||||
|
matchState.value.myMarks = data.marks[myId] || 0;
|
||||||
|
matchState.value.oppMarks = data.marks[oppId] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.game) {
|
||||||
|
matchState.value.currentGameId = data.game.id;
|
||||||
|
wsStore.gameState = data.game;
|
||||||
|
} else if (data.status === 'Playing') {
|
||||||
|
matchState.value.currentGameId = null;
|
||||||
|
wsStore.gameState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FIX: Cancel Match with Flag ---
|
||||||
|
const cancelMatch = async () => {
|
||||||
|
if (isCancelling.value) return;
|
||||||
|
isCancelling.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_BASE_URL}/matches/${matchState.value.id}`)
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore 404s if we just deleted it
|
||||||
|
if (e.response && e.response.status === 404) {
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} else {
|
||||||
|
isCancelling.value = false; // Reset if legitimate error
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const leaveMatch = () => router.push({ name: 'home' })
|
||||||
|
|
||||||
|
const handleBeforeUnload = (e) => {
|
||||||
|
if (matchState.value.status === 'Playing' && !matchOver.value) {
|
||||||
|
const msg = "If you leave now, you will lose the match and your wager.";
|
||||||
|
e.returnValue = msg;
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FIX: Router Guard checks Flag ---
|
||||||
|
onBeforeRouteLeave((to, from, next) => {
|
||||||
|
// If we already clicked cancel, just let it go
|
||||||
|
if (isCancelling.value) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchState.value.status === 'Playing' && !matchOver.value) {
|
||||||
|
if (confirm("⚠️ WARNING: Leaving now triggers an automatic match surrender (Loss).\n\nAre you sure?")) {
|
||||||
|
if(wsStore.socket) wsStore.socket.emit('match:surrender', { matchId: matchState.value.id });
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
next(false);
|
||||||
|
}
|
||||||
|
} else if (isPending.value && amIHost.value) {
|
||||||
|
if(confirm("Cancel this lobby?")) {
|
||||||
|
// Mark as cancelling so we don't trigger double requests if cancelMatch is weird
|
||||||
|
isCancelling.value = true;
|
||||||
|
cancelMatch().then(() => next());
|
||||||
|
} else {
|
||||||
|
next(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
try {
|
||||||
|
const res = await axios.get(`${API_BASE_URL}/matches/${matchState.value.id}`)
|
||||||
|
const d = res.data.data || res.data
|
||||||
|
matchState.value.player1 = d.player1
|
||||||
|
matchState.value.player2 = d.player2
|
||||||
|
matchState.value.wager = d.stake
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
wsStore.connect()
|
||||||
|
if (wsStore.socket) {
|
||||||
|
wsStore.socket.emit('match:enter', { matchId: matchState.value.id })
|
||||||
|
wsStore.socket.on('match:update', updateLocalState)
|
||||||
|
wsStore.socket.on('match:new-round', (data) => {
|
||||||
|
updateLocalState(data);
|
||||||
|
toast.info("New round started!");
|
||||||
|
})
|
||||||
|
wsStore.socket.on('match:ended', updateLocalState)
|
||||||
|
wsStore.socket.on('game:update', (state) => { wsStore.gameState = state })
|
||||||
|
wsStore.socket.on('game:trick-end', (result) => {
|
||||||
|
lastTrickWinner.value = result.winnerId;
|
||||||
|
showTrickResult.value = true;
|
||||||
|
setTimeout(() => { showTrickResult.value = false }, 2000);
|
||||||
|
});
|
||||||
|
wsStore.socket.on('match:round-ended', (data) => {
|
||||||
|
const myPoints = data.winnerId == myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
|
||||||
|
const oppPoints = data.winnerId != myUserId.value ? (data.winnerId == matchState.value.player1.id ? data.p1Points : data.p2Points) : (data.winnerId == matchState.value.player1.id ? data.p2Points : data.p1Points);
|
||||||
|
|
||||||
|
roundData.value = {
|
||||||
|
winnerId: data.winnerId,
|
||||||
|
marksAdded: data.marksAdded,
|
||||||
|
myPoints: myPoints,
|
||||||
|
oppPoints: oppPoints,
|
||||||
|
reason: data.reason
|
||||||
|
};
|
||||||
|
showRoundModal.value = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||||
|
if (wsStore.socket) {
|
||||||
|
wsStore.socket.off('match:update')
|
||||||
|
wsStore.socket.off('match:new-round')
|
||||||
|
wsStore.socket.off('game:trick-end')
|
||||||
|
wsStore.socket.off('match:round-ended')
|
||||||
|
}
|
||||||
|
wsStore.disconnect()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative min-h-screen bg-green-900 text-white overflow-hidden">
|
||||||
|
<div class="fixed top-0 left-0 right-0 z-40 bg-green-950/90 backdrop-blur border-b border-green-800 p-2 shadow-xl">
|
||||||
|
<div class="max-w-4xl mx-auto flex items-center justify-between">
|
||||||
|
<div class="flex flex-col items-start">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-bold text-lg text-white drop-shadow-md">{{ myDisplayName }}</span>
|
||||||
|
<span v-if="matchState.wager > 0" class="flex items-center text-xs text-yellow-300 bg-yellow-900/50 px-2 rounded border border-yellow-700/50">
|
||||||
|
<Coins class="w-3 h-3 mr-1" /> {{ matchState.wager }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-green-400 bg-green-900"
|
||||||
|
:class="{ '!bg-green-400 shadow-[0_0_10px_rgba(74,222,128,0.5)]': matchState.myMarks >= i }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span class="text-[10px] uppercase tracking-widest text-green-400 font-bold">Match #{{ matchState.id }}</span>
|
||||||
|
<span class="text-2xl font-black font-mono text-white drop-shadow-md">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end">
|
||||||
|
<span class="font-bold text-lg text-white drop-shadow-md">{{ opponentDisplayName }}</span>
|
||||||
|
<div class="flex gap-1 mt-1">
|
||||||
|
<div v-for="i in 4" :key="i" class="w-3 h-3 rounded-full border border-red-400 bg-green-900"
|
||||||
|
:class="{ '!bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]': matchState.oppMarks >= i }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="wsStore.gameState && !matchOver" class="absolute top-24 right-4 z-50">
|
||||||
|
<button @click="openSurrenderModal" class="bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded-lg shadow-lg border-2 border-red-800 transition-all hover:scale-105 flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<span>🏳️</span> Surrender
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="wsStore.gameState && !matchOver && isMyTurn" class="absolute top-24 left-1/2 -translate-x-1/2 z-50">
|
||||||
|
<div class="bg-gray-800 px-6 py-2 rounded-lg border-2 shadow-xl transition-colors duration-300" :class="timeLeft <= 5 ? 'border-red-500 bg-red-900/20' : 'border-gray-700'">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5" :class="timeLeft <= 5 ? 'text-red-500 animate-bounce' : 'text-white'" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke-width="2" />
|
||||||
|
<polyline points="12 6 12 12 16 14" stroke-width="2" />
|
||||||
|
</svg>
|
||||||
|
<span :class="['text-2xl font-mono font-bold', timeLeft <= 5 ? 'text-red-500' : 'text-white']">{{ timeLeft }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-20 h-screen">
|
||||||
|
<GameBoard
|
||||||
|
v-if="wsStore.gameState && !matchOver"
|
||||||
|
:game-id="matchState.currentGameId"
|
||||||
|
@play-card="handlePlayCard"
|
||||||
|
:trump-card="wsStore.gameState?.trumpCard"
|
||||||
|
:cards-remaining="wsStore.gameState?.deckCount"
|
||||||
|
:player-hand="wsStore.gameState?.hand"
|
||||||
|
:opponent-hand="opponentHand"
|
||||||
|
:player-score="wsStore.gameState?.points"
|
||||||
|
:opponent-score="wsStore.gameState?.opponent?.points"
|
||||||
|
:current-turn="currentTurnLabel"
|
||||||
|
:current-trick="wsStore.gameState?.table"
|
||||||
|
:is-my-turn="isMyTurn"
|
||||||
|
:player-name="myDisplayName"
|
||||||
|
:opponent-name="opponentDisplayName"
|
||||||
|
/>
|
||||||
|
<div v-else-if="isPending" class="flex h-full items-center justify-center flex-col gap-6">
|
||||||
|
<div class="text-center p-8 bg-green-800/60 rounded-2xl border border-green-600 shadow-2xl backdrop-blur-md max-w-sm w-full mx-4">
|
||||||
|
<div class="relative mx-auto mb-6 h-16 w-16">
|
||||||
|
<div class="absolute inset-0 rounded-full border-4 border-green-700/50"></div>
|
||||||
|
<div class="absolute inset-0 rounded-full border-4 border-t-white border-r-transparent border-b-transparent border-l-transparent animate-spin"></div>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold mb-2 text-white">Waiting for Opponent</h2>
|
||||||
|
<button v-if="amIHost" @click="cancelMatch" class="bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded shadow mt-4">Cancel Match</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!matchOver && !wsStore.gameState" class="flex h-full items-center justify-center flex-col gap-4">
|
||||||
|
<div class="text-3xl font-bold animate-pulse text-white drop-shadow">Preparing next round...</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="matchOver" class="absolute inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||||
|
<div class="text-center space-y-6 p-8 bg-green-900 border-2 border-green-500 rounded-2xl shadow-2xl max-w-md w-full">
|
||||||
|
<Trophy v-if="iWonMatch" class="w-24 h-24 text-yellow-400 mx-auto animate-bounce" />
|
||||||
|
<Frown v-else class="w-24 h-24 text-green-300 mx-auto opacity-75" />
|
||||||
|
<h1 class="text-5xl font-black uppercase mb-2" :class="iWonMatch ? 'text-yellow-400' : 'text-gray-300'">{{ iWonMatch ? 'Victory!' : 'Defeat' }}</h1>
|
||||||
|
<p class="text-green-100 text-lg">Marks: <span class="font-bold">{{ matchState.myMarks }} - {{ matchState.oppMarks }}</span></p>
|
||||||
|
<Button @click="leaveMatch" class="w-full py-6 text-lg bg-purple-600 hover:bg-purple-700">Return to Lobby</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showRoundModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/80 backdrop-blur-md">
|
||||||
|
<div class="bg-gray-800 p-8 rounded-2xl border-2 border-yellow-500/50 shadow-2xl max-w-md w-full text-center relative overflow-hidden">
|
||||||
|
<div class="absolute top-0 left-0 w-full h-2 bg-gradient-to-r from-transparent via-yellow-500 to-transparent"></div>
|
||||||
|
<h2 class="text-3xl font-black text-white mb-2 uppercase">Round Over</h2>
|
||||||
|
<p class="text-gray-400 mb-6 uppercase text-xs tracking-widest">{{ roundData.reason === 'surrender' ? 'Opponent Surrendered' : 'Points Limit Reached' }}</p>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center bg-black/30 p-4 rounded-lg mb-6">
|
||||||
|
<div class="text-left">
|
||||||
|
<p class="text-xs text-gray-500 uppercase">You</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ roundData.myPoints }} pts</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-xl font-bold text-yellow-500">VS</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 uppercase">Opponent</p>
|
||||||
|
<p class="text-2xl font-bold text-white">{{ roundData.oppPoints }} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<p class="text-lg text-white" v-if="roundData.winnerId == myUserId">
|
||||||
|
You won <span class="text-yellow-400 font-bold">+{{ roundData.marksAdded }} marks</span>!
|
||||||
|
</p>
|
||||||
|
<p class="text-lg text-white" v-else>
|
||||||
|
Opponent won <span class="text-red-400 font-bold">+{{ roundData.marksAdded }} marks</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="closeRoundModal" class="w-full py-4 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-xl shadow-lg transition-transform hover:scale-105">
|
||||||
|
Ready for Next Round
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showSurrenderModal" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||||
|
<div class="bg-gray-800 p-6 rounded-lg border border-gray-600 shadow-2xl max-w-sm w-full text-center">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-6">Surrender Options</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button @click="confirmSurrenderRound" class="w-full px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded text-white font-bold border border-gray-500 flex justify-between items-center group">
|
||||||
|
<span>Surrender Round</span>
|
||||||
|
<span class="text-xs bg-black/40 px-2 py-1 rounded text-gray-300 group-hover:bg-red-500 group-hover:text-white">-2 Marks</span>
|
||||||
|
</button>
|
||||||
|
<button @click="confirmSurrenderMatch" class="w-full px-4 py-3 bg-red-900/50 hover:bg-red-600 rounded text-red-200 hover:text-white font-bold border border-red-800 flex justify-between items-center group">
|
||||||
|
<span>Surrender Match</span>
|
||||||
|
<span class="text-xs bg-black/40 px-2 py-1 rounded text-red-300 group-hover:bg-red-800 group-hover:text-white">Defeat</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button @click="showSurrenderModal = false" class="mt-6 text-gray-400 hover:text-white text-sm underline">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 scale-50" enter-to-class="opacity-100 scale-100" leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-75">
|
||||||
|
<div v-if="showTrickResult" class="fixed top-1/3 left-1/2 -translate-x-1/2 z-[70] pointer-events-none">
|
||||||
|
<div class="bg-black/70 backdrop-blur-md px-8 py-4 rounded-2xl border border-white/10 shadow-2xl text-center">
|
||||||
|
<p class="text-sm uppercase tracking-widest text-gray-300 font-bold mb-1">Trick Winner</p>
|
||||||
|
<h2 class="text-3xl font-black" :class="lastTrickWinner === myUserId ? 'text-green-400' : 'text-red-400'">{{ lastTrickWinner === myUserId ? 'YOU' : 'OPPONENT' }}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -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>
|
||||||