Compare commits

...
Author SHA1 Message Date
AfonsoCMSousa a3da9af90d hotfix: Singler player now plays fine 2026-01-03 14:02:58 +00:00
AfonsoCMSousa 83832aaaa9 hotfix: Finnal fix 2026-01-03 14:01:38 +00:00
FernandoJVideira 4cfab79611 Merge pull request 'feature/bestof-series-interface' (#99) from feature/bestof-series-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/99
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-03 13:26:18 +00:00
AfonsoCMSousa 00cb5ba966 SyntaxSquad/DADProject#16 fix: merge conflics fixed 2026-01-03 13:25:21 +00:00
AfonsoCMSousa ceba5178f3 SyntaxSquad/DADProject#16 fix: i forgot this 2026-01-03 13:14:59 +00:00
AfonsoCMSousa 0fb64ff30c SyntaxSquad/DADProject#16 feat: MATCHES AND STUFF 2026-01-03 13:14:37 +00:00
AfonsoCMSousa e0ca8e51a6 SyntaxSquad/DADProject#16 feat: Can now start matches, just missing the actuall gameplay 2026-01-03 04:52:02 +00:00
FernandoJVideira cd25915d92 Merge pull request 'fix:admin & user match table sort' (#98) from fix/frontend-admin-match-sort into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/98
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-03 02:35:07 +00:00
FernandoJVideira bcfa5c06f7 fix:user profile match table sort 2026-01-03 02:33:22 +00:00
AfonsoCMSousa 3dcd8df4c7 SyntaxSquad/DADProject#16 fix: Cancle and Return is working and some other fixes involving game duration 2026-01-03 02:19:00 +00:00
FernandoJVideira 46e3f0eef6 fix:admin match table sort 2026-01-03 02:11:41 +00:00
AfonsoCMSousa 7e6766c726 SyntaxSquad/DADProject#16 merge: fix merge conflics 2026-01-03 00:53:51 +00:00
AfonsoCMSousa 1088749386 SyntaxSquad/DADProject#16 fix: Surrender if leave 2026-01-03 00:51:42 +00:00
KZix 52cb893bd0 Merge pull request 'Added matches scrollable to home page' (#96) from na/matches-home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/96
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-03 00:43:38 +00:00
Edd 59fea64df5 Merge branch 'develop' into na/matches-home-page 2026-01-03 00:43:07 +00:00
Edd a09639abcf Merge pull request 'feature/frontend-add-match-profile-tabs' (#97) from feature/frontend-add-match-profile-tabs into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/97
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-03 00:43:03 +00:00
Edd 2d2d0149d8 little date adjustements 2026-01-03 00:42:04 +00:00
KZix 894a38bb27 Added corrections to user profile and admin match and game history 2026-01-03 00:41:24 +00:00
Edd 432d3c0b88 Added matches scrollable to home page 2026-01-03 00:39:08 +00:00
KZix 1a7916a836 Added list for games and matches in the user profile 2026-01-03 00:06:26 +00:00
Edd 030b00059e Merge pull request 'feature/websockets-matches' (#95) from feature/websockets-matches into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/95
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
2026-01-02 22:23:20 +00:00
KZix a804f41956 Final touches 2026-01-02 22:11:47 +00:00
KZix 25d21f7cf8 Merge branch 'develop' into feature/websockets-matches 2026-01-02 21:47:30 +00:00
KZix c3f8c3fff0 Matches almost done still refining 2026-01-02 21:45:37 +00:00
AfonsoCMSousa 23d0ea1343 SyntaxSquad/DADProject#16 fix: Timer issues and Forfeight button 2026-01-02 20:12:18 +00:00
KZix 1dc2d25a41 Merge pull request 'feature/multiplayer-game-interface' (#94) from feature/multiplayer-game-interface into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/94
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: KZix <[email protected]>
2026-01-02 19:18:04 +00:00
AfonsoCMSousa 7c8fdd6ea2 SyntaxSquad/DADProject#15 fix: Merge Conflicts Fixed. 2026-01-02 19:12:35 +00:00
AfonsoCMSousa 5e90de812b SyntaxSquad/DADProject#15 feat: Added Multiplayer View and Stuff 2026-01-02 19:07:27 +00:00
AfonsoCMSousa ee28333da8 Merge pull request 'Admin platform, with users, transactions, games and admin creation' (#93) from feature/admin-platform into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/93
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 16:20:26 +00:00
Edd e89b60c844 Admin platform, with users, transactions, games and admin creation 2026-01-02 16:17:55 +00:00
AfonsoCMSousa b9df0a4d4a Merge pull request 'fix:websocket errors & polict change' (#92) from fix/websocket-errors into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/92
Reviewed-by: Edd <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 14:29:39 +00:00
FernandoJVideira 3a7b1b9794 fix:websocket errors & polict change 2026-01-02 14:26:54 +00:00
AfonsoCMSousa 5880960c42 Merge remote-tracking branch 'origin/develop' into feature/multiplayer-game-interface 2026-01-02 13:34:53 +00:00
AfonsoCMSousa ef277a3cfd Merge pull request 'Issues Websockets #48 #49 #51 e #54' (#91) from feature/websockets-game-continue into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/91
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2026-01-02 13:33:21 +00:00
KZix e23ca3d1ff Issues Websockets #48 #49 #51 e #54 2026-01-02 13:24:20 +00:00
AfonsoCMSousa e84dd993af SyntaxSquad/DADProject#15 feat: Initial Idea 2026-01-02 00:39:39 +00:00
FernandoJVideira bcdf542606 Merge pull request 'Created statistics personal and general' (#90) from feature/statistics into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/90
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2026-01-01 12:34:40 +00:00
Edd 16f8d3bdfc Created statistics personal and general 2025-12-31 23:57:54 +00:00
AfonsoCMSousa 45d8aa0035 Merge pull request 'Implemented coin purchase-flow' (#89) from feature/coin-purchase into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/89
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-31 22:41:09 +00:00
Edd c0af76abd3 Implemented coin purchase-flow 2025-12-31 18:28:29 +00:00
FernandoJVideira ff16a57a97 Merge pull request 'Bugfix: Added api logic with transactions when match is finished, started and joined' (#88) from bugfix/matches-transactions into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/88
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-31 15:54:20 +00:00
KZix cf238d5451 Added api logic with transactions when match is finished, started and joined 2025-12-31 15:48:24 +00:00
Edd 50ae1b7f1f Merge pull request 'Added endpoint to create tranasactions' (#87) from feature/api-coinpurchase into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/87
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-31 11:07:11 +00:00
KZix 5975208877 Added endpoint to create tranasactions 2025-12-31 01:13:27 +00:00
KZix f3e8ffc400 Merge pull request 'matches and transactions history fully operational' (#86) from na/fix-histories into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/86
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-31 00:31:59 +00:00
Edd 1b03558f93 matches and transactions history fully operational 2025-12-31 00:16:14 +00:00
AfonsoCMSousa 881a60a388 Merge pull request 'feature/api-adminstats-transactions' (#85) from feature/api-adminstats-transactions into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/85
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-30 19:24:49 +00:00
KZix cf3ca26382 removed comments on code 2025-12-30 19:17:49 +00:00
KZix 58e6615876 Merge branch 'develop' into feature/api-adminstats-transactions 2025-12-30 19:02:09 +00:00
KZix 9bfa06b1ce Added endpoint to get authenticated user's transactions 2025-12-30 19:01:02 +00:00
Edd 2a438dce26 Merge pull request 'feat: api users change password endpoint' (#84) from feature/api-update-password into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/84
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-30 17:17:06 +00:00
KZix f38f08923f Added transaction details for the logged user 2025-12-30 16:13:46 +00:00
KZix 5f845b2ba7 Added routes, admin statistics to the controller and added models for coin transaction and coin transaction type 2025-12-30 16:08:53 +00:00
FernandoJVideira 54b4fc7cb1 feat: api users change password endpoint 2025-12-30 10:55:46 +00:00
KZix 3d23627433 Merge pull request 'register, coin balance, game history and transactions mockup' (#83) from na/history-and-register into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/83
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-30 10:24:15 +00:00
Edd 4f391e3638 histories are a little messed, will change the logic in the future (techdebt) 2025-12-30 01:49:39 +00:00
FernandoJVideira c456c9482f Merge pull request 'feature/api-stats' (#82) from feature/api-stats into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/82
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-29 19:08:47 +00:00
KZix 754a227608 Merge branch 'develop' into feature/api-stats 2025-12-29 19:07:38 +00:00
KZix 83f92e670d Merge pull request 'feature/api-admin-policies' (#78) from feature/api-admin-policies into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/78
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: Edd <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-29 17:11:15 +00:00
KZix 59df3e2d37 Added protection to Nickname and added user get coins endpoint 2025-12-29 15:59:25 +00:00
KZix 39f55a12c0 Adde routes for statistics 2025-12-29 15:40:23 +00:00
KZix edecef3af8 added StatisticsController.php and changed the User.php model and ProfileController.php that were missing some imports 2025-12-29 15:34:56 +00:00
FernandoJVideira 51f745a3dd Merge branch 'develop' into feature/api-admin-policies 2025-12-29 14:53:44 +00:00
FernandoJVideira 4b311c74eb fix: added missing route 2025-12-29 14:47:32 +00:00
FernandoJVideira 0ecdc30bd4 fix: removed .env 2025-12-29 11:15:50 +00:00
FernandoJVideira 25c819c6e4 feat: api admin user and game policies 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira f60b25f29b chore: stop tracking package-lock.json 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira 52887fc2eb SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added packagelock.json to gitignore 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira c4078d9b82 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added sockets for game joining and card play 2025-12-29 11:15:50 +00:00
KZixandFernandoJVideira d29e44c61c Added dummy user to create a game 2025-12-29 11:15:49 +00:00
KZixandFernandoJVideira 8f7102b335 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 Added initial setup for websockets 2025-12-29 11:15:49 +00:00
EddandFernandoJVideira a34027889f remove package-lock 2025-12-29 11:15:49 +00:00
EddandFernandoJVideira 87e0dc2518 home page feature final 2025-12-29 11:15:49 +00:00
KZixandFernandoJVideira 79be3544cb SyntaxSquad/DADProject#14 missed one file 2025-12-29 11:15:35 +00:00
KZixandFernandoJVideira eafb4a4d1c SyntaxSquad/DADProject#14 added surrender button 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira 08c20acdf4 SyntaxSquad/DADProject#14 Added final touches to the gameplay 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira ab231ee94f SyntaxSquad/DADProject#14 fixed routes 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira ceb3d735ad SyntaxSquad/DADProject#14 Bot works and play works, still has some bugs 2025-12-29 11:15:34 +00:00
KZixandFernandoJVideira 5924d08f73 SyntaxSquad/DADProject#14 added start logic for bot 2025-12-29 11:15:33 +00:00
KZixandFernandoJVideira 3ec6f8971c SyntaxSquad/DADProject#14 added protection to logout inside a game and going to another page 2025-12-29 11:15:33 +00:00
KZixandFernandoJVideira e68f1357cc Added game start for single player, 3 and 9 card game types and protected the pages for only logged in players 2025-12-29 11:15:20 +00:00
FernandoJVideira cfda949ea6 feat: gameboard ui 2025-12-29 11:15:02 +00:00
EddandFernandoJVideira c68cda350e home page feature part 1 2025-12-29 11:13:22 +00:00
FernandoJVideira b7daa4610b tmp: pc change 2025-12-29 09:43:25 +00:00
FernandoJVideira 329bbcc1d3 Merge pull request 'feature/initial_gameplay_websockets' (#77) from feature/initial_gameplay_websockets into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/77
Reviewed-by: Edd <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-28 18:05:52 +00:00
KZix b35cac3b2c chore: stop tracking package-lock.json 2025-12-28 16:54:01 +00:00
KZix b5c3c552da SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added packagelock.json to gitignore 2025-12-28 16:45:23 +00:00
KZix 14dd4b3a4e SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 added sockets for game joining and card play 2025-12-28 16:34:51 +00:00
KZix 0d0f7569a6 Added dummy user to create a game 2025-12-28 16:30:18 +00:00
KZix 09c4732e19 SyntaxSquad/DADProject#46 SyntaxSquad/DADProject#47 Added initial setup for websockets 2025-12-27 19:52:46 +00:00
FernandoJVideira d4361121d8 feat: api admin policies 2025-12-27 15:07:48 +00:00
KZix ef0bdab0d3 Merge pull request 'Build game lobby for single-player and multiplayer modes' (#76) from feature/home-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/76
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-25 11:42:00 +00:00
Edd 5295d0620f remove package-lock 2025-12-25 01:51:47 +00:00
Edd d8cdbbf2ad home page feature final 2025-12-25 01:49:02 +00:00
Edd 160489b080 Merge branch 'develop' into feature/home-page 2025-12-24 23:42:00 +00:00
Edd a0bbd8a69d Merge pull request 'SyntaxSquad/DADProject#14 feature/frontend-gameboard-singleplayer' (#75) from feature/frontend-gameboard-singleplayer into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/75
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-24 23:35:40 +00:00
Edd 943a5dd898 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-24 23:35:33 +00:00
KZix 8912029686 SyntaxSquad/DADProject#14 missed one file 2025-12-24 16:02:03 +00:00
KZix 0bf96fc948 SyntaxSquad/DADProject#14 added surrender button 2025-12-24 16:00:53 +00:00
FernandoJVideira 79310fa63e Merge pull request 'feat: api admin users endpoints' (#74) from feature/api-admin-user-endpoint into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/74
Reviewed-by: KZix <[email protected]>
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
2025-12-24 15:54:11 +00:00
KZix 83adbcf2f1 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-24 15:48:49 +00:00
KZix 00666a16b2 SyntaxSquad/DADProject#14 Added final touches to the gameplay 2025-12-24 15:48:16 +00:00
FernandoJVideira 026e7af6a5 feat: api admin users endpoints 2025-12-24 15:03:37 +00:00
FernandoJVideira 7cfe9d2746 Merge pull request 'feature/account-deletion' (#73) from feature/account-deletion into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/73
Reviewed-by: KZix <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-24 10:52:01 +00:00
KZix 7a6fbb4ba3 SyntaxSquad/DADProject#14 fixed routes 2025-12-23 22:02:40 +00:00
KZix f3f6c6e564 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 21:55:23 +00:00
KZix d8e20dfc67 SyntaxSquad/DADProject#14 Bot works and play works, still has some bugs 2025-12-23 21:52:09 +00:00
AfonsoCMSousa 381f30a0f5 Merge branch 'develop' into feature/account-deletion 2025-12-23 21:52:05 +00:00
AfonsoCMSousa 723d5270e4 Merge pull request 'feature/profile-page' (#71) from feature/profile-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/71
Reviewed-by: KZix <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-23 21:49:20 +00:00
AfonsoCMSousa f4aaf9a787 Merge branch 'develop' into feature/profile-page 2025-12-23 21:45:33 +00:00
AfonsoCMSousa 30705fd194 SyntaxSquad/DADProject#10 feat: Account deletion (soft delete) 2025-12-23 21:39:38 +00:00
KZix 1939822e9b Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 20:12:01 +00:00
KZix c0b77aebb1 SyntaxSquad/DADProject#14 added start logic for bot 2025-12-23 20:08:19 +00:00
Edd ea15f5e28a Merge remote-tracking branch 'origin/develop' into feature/home-page 2025-12-23 20:07:35 +00:00
KZix 44c0ac599e Merge pull request 'Fix auth' (#72) from na/fix-auth into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/72
Reviewed-by: FernandoJVideira <[email protected]>
Reviewed-by: KZix <[email protected]>
2025-12-23 20:06:43 +00:00
Edd 28466ecbbd Merge branch 'develop' into na/fix-auth 2025-12-23 20:05:44 +00:00
Edd e47a453588 remove comment 2025-12-23 20:05:17 +00:00
Edd 0320230279 fix auth 2025-12-23 20:03:59 +00:00
KZix 8505d207e6 SyntaxSquad/DADProject#14 added protection to logout inside a game and going to another page 2025-12-23 19:43:57 +00:00
AfonsoCMSousa c34d74db46 SyntaxSquad/DADProject#9 fixforthefix: Remove anoying gray bg 2025-12-23 18:53:11 +00:00
AfonsoCMSousa 18cef8da4b SyntaxSquad/DADProject#9 fix: Used Tailwind Classes and Exported inline CSS into "UserPage.css" 2025-12-23 18:42:09 +00:00
AfonsoCMSousa b82b4d5b59 SyntaxSquad/DADProject#9 feat: Added profile change and name/password changes. 2025-12-23 18:21:04 +00:00
AfonsoCMSousa 29fc78c9f5 Merge remote-tracking branch 'origin/develop' into feature/profile-page 2025-12-23 17:35:49 +00:00
AfonsoCMSousa d903f96d57 SyntaxSquad/DADProject#9 Actuall User View 2025-12-23 17:24:25 +00:00
KZix e54ec58f0c Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-23 15:25:45 +00:00
Edd 7d1b769bdb Merge pull request 'Fixed Endpoint file and GameController' (#70) from fix/api-endpoints into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/70
Reviewed-by: KZix <[email protected]>
Reviewed-by: Edd <[email protected]>
2025-12-23 14:58:27 +00:00
Edd d0f9a03b93 Merge branch 'develop' into fix/api-endpoints 2025-12-23 12:38:51 +00:00
FernandoJVideira 10508cc0b1 fix: GameControler create game fixed 2025-12-23 12:36:56 +00:00
FernandoJVideira c78c5d2784 fix: api.php cleanup 2025-12-23 12:34:19 +00:00
KZix e833e403f7 Added game start for single player, 3 and 9 card game types and protected the pages for only logged in players 2025-12-23 00:18:34 +00:00
KZix 5c1fc11975 Merge branch 'develop' into feature/frontend-gameboard-singleplayer 2025-12-22 19:41:26 +00:00
Edd b59640c858 Merge branch 'develop' into feature/home-page 2025-12-22 17:14:56 +00:00
FernandoJVideira 289e8df604 Merge pull request 'Implement login page with email/password authentication' (#69) from feature/login-page into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/69
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-22 17:08:37 +00:00
Edd e8ccea4e42 home page feature part 1 2025-12-21 23:12:13 +00:00
Edd 8e179200a5 Merge branch 'develop' into feature/login-page 2025-12-21 14:52:32 +00:00
Edd c03d49e7e4 removed vscode preferences 2025-12-21 02:37:46 +00:00
Edd 2734e25cce feature implement login final changes 2025-12-20 22:26:22 +00:00
Edd a566387998 feature implement login part2 2025-12-20 21:59:33 +00:00
FernandoJVideira 2b161e2099 feat: gameboard ui 2025-12-20 17:03:18 +00:00
AfonsoCMSousa d1dd2c00f6 SyntaxSquad/DADProject#9 feat: User info dysplayed in /users/ 2025-12-18 21:04:49 +00:00
FernandoJVideira 8a6e0d0255 Merge pull request 'Added routes, endpoints to get all matches, get a specific match, create a match, join a match, and update the match' (#68) from feature/matches-api-endpoints into develop
Reviewed-on: https://gitea.fernandovideira.com/SyntaxSquad/DADProject/pulls/68
Reviewed-by: Afonso Clerigo Mendes de Sousa <[email protected]>
Reviewed-by: FernandoJVideira <[email protected]>
2025-12-18 20:36:49 +00:00
AfonsoCMSousa 94fdbd568c Merge branch 'develop' into feature/profile-page 2025-12-18 19:15:49 +00:00
Edd ae6a0b4706 Merge branch 'develop' into feature/login-page 2025-12-18 19:11:47 +00:00
AfonsoCMSousa a0ad35bb08 Merge branch 'feature/login-page' into feature/profile-page 2025-12-11 21:18:31 +00:00
AfonsoCMSousa 1d5100fbdf feat:initial commit 2025-12-11 21:14:54 +00:00
Edd 511e645d7e feature implement login part 1 2025-12-11 21:14:25 +00:00
139 changed files with 9453 additions and 15652 deletions
+7
View File
@@ -80,6 +80,7 @@ frontend/.env.*.local
# Websockets: NodeJs (in websockets) # Websockets: NodeJs (in websockets)
# ------------------------- # -------------------------
/websockets/node_modules/ /websockets/node_modules/
/websockets/.env
# ------------------------- # -------------------------
# Other useful ignores # Other useful ignores
# ------------------------- # -------------------------
@@ -99,3 +100,9 @@ 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
/frontend/package-lock.json
/websockets/package-lock.json
/api/package-lock.json
.vscode
-3
View File
@@ -1,3 +0,0 @@
{
"postman.settings.dotenv-detection-notification-visibility": false
}
+2
View File
@@ -22,3 +22,5 @@
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
Thumbs.db Thumbs.db
composer.lock
+24 -5
View File
@@ -2,11 +2,11 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Http\Requests\RegisterRequest;
use App\Models\User;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use App\Models\User;
use App\Http\Requests\RegisterRequest;
class AuthController extends Controller class AuthController extends Controller
{ {
@@ -24,6 +24,13 @@ class AuthController extends Controller
} }
$user = Auth::user(); $user = Auth::user();
if ($user->blocked) {
Auth::logout();
return response()->json(['message' => 'Your account is blocked. Please contact support.'], 403);
}
$token = $user->createToken('auth-token')->plainTextToken; $token = $user->createToken('auth-token')->plainTextToken;
return response()->json([ return response()->json([
@@ -46,15 +53,27 @@ class AuthController extends Controller
if (User::where('email', $request->email)->exists()) { if (User::where('email', $request->email)->exists()) {
return response()->json(['message' => 'Email already registered'], 409); return response()->json(['message' => 'Email already registered'], 409);
} }
if (User::where('nickname', $request->nickname)->exists()) {
return response()->json(['message' => 'Nickname already registered'], 409);
}
$avatarFilename = null;
if ($request->hasFile('photo_avatar_filename')) {
$file = $request->file('photo_avatar_filename');
$path = $file->store('photos_avatars', 'public');
$avatarFilename = basename($path);
}
$user = User::create([ $user = User::create([
'email' => $request->email, 'email' => $request->email,
'nickname' => $request->nickname, 'nickname' => $request->nickname,
'name' => $request->name, 'name' => $request->name,
'password' => bcrypt($request->password), 'password' => bcrypt($request->password),
'photo_avatar_filename' => $request->photo, 'photo_avatar_filename' => $avatarFilename,
'type' => 'P', 'type' => 'P',
'blocked' => false, 'blocked' => false,
'coins_balance' => 10, 'coins_balance' => 10,
]); ]);
@@ -0,0 +1,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);
});
}
}
+5 -2
View File
@@ -2,7 +2,10 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
abstract class Controller use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{ {
// use AuthorizesRequests;
} }
+207 -83
View File
@@ -4,135 +4,259 @@ 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)
{ {
$query = Game::query()->with(['winner', 'player1', 'player2']); $this->authorize('viewAny', Game::class);
if ($request->has('type') && in_array($request->type, ['3', '9'])) { $user = Auth::user();
$query->where('type', $request->type); $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('status') && in_array($request->status, ['Pending', 'Playing', 'Ended', 'Interrupted'])) { if ($request->has("type") && in_array($request->type, ["3", "9"])) {
$query->where('status', $request->status); $query->where("type", $request->type);
} }
$query->orderBy('began_at', 'desc'); if (
$request->has("status") &&
in_array($request->status, [
"Pending",
"Playing",
"Ended",
"Interrupted",
])
) {
$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");
}
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) $q->where("player1_user_id", $user->id)
->orWhere('player2_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);
}
$game = Game::create([
'type' => $validated['type'],
'status' => 'Pending',
'player1_user_id' => $user->id,
'player2_user_id' => $user->id,
'winner_user_id' => $user->id,
'loser_user_id' => $user->id,
'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);
}) })
->whereIn('status', ['Pending', 'Playing']) ->whereIn("status", ["Pending", "Playing"])
->exists(); ->exists();
if ($activeGame) { if ($activeGame) {
return response()->json(['message' => 'You are already in another active game.'], 400); return response()->json(["message" => "You already have an active game."], 400);
} }
$game->update([ $game = Game::create([
'status' => 'Playing', 'type' => $request->type,
'player2_user_id' => $user->id, '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([ return response()->json([
'message' => 'Joined successfully! Game starts now.', "message" => "Game created",
'game' => $game "game" => $game,
], 200); ], 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([
'player2_user_id' => $request->user()->id,
'status' => 'Playing',
]);
return response()->json($game);
} }
public function show(Game $game) public function show(Game $game)
{ {
$game->load(['player1', 'player2', 'winner']); $this->authorize('view', $game);
$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)
{ {
if ($game->status == 'Ended') { $this->authorize('update', $game);
return response()->json(['message' => 'Game already ended'], 400);
if ($game->status == "Ended") {
return response()->json(["message" => "Game 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_points' => 'integer', "player1_points" => "integer",
'player2_points' => 'integer', "player2_points" => "integer",
'is_draw' => 'boolean', "is_draw" => "boolean",
'total_time' => 'numeric' "total_time" => "numeric",
]); ]);
if (isset($data['status']) && $data['status'] === 'Ended') { if (isset($data["status"]) && $data["status"] === "Ended") {
$data['ended_at'] = now(); $data["ended_at"] = now();
} }
$game->update($data); $game->update($data);
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();
}
}
+311 -72
View File
@@ -3,26 +3,36 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MatchGame; use App\Models\MatchGame;
use App\Models\Game; use App\Models\CoinTransaction;
use App\Models\CoinTransactionType;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use App\Http\Requests\StoreMatchRequest; use App\Http\Requests\StoreMatchRequest;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB;
class MatchController extends Controller class MatchController extends Controller
{ {
public function index(Request $request) public function index(Request $request)
{ {
$query = MatchGame::query()->with(['winner', 'player1', 'player2']); $query = MatchGame::query()->with(["winner", "player1", "player2"]);
if ($request->has('type')) { if ($request->has("type")) {
$query->where('type', $request->type); $query->where("type", $request->type);
} }
if ($request->has('status')) { if ($request->has("status")) {
$query->where('status', $request->status); $query->where("status", $request->status);
} }
$query->orderBy('began_at', 'desc'); if ($request->has("pot") && in_array($request->pot, ["asc", "desc"])) {
$query->orderBy("stake", $request->pot);
} elseif (
$request->has("sort_direction") &&
in_array($request->sort_direction, ["asc", "desc"])
) {
$query->orderBy("began_at", $request->sort_direction);
} else {
$query->orderBy("began_at", "desc");
}
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
@@ -30,105 +40,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);
} }
$match = MatchGame::create([ $activeMatch = MatchGame::where(function ($q) use ($user) {
'type' => $validated['type'], $q->where("player1_user_id", $user->id)->orWhere(
'status' => 'Pending', "player2_user_id",
'player1_user_id' => $user->id, $user->id,
'player2_user_id' => $user->id, );
'winner_user_id' => $user->id, })
'loser_user_id' => $user->id, ->whereIn("status", ["Pending", "Playing"])
'stake' => $validated['stake'], ->exists();
'began_at' => now(),
'player1_marks' => 0,
'player2_marks' => 0,
'player1_points' => 0,
'player2_points' => 0,
'custom' => null
]);
return response()->json([ if ($activeMatch) {
'message' => 'Match created successfully', return response()->json(['message' => 'You already have an active match.'], 400);
'match' => $match }
], 201);
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([
"type" => $validated["type"],
"status" => "Pending",
"player1_user_id" => $user->id,
"player2_user_id" => $GHOST_ID,
"winner_user_id" => $GHOST_ID,
"loser_user_id" => $GHOST_ID,
"stake" => $stake,
"began_at" => now(),
"player1_marks" => 0,
"player2_marks" => 0,
"player1_points" => 0,
"player2_points" => 0,
]);
CoinTransaction::create([
"user_id" => $user->id,
"match_id" => $match->id,
"coin_transaction_type_id" => $stakeType->id,
"transaction_datetime" => now(),
"coins" => -$stake,
]);
return response()->json(
[
"message" => "Match created successfully",
"match" => $match,
"balance" => $user->coins_balance,
],
201,
);
});
} }
public function show(MatchGame $match) public function show(MatchGame $match)
{ {
$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,
);
} }
$match->update([ return DB::transaction(function () use ($match, $user) {
'status' => 'Playing', $user->decrement("coins_balance", $match->stake);
'player2_user_id' => $user->id
]);
return response()->json([ $stakeType = CoinTransactionType::firstOrCreate(
'message' => 'Joined match successfully!', ["name" => "Match stake"],
'match' => $match ["type" => "D"],
]); );
CoinTransaction::create([
'user_id' => $user->id,
'match_id' => $match->id,
'coin_transaction_type_id' => $stakeType->id,
'transaction_datetime' => now(),
'coins' => -$match->stake,
]);
$match->update([
"status" => "Playing",
"player2_user_id" => $user->id,
]);
return response()->json([
"message" => "Joined match successfully!",
"match" => $match,
"balance" => $user->coins_balance,
]);
});
} }
public function update(Request $request, MatchGame $match) public function update(Request $request, MatchGame $match)
{ {
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",
]); ]);
if (isset($data['status']) && $data['status'] === 'Ended') { return DB::transaction(function () use ($match, $data) {
$data['ended_at'] = now(); if (isset($data['status']) && $data['status'] === 'Ended') {
$data['ended_at'] = now();
}
$match->update($data);
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);
});
}
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,
);
} }
$match->update($data); $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();
return response()->json($match); 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
]);
});
}
}
@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers;
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
{
public function show(Request $request)
{
$user = $request->user();
return response()->json($user);
}
public function update(Request $request)
{
$request->validate([
"name" => "required|string|max:255",
"nickname" =>
"required|string|max:255|unique:users,nickname," .
$request->user()->id,
]);
$user = $request->user();
$user->name = $request->name;
$user->nickname = $request->nickname;
$user->save();
return response()->json($user);
}
public function destroy(User $user)
{
$currentUser = Auth::user();
if ($currentUser->id !== $user->id && $currentUser->type !== "A") {
return response()->json(["message" => "Unauthorized"], 403);
}
$user->delete();
return response()->json(["message" => "User deleted successfully"]);
}
public function updatePassword(Request $request)
{
$request->validate([
"current_password" => "required",
"new_password" => "required|min:8|confirmed",
]);
$user = $request->user();
if (!Hash::check($request->current_password, $user->password)) {
return response()->json(
[
"message" => "Current password is incorrect",
],
422,
);
}
$user->password = Hash::make($request->new_password);
$user->save();
return response()->json([
"message" => "Password changed successfully",
]);
}
public function getCoins(Request $request)
{
return response()->json([
'coins' => $request->user()->coins_balance
]);
}
public function uploadAvatar(Request $request)
{
$request->validate([
"avatar" => "required|image|mimes:jpeg,png,jpg,gif|max:2048",
]);
$user = $request->user();
if ($user->photo_avatar_filename) {
Storage::disk("public")->delete(
"photos_avatars/" . $user->photo_avatar_filename,
);
}
$file = $request->file("avatar");
$filename =
str_pad($user->id, 5, "0", STR_PAD_LEFT) .
"_" .
Str::random(10) .
"." .
$file->extension();
$file->storeAs("photos_avatars", $filename, "public");
$user->photo_avatar_filename = $filename;
$user->save();
return response()->json($user);
}
}
@@ -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);
}
}
+287 -55
View File
@@ -2,8 +2,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Game; use App\Models\Game;
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,25 +18,51 @@ class UserController extends Controller
*/ */
public function index(Request $request) public function index(Request $request)
{ {
if ($request->user()->type !== 'A') { $this->authorize("viewAny", User::class);
return response()->json(['message' => 'Unauthorized'], 403);
}
$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')) {
$query->where('name', 'like', '%' . $request->search . '%') if ($request->has("blocked")) {
->orWhere('email', 'like', '%' . $request->search . '%') $query->where("blocked", $request->type);
->orWhere('nickname', 'like', '%' . $request->search . '%'); }
if ($request->has("search")) {
$query->where(function ($q) use ($request) {
$q->where("name", "like", "%" . $request->search . "%")
->orWhere("email", "like", "%" . $request->search . "%")
->orWhere("nickname", "like", "%" . $request->search . "%");
});
} }
return response()->json($query->paginate(15)); return response()->json($query->paginate(15));
} }
/**
* POST /api/users
* Registar um novo utilizador (Admin only)
*/
public function store(Request $request)
{
$this->authorize("create", User::class);
$validated = $request->validate([
"name" => "required|string|max:255",
"email" => "required|email|unique:users,email",
"password" => "required|string|min:3",
"type" => "required|in:A,U",
]);
$validated["password"] = Hash::make($validated["password"]);
$user = User::create($validated);
return response()->json($user, 201);
}
/** /**
* GET /api/users/me * GET /api/users/me
* Retorna o perfil do utilizador autenticado atual. * Retorna o perfil do utilizador autenticado atual.
@@ -53,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);
} }
/** /**
@@ -72,24 +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' => ['sometimes', 'string', 'max:20', Rule::unique('users')->ignore($user->id)], "nickname" => [
'email' => ['sometimes', 'email', Rule::unique('users')->ignore($user->id)], "sometimes",
'password' => 'sometimes|string|min:3', "string",
'blocked' => 'sometimes|boolean', "max:20",
Rule::unique("users")->ignore($user->id),
],
"email" => [
"sometimes",
"email",
Rule::unique("users")->ignore($user->id),
],
"password" => "sometimes|string|min:3",
]); ]);
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');
@@ -101,50 +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(
"player2_user_id",
$user->id,
);
})->exists();
if ($hasTransactions || $hasGames) {
$user->delete(); // Soft delete
$message =
"User account deactivated (soft-deleted due to transaction/game history)";
} else {
$user->forceDelete(); // Hard delete
$message = "User permanently deleted";
} }
if ($currentUser->id === $user->id && $currentUser->type === 'A') { return response()->json(["message" => $message]);
if ($user->type === 'A') { }
return response()->json(['message' => 'Admins cannot delete their own account'], 403);
} /**
* 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);
} }
$user->delete(); if ($request->has("status")) {
$query->where("status", $request->status);
}
return response()->json(['message' => 'User deleted successfully']); // 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); ->where(function ($q) use ($user) {
$q->where("player1_user_id", $user->id)->orWhere(
"player2_user_id",
$user->id,
);
})
->with(["winner", "player1", "player2"]);
if ($request->has("type")) {
$query->where("type", $request->type);
} }
$matches = Game::query() if ($request->has("status")) {
->where(function($q) use ($user) { $query->where("status", $request->status);
$q->where('player1_user_id', $user->id) }
->orWhere('player2_user_id', $user->id);
}) // Date filtering
->with(['winner', 'player1', 'player2']) if ($request->has("date_from")) {
->orderBy('began_at', 'desc') $query->whereDate("began_at", ">=", $request->date_from);
->paginate(10); }
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());
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckUserType
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(
Request $request,
Closure $next,
string $type,
): Response {
$user = $request->user();
if (!$user || $user->type !== $type) {
return response()->json(
["message" => "Unauthorized. User type {$type} required."],
403,
);
}
return $next($request);
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ class RegisterRequest extends FormRequest
'nickname' => 'required|string|max:255', 'nickname' => 'required|string|max:255',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'password' => 'required|string|min:3', 'password' => 'required|string|min:3',
'photo' => 'nullable|string', 'photo' => 'nullable|image|mimes:jpeg,png,jpg,gif|max:2048',
]; ];
} }
} }
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinPurchase extends Model
{
use HasFactory;
protected $table = 'coin_purchases';
public $timestamps = false;
protected $fillable = [
'purchase_datetime',
'user_id',
'coin_transaction_id',
'euros',
'payment_type',
'payment_reference',
'custom'
];
protected $casts = [
'purchase_datetime' => 'datetime',
'custom' => 'array',
];
public function transaction()
{
return $this->belongsTo(CoinTransaction::class, 'coin_transaction_id');
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class CoinTransaction extends Model
{
use HasFactory;
protected $table = 'coin_transactions';
public $timestamps = false;
protected $fillable = [
'transaction_datetime',
'user_id',
'match_id',
'game_id',
'coin_transaction_type_id',
'coins',
'custom'
];
protected $casts = [
'transaction_datetime' => 'datetime',
'custom' => 'array',
];
public function type()
{
return $this->belongsTo(CoinTransactionType::class, 'coin_transaction_type_id');
}
public function user()
{
return $this->belongsTo(User::class);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
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
];
}
+11 -1
View File
@@ -3,7 +3,6 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Http\Requests\StoreGameRequest;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -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');
+25 -16
View File
@@ -2,7 +2,6 @@
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -11,30 +10,27 @@ use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
use HasFactory, Notifiable, HasApiTokens, SoftDeletes;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
'password', 'password',
'nickname', 'nickname',
'photo_avatar_filename',
'type', 'type',
'blocked', 'photo_avatar_filename',
'coins_balance', 'coins_balance',
'custom',
]; ];
/** /**
* The attributes that should be hidden for serialization. * The attributes that should be hidden for serialization.
* *
* @var list<string> * @var array<int, string>
*/ */
protected $hidden = [ protected $hidden = [
'password', 'password',
@@ -42,16 +38,29 @@ class User extends Authenticatable
]; ];
/** /**
* Get the attributes that should be cast. * The attributes that should be cast.
* *
* @return array<string, string> * @var array<string, string>
*/ */
protected function casts(): array protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'blocked' => 'boolean',
'deleted_at' => 'datetime',
];
public function wonGames()
{ {
return [ return $this->hasMany(Game::class, 'winner_user_id');
'email_verified_at' => 'datetime', }
'password' => 'hashed',
'blocked' => 'boolean', 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;
}
}
+132
View File
@@ -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;
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
namespace App\Policies;
use App\Models\User;
class UserPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return $user->type === 'A';
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, User $model): bool
{
return $user->id === $model->id || $user->type === 'A';
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->type === 'A';
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, User $model): bool
{
return $user->id === $model->id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, User $model): bool
{
return ($user->type === 'A' && $user->id !== $model->id) || ($user->type === 'P' && $user->id === $model->id);
}
public function block(User $user, User $model): bool
{
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
}
public function unblock(User $user, User $model): bool
{
return $user->type === 'A' && $user->id !== $model->id && $model->type === 'P';
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, User $model): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, User $model): bool
{
return false;
}
}
+9 -6
View File
@@ -6,14 +6,17 @@ use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__ . "/../routes/web.php",
api: __DIR__.'/../routes/api.php', api: __DIR__ . "/../routes/api.php",
commands: __DIR__.'/../routes/console.php', commands: __DIR__ . "/../routes/console.php",
health: '/up', health: "/up",
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->alias([
"user.type" => App\Http\Middleware\CheckUserType::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //
})->create(); })
->create();
+3
View File
@@ -102,12 +102,15 @@
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider', 'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
-9392
View File
File diff suppressed because it is too large Load Diff
View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

+85 -27
View File
@@ -2,37 +2,95 @@
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\GameController; use App\Http\Controllers\GameController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\MatchController; use App\Http\Controllers\MatchController;
use Illuminate\Http\Request; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\StatisticsController;
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 () {
// Public Routes
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
# Authentication Routes Route::get('/statistics/public', [StatisticsController::class, 'getPublicStats']);
Route::post('/login', [AuthController::class, 'login']); Route::get('/leaderboard', [StatisticsController::class, 'getLeaderboard']);
Route::middleware('auth:sanctum')->group(function () { // Authenticated Routes
Route::get('/users/me', function (Request $request) { Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
return $request->user(); 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::get('/', [ProfileController::class, 'show']);
Route::put('/', [ProfileController::class, 'update']);
Route::patch('/password', [ProfileController::class, 'updatePassword']);
Route::delete('/', [ProfileController::class, 'destroy']);
Route::get('/coins', [ProfileController::class, 'getCoins']);
Route::put('/password', [
ProfileController::class,
'updatePassword',
]);
Route::post('/avatar', [ProfileController::class, 'uploadAvatar']);
Route::get('/transactions', [UserController::class, 'getMyTransactions']);
});
// User Resources
Route::prefix('/users')->group(function () {
Route::get('/{user}', [UserController::class, 'show']);
Route::get('/{user}/transactions', [UserController::class, 'getTransactions']);
Route::get('/{user}/games', [UserController::class, 'getGames']);
Route::get('/{user}/matches', [UserController::class, 'getMatches']);
});
Route::prefix('games')->group(function () {
Route::post('host', [GameController::class, 'host']);
Route::get('open', [GameController::class, 'open']);
Route::get('/', [GameController::class, 'index']);
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}/join', [GameController::class, 'join']);
});
Route::prefix('matches')->group(function () {
Route::post('host', [MatchController::class, 'host']);
Route::get('open', [MatchController::class, 'open']);
Route::post('/{match}/join', [MatchController::class, 'join']);
Route::post('/{match}/start', [MatchController::class, 'start']); // Changed to /start to be explicit
Route::delete('/{match}', [MatchController::class, 'destroy']);
Route::apiResource('/', MatchController::class)->parameters(['' => 'match']);
});
// Admin Routes
Route::middleware('user.type:A')
->prefix('admin')
->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::get('/', [UserController::class, 'index']);
Route::post('/', [UserController::class, 'store']);
Route::put('/{user}', [UserController::class, 'update']);
Route::delete('/{user}', [
UserController::class,
'destroy',
]);
Route::post('/{user}/block', [
UserController::class,
'block',
]);
Route::post('/{user}/unblock', [
UserController::class,
'unblock',
]);
});
});
}); });
Route::post('logout', [AuthController::class, 'logout']);
}); });
Route::post('/register', [AuthController::class, 'register']);
# Game Routes - Protected
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('games', GameController::class);
Route::post('games', [GameController::class, 'store']);
Route::post('/games/{game}/resign', [GameController::class, 'resign']);
Route::get('/users/{user}/matches', [UserController::class, 'getMatches']);
Route::post('games/{game}/join', [GameController::class, 'join']);
});
Route::middleware('auth:sanctum')->group(function () {
Route::get('matches', [MatchController::class, 'index']);
Route::post('matches', [MatchController::class, 'store']);
Route::get('matches/{match}', [MatchController::class, 'show']);
Route::post('matches/{match}/join', [MatchController::class, 'join']);
Route::put('matches/{match}', [MatchController::class, 'update']);
});
+84
View File
@@ -0,0 +1,84 @@
### Get All Students (Login)
# @name login
POST http://localhost:8000/api/v1/login
Content-Type: application/json
Accept: application/json
{
"email": "[email protected]",
"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}}
+20
View File
@@ -0,0 +1,20 @@
meta {
name: Get Users
type: http
seq: 1
}
get {
url: {{api_url}}/admin/users/
body: none
auth: inherit
}
headers {
Authorization: Bearer {{token}}
}
settings {
encodeUrl: true
timeout: 0
}
+8
View File
@@ -0,0 +1,8 @@
meta {
name: Admin
seq: 4
}
auth {
mode: inherit
}
+2 -2
View File
@@ -5,14 +5,14 @@ meta {
} }
post { post {
url: {{api_url}}/auth/login url: {{api_url}}/login
body: json body: json
auth: none auth: none
} }
body:json { body:json {
{ {
"email":"a1@mail.pt", "email":"p[email protected]",
"password":"123" "password":"123"
} }
} }
+23
View File
@@ -0,0 +1,23 @@
meta {
name: Patch Password
type: http
seq: 3
}
patch {
url: {{api_url}}/users/me
body: json
auth: none
}
headers {
Accept: application/json
Authorization: Bearer {{token}}
}
body:json {
{
"current_password":"123",
"new_password": "456"
}
}
+15
View File
@@ -0,0 +1,15 @@
meta {
name: Create Game
type: http
seq: 3
}
post {
url: {{api_url}}/games
body: none
auth: inherit
}
settings {
encodeUrl: true
}
+1 -1
View File
@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: {{api_url}}/games url: {{api_url}}/games/1
body: none body: none
auth: none auth: none
} }
@@ -1,5 +1,5 @@
vars { vars {
base_url: http://localhost:8085 base_url: http://localhost:8000
api_url: http://localhost:8085/api api_url: http://localhost:8000/api/v1
token: token:
} }
+1 -1
View File
@@ -17,7 +17,7 @@ spec:
priorityClassName: low-priority priorityClassName: low-priority
containers: containers:
- name: web - name: web
image: registry-172.22.21.115.sslip.io/dad-group-x/web:v1.0.0 image: registry-172.22.21.115.sslip.io/dad-group-46/web:v1.0.1
resources: resources:
requests: requests:
memory: "64Mi" memory: "64Mi"
+1 -1
View File
@@ -1,2 +1,2 @@
VITE_API_DOMAIN=localhost:8000 VITE_API_DOMAIN=localhost:8000
VITE_WS_CONNECTION=ws://localhost:3000 VITE_WS_CONNECTION=ws://localhost:3001
+4 -5
View File
@@ -9,13 +9,12 @@
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide",
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib"
"composables": "@/composables"
}, },
"registries": {} "iconLibrary": "lucide"
} }
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/index.css" /> <link rel="stylesheet" href="/src/index.css" />
<title>Vite App</title> <title>Bisca</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
-5260
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -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",
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

+14 -21
View File
@@ -3,8 +3,7 @@
<nav class="max-w-full p-5 flex flex-row justify-between align-middle"> <nav class="max-w-full p-5 flex flex-row justify-between align-middle">
<div class="align-middle text-xl"> <div class="align-middle text-xl">
<RouterLink to="/"> {{ pageTitle }} </RouterLink> <RouterLink to="/"> {{ pageTitle }} </RouterLink>
<span class="text-xs" v-if="authStore.currentUser">&nbsp;&nbsp;&nbsp; <span class="text-xs" v-if="authStore.currentUser">&nbsp;&nbsp;&nbsp; ({{ authStore.currentUser?.name }})
({{ authStore.currentUser?.name }})
</span> </span>
</div> </div>
<NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" /> <NavBar @logout="logout" :userLoggedIn="authStore.isLoggedIn" />
@@ -17,41 +16,35 @@
</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: () => {
return 'Logout Sucessfull ' return 'Logout Sucessfull '
}, },
error: (data) => `[API] Error saving game - ${data?.response?.data?.message}`, error: (data) => `[API] Error - ${data?.response?.data?.message}`,
}) })
} }
onMounted(() => { onMounted(async () => {
socketStore.handleConnection() await authStore.restoreSession()
// REMOVED - The WebSocket for multiplayer is handled separately in the WebSocketStore
// socketStore.handleConnection()
}) })
</script> </script>
<style></style>` <style></style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="relative flex items-center justify-center w-64 h-48">
<div
v-if="trumpCard"
class="absolute rotate-90 origin-center transition-all duration-700"
:class="[trumpReveal && 'scale-110']"
>
<div
class="ring-2 ring-amber-400/60 ring-offset-2 rounded-lg transition-all duration-700"
:class="[
trumpReveal &&
'ring-4 ring-amber-400 shadow-[0_0_30px_rgba(251,191,36,0.8)] animate-pulse',
]"
>
<GameCard :suit="trumpCard.suit" :rank="trumpCard.rank" />
</div>
<div
v-if="trumpReveal"
class="absolute -bottom-8 left-1/2 -translate-x-1/2 bg-amber-500/90 text-white text-[10px] font-bold px-3 py-1 rounded-full whitespace-nowrap animate-pulse"
>
TRUMP
</div>
</div>
<div
v-if="!isEmpty && cardsRemaining > 0"
class="absolute -translate-x-8 -translate-y-4 transition-all duration-300"
:class="[cardsRemaining === 0 && 'opacity-0 scale-95', deckPulse && 'scale-105']"
>
<div class="relative">
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-20 translate-x-1 translate-y-1"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-15 translate-x-2 translate-y-2"
></div>
<div
class="absolute inset-0 bg-gray-800 rounded-lg opacity-10 translate-x-3 translate-y-3"
></div>
<div class="relative">
<GameCard suit="c" :rank="1" :face-down="true" />
<div
class="absolute -top-2 -right-2 bg-blue-600 text-white text-xs font-bold rounded-full w-8 h-8 flex items-center justify-center shadow-lg border-2 border-white transition-all duration-300"
>
<Transition
enter-active-class="transition-all duration-200"
enter-from-class="scale-150 opacity-0"
enter-to-class="scale-100 opacity-100"
leave-active-class="transition-all duration-200"
leave-from-class="scale-100 opacity-100"
leave-to-class="scale-50 opacity-0"
mode="out-in"
>
<span :key="cardsRemaining">{{ cardsRemaining }}</span>
</Transition>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
trumpCard: {
type: Object,
default: null,
},
cardsRemaining: {
type: Number,
required: true,
},
isEmpty: {
type: Boolean,
required: true,
},
revealTrump: {
type: Boolean,
default: false,
},
})
const deckPulse = ref(false)
const trumpReveal = ref(false)
// Pulse animation when cards remaining changes
watch(
() => props.cardsRemaining,
() => {
deckPulse.value = true
setTimeout(() => {
deckPulse.value = false
}, 300)
},
)
// Trump reveal animation
watch(
() => props.revealTrump,
(newValue) => {
if (newValue) {
trumpReveal.value = true
setTimeout(() => {
trumpReveal.value = false
}, 3000) // Show for 3 seconds
}
},
)
</script>
@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<div
v-if="isVisible"
class="fixed pointer-events-none z-[9999]"
:style="{
left: currentPosition.x + 'px',
top: currentPosition.y + 'px',
transition: isAnimating ? `all ${duration}ms ease-out` : 'none',
}"
>
<GameCard :suit="card.suit" :rank="card.rank" :face-down="faceDown" />
</div>
</Teleport>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import GameCard from './GameCard.vue'
const props = defineProps({
card: {
type: Object,
required: true,
},
startPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
endPosition: {
type: Object,
required: true,
validator: (value) => 'x' in value && 'y' in value,
},
duration: {
type: Number,
default: 500,
},
faceDown: {
type: Boolean,
default: true,
},
delay: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['complete'])
const isVisible = ref(false)
const isAnimating = ref(false)
const currentPosition = ref({ x: 0, y: 0 })
onMounted(() => {
// Start at the deck position
currentPosition.value = { ...props.startPosition }
isVisible.value = true
// Wait for delay, then start animation
setTimeout(() => {
// Force a reflow to ensure starting position is set
requestAnimationFrame(() => {
isAnimating.value = true
currentPosition.value = { ...props.endPosition }
// After animation completes, emit event and hide
setTimeout(() => {
emit('complete')
isVisible.value = false
}, props.duration)
})
}, props.delay)
})
</script>
@@ -0,0 +1,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>
+39
View File
@@ -0,0 +1,39 @@
<template>
<div
class="relative w-32 aspect-[2.5/3.5] cursor-pointer transition-all duration-300 ease-in-out hover:scale-105 hover:-translate-y-2 hover:shadow-2xl"
>
<img
:src="cardImage"
:alt="faceDown ? 'Card back' : `${suit} ${rank}`"
class="w-full h-full object-cover rounded-lg shadow-lg select-none"
draggable="false"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
suit: {
type: String,
required: true,
},
rank: {
type: Number,
required: true,
},
faceDown: {
type: Boolean,
default: false,
},
})
const cardImage = computed(() => {
if (props.faceDown) {
return '/cards/semFace.png'
} else {
return `/cards/${props.suit}${props.rank}.png`
}
})
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isVisible"
class="fixed inset-0 bg-black/70 backdrop-blur-sm z-[9998] flex items-center justify-center p-4"
@click.self="handleClose"
>
<Transition
enter-active-class="transition-all duration-500 ease-out delay-100"
enter-from-class="opacity-0 scale-75 -translate-y-10"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-90"
>
<div
v-if="isVisible"
class="bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 rounded-2xl shadow-2xl border-2 max-w-md w-full p-8 relative overflow-hidden"
:class="[
winner === 'player'
? 'border-emerald-500/50'
: winner === 'opponent'
? 'border-rose-500/50'
: 'border-gray-600/50',
]"
>
<!-- Confetti Effect (if player wins) -->
<div
v-if="winner === 'player' && showConfetti"
class="absolute inset-0 pointer-events-none"
>
<div
v-for="i in 30"
:key="i"
class="absolute w-2 h-2 animate-confetti"
:style="{
left: Math.random() * 100 + '%',
top: '-10px',
backgroundColor: ['#fbbf24', '#34d399', '#60a5fa', '#f472b6', '#a78bfa'][
Math.floor(Math.random() * 5)
],
animationDelay: Math.random() * 2 + 's',
animationDuration: 3 + Math.random() * 2 + 's',
}"
></div>
</div>
<!-- Winner Icon/Badge -->
<div class="flex justify-center mb-6">
<div
class="relative"
:class="[winner === 'player' ? 'animate-bounce' : 'animate-pulse']"
>
<div
v-if="winner === 'player'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-emerald-400 to-emerald-600 flex items-center justify-center shadow-lg shadow-emerald-500/50"
>
<span class="text-5xl">🏆</span>
</div>
<div
v-else-if="winner === 'opponent'"
class="w-24 h-24 rounded-full bg-gradient-to-br from-rose-400 to-rose-600 flex items-center justify-center shadow-lg shadow-rose-500/50"
>
<span class="text-5xl">😔</span>
</div>
<div
v-else
class="w-24 h-24 rounded-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center shadow-lg"
>
<span class="text-5xl">🤝</span>
</div>
</div>
</div>
<!-- Title -->
<h2
class="text-4xl font-black text-center mb-2 bg-clip-text text-transparent bg-gradient-to-r"
:class="[
winner === 'player'
? 'from-emerald-300 to-emerald-500'
: winner === 'opponent'
? 'from-rose-300 to-rose-500'
: 'from-gray-300 to-gray-500',
]"
>
{{ title }}
</h2>
<!-- Subtitle -->
<p class="text-center text-gray-400 mb-8">{{ subtitle }}</p>
<!-- Scores -->
<div class="bg-black/30 rounded-xl p-6 mb-8 space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ playerName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
playerScore > opponentScore
? 'text-emerald-400 drop-shadow-[0_0_12px_rgba(52,211,153,0.6)]'
: 'text-white',
]"
>
{{ displayPlayerScore }}
</span>
</div>
<div class="h-px bg-gray-700"></div>
<div class="flex items-center justify-between">
<span class="text-gray-300 font-medium">{{ opponentName }}</span>
<span
class="text-3xl font-black tabular-nums transition-all duration-500"
:class="[
opponentScore > playerScore
? 'text-rose-400 drop-shadow-[0_0_12px_rgba(251,113,133,0.6)]'
: 'text-white',
]"
>
{{ displayOpponentScore }}
</span>
</div>
</div>
<!-- Stats (optional) -->
<div v-if="stats" class="grid grid-cols-2 gap-4 mb-8">
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Your Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.playerTricks }}</p>
</div>
<div class="bg-black/20 rounded-lg p-3 text-center">
<p class="text-gray-400 text-xs mb-1">Opponent Tricks</p>
<p class="text-2xl font-bold text-white">{{ stats.opponentTricks }}</p>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3">
<button
v-if="!isLoggingOut"
@click="$emit('play-again')"
class="flex-1 bg-gradient-to-r from-emerald-500 to-emerald-600 hover:from-emerald-600 hover:to-emerald-700 text-white font-bold py-3 px-6 rounded-lg transition-all shadow-lg"
>
Play Again
</button>
<button
v-if="!hideClose"
@click="handleClose"
class="flex-1 font-bold py-3 px-6 rounded-lg transition-all"
:class="[
isLoggingOut
? 'bg-red-600 hover:bg-red-700 text-white w-full'
: 'bg-gray-700 hover:bg-gray-600 text-white',
]"
>
{{ isLoggingOut ? 'Confirm Logout' : 'Close' }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
const props = defineProps({
isVisible: {
type: Boolean,
required: true,
},
winner: {
type: String,
required: true,
validator: (value) => ['player', 'opponent', 'draw'].includes(value),
},
playerScore: {
type: Number,
required: true,
},
opponentScore: {
type: Number,
required: true,
},
playerName: {
type: String,
default: 'You',
},
opponentName: {
type: String,
default: 'Bot',
},
stats: {
type: Object,
default: null,
},
hideClose: {
type: Boolean,
default: false,
},
isLoggingOut: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['close', 'play-again'])
const showConfetti = ref(false)
const displayPlayerScore = ref(0)
const displayOpponentScore = ref(0)
// Computed title based on winner
const title = computed(() => {
if (props.winner === 'player') return 'Victory!'
if (props.winner === 'opponent') return 'Defeat'
return 'Draw!'
})
// Computed subtitle
const subtitle = computed(() => {
if (props.winner === 'player') return 'Congratulations! You won the game!'
if (props.winner === 'opponent') return 'Better luck next time!'
return 'The game ended in a draw'
})
// Animate score count-up
const animateScore = (fromValue, toValue, callback) => {
const duration = 1000
const steps = 30
const stepDuration = duration / steps
const increment = (toValue - fromValue) / steps
let current = fromValue
let step = 0
const interval = setInterval(() => {
step++
current += increment
if (step >= steps) {
current = toValue
clearInterval(interval)
}
callback(Math.round(current))
}, stepDuration)
}
// Watch for visibility and trigger animations
watch(
() => props.isVisible,
(newValue) => {
if (newValue) {
// Start confetti if player wins
if (props.winner === 'player') {
setTimeout(() => {
showConfetti.value = true
}, 300)
}
// Animate scores counting up
setTimeout(() => {
animateScore(0, props.playerScore, (value) => {
displayPlayerScore.value = value
})
animateScore(0, props.opponentScore, (value) => {
displayOpponentScore.value = value
})
}, 400)
} else {
showConfetti.value = false
displayPlayerScore.value = 0
displayOpponentScore.value = 0
}
},
{ immediate: true },
)
const handleClose = () => {
emit('close')
}
</script>
<style scoped>
@keyframes confetti {
0% {
transform: translateY(0) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(720deg);
opacity: 0;
}
}
.animate-confetti {
animation: confetti linear forwards;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<template>
<div class="flex items-center justify-center h-64 relative">
<div class="relative w-96 h-full flex items-center justify-center">
<!-- Opponent Card Slot -->
<div
class="absolute top-4"
:class="[
winner === 'opponent' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: opponentCard ? (firstPlayer === 'opponent' ? 1 : 2) : 0 }"
>
<div
v-if="!opponentCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">Opponent</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 -translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div
v-if="opponentCard"
:key="`${opponentCard.suit}-${opponentCard.rank}`"
class="rotate-6"
>
<GameCard :suit="opponentCard.suit" :rank="opponentCard.rank" />
</div>
</Transition>
</div>
<!-- Player Card Slot -->
<div
class="absolute bottom-4"
:class="[
winner === 'player' && 'ring-4 ring-green-400 ring-offset-4 rounded-lg animate-pulse',
]"
:style="{ zIndex: playerCard ? (firstPlayer === 'player' ? 1 : 2) : 0 }"
>
<div
v-if="!playerCard"
class="w-32 aspect-[2.5/3.5] border-4 border-dashed border-gray-400/40 rounded-lg bg-gray-100/20 flex items-center justify-center"
>
<span class="text-gray-400 text-xs font-medium">You</span>
</div>
<Transition
enter-active-class="transition-all duration-500 ease-out"
enter-from-class="opacity-0 translate-y-[200px] scale-75"
enter-to-class="opacity-100 translate-y-0 scale-100 -rotate-6"
leave-active-class="transition-all duration-500 ease-in"
:leave-from-class="`opacity-100 translate-y-0 scale-100 -rotate-6`"
:leave-to-class="
winner === 'opponent'
? 'opacity-0 -translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
: 'opacity-0 translate-y-[200px] translate-x-[300px] scale-50 rotate-0'
"
mode="out-in"
>
<div v-if="playerCard" :key="`${playerCard.suit}-${playerCard.rank}`" class="-rotate-6">
<GameCard :suit="playerCard.suit" :rank="playerCard.rank" />
</div>
</Transition>
</div>
</div>
</div>
</template>
<script setup>
import GameCard from './GameCard.vue'
const props = defineProps({
playerCard: {
type: Object,
default: null,
},
opponentCard: {
type: Object,
default: null,
},
winner: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
firstPlayer: {
type: String,
default: null,
validator: (value) => [null, 'player', 'opponent'].includes(value),
},
})
</script>
+112
View File
@@ -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>
+69 -37
View File
@@ -1,50 +1,82 @@
<template> <template>
<div> <div>
<NavigationMenu> <NavigationMenu>
<NavigationMenuList class="justify-around gap-20"> <NavigationMenuList class="justify-around gap-20">
<NavigationMenuItem> <NavigationMenuItem v-if="!userLoggedIn">
<NavigationMenuTrigger>Testing</NavigationMenuTrigger> <NavigationMenuLink>
<NavigationMenuContent> <RouterLink to="/login">Login</RouterLink>
<li> </NavigationMenuLink>
<NavigationMenuLink as-child> </NavigationMenuItem>
<RouterLink to="/testing/laravel">Laravel</RouterLink>
</NavigationMenuLink> <NavigationMenuItem v-else class="flex flex-row gap-4">
<NavigationMenuLink as-child> <NavigationMenuLink v-if="userStore.coins > 0" href="/coins-purchase"
<RouterLink to="/testing/websockets">Web Sockets</RouterLink> class="flex flex-row items-center gap-1">
</NavigationMenuLink> <div class="flex items-center justify-center w-5 h-5 bg-purple-400 rounded-full shadow-sm">
</li> <span class="text-[10px] font-bold text-purple-900">$</span>
</NavigationMenuContent> </div>
</NavigationMenuItem>
<NavigationMenuItem v-if="!userLoggedIn"> <span class="text-sm font-medium tabular-nums">
<NavigationMenuLink> {{ userStore.coins }} <span class="text-muted-foreground text-xs">Coins</span>
<RouterLink to="/login">Login</RouterLink> </span>
</NavigationMenuLink> </NavigationMenuLink>
</NavigationMenuItem> <NavigationMenuLink>
<NavigationMenuItem v-else> <RouterLink to="/user">Profile</RouterLink>
<NavigationMenuLink> </NavigationMenuLink>
<a @click.prevent="logoutClickHandler">Logout</a> <NavigationMenuLink>
</NavigationMenuLink> <a href="/home" @click.prevent="logoutClickHandler" class="cursor-pointer">Logout</a>
</NavigationMenuItem> </NavigationMenuLink>
</NavigationMenuList> </NavigationMenuItem>
</NavigationMenu> </NavigationMenuList>
</div> </NavigationMenu>
</div>
</template> </template>
<script setup> <script setup>
import { useBiscaStore } from '@/stores/bisca'
import { import {
NavigationMenu, NavigationMenu,
NavigationMenuContent, NavigationMenuContent,
NavigationMenuItem, NavigationMenuItem,
NavigationMenuLink, NavigationMenuLink,
NavigationMenuList, NavigationMenuList,
NavigationMenuTrigger, NavigationMenuTrigger,
} from '@/components/ui/navigation-menu' } from '@/components/ui/navigation-menu'
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 = () => {
emits('logout') 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')
router.push('/login')
} }
const userStore = useUserStore()
watch(
() => userLoggedIn,
async (isLoggedIn) => {
if (isLoggedIn) {
await userStore.fetchCoins()
} else {
userStore.coins = 0
}
}, { immediate: true })
</script> </script>
+21
View File
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,13 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,16 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>
+7
View File
@@ -0,0 +1,7 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";

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