diff --git a/Project.App/Project.Api/.env.development b/Project.App/Project.Api/.env.development new file mode 100644 index 0000000..4111b6a --- /dev/null +++ b/Project.App/Project.Api/.env.development @@ -0,0 +1,2 @@ +PORT=5131 +ASPNETCORE_ENVIRONMENT=Production \ No newline at end of file diff --git a/Project.App/Project.Api/Controllers/AuthController.cs b/Project.App/Project.Api/Controllers/AuthController.cs index 0bfa8fd..b282155 100644 --- a/Project.App/Project.Api/Controllers/AuthController.cs +++ b/Project.App/Project.Api/Controllers/AuthController.cs @@ -18,7 +18,12 @@ public IActionResult Login([FromQuery] string? returnUrl = null) Console.WriteLine($"[AUTH] Login called with returnUrl: {returnUrl}"); // Allow frontend URLs (localhost:3000 or your production frontend) - var allowedOrigins = new[] { "http://localhost:3000", "https://localhost:3000" }; + var allowedOrigins = new[] + { + "http://localhost:3000", + "https://localhost:3000", + "https://dannyscasino-abhqdhcwabdaccfg.westus3-01.azurewebsites.net/", // production frontend + }; var safe = "/swagger"; // default fallback if (!string.IsNullOrEmpty(returnUrl)) diff --git a/Project.App/Project.Api/Controllers/HandController.cs b/Project.App/Project.Api/Controllers/HandController.cs index 61935dd..1f0cf9e 100644 --- a/Project.App/Project.Api/Controllers/HandController.cs +++ b/Project.App/Project.Api/Controllers/HandController.cs @@ -1,4 +1,5 @@ using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Project.Api.DTOs; using Project.Api.Models; @@ -8,6 +9,7 @@ namespace Project.Api.Controllers { [ApiController] [Route("api/rooms/{roomId}/hands")] + [Authorize] // Require authentication for all hand endpoints public class HandController : ControllerBase { private readonly ILogger _logger; diff --git a/Project.App/Project.Api/Controllers/RoomController.cs b/Project.App/Project.Api/Controllers/RoomController.cs index bf0503f..669c7c5 100644 --- a/Project.App/Project.Api/Controllers/RoomController.cs +++ b/Project.App/Project.Api/Controllers/RoomController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Project.Api.DTOs; using Project.Api.Models.Games; +using Project.Api.Repositories.Interface; using Project.Api.Services.Interface; using Project.Api.Utilities; using Project.Api.Utilities.Enums; @@ -11,6 +12,7 @@ namespace Project.Api.Controllers; [ApiController] [Route("api/[controller]")] +[Authorize] // Require authentication for all room endpoints public class RoomController( IRoomService roomService, IRoomSSEService roomSSEService, @@ -207,6 +209,28 @@ [FromBody] LeaveRoomRequest request return Ok(room); } + // GET: api/room/{roomId}/players + [HttpGet("{roomId}/players")] + public async Task GetRoomPlayers( + Guid roomId, + [FromServices] IRoomPlayerRepository roomPlayerRepository + ) + { + var players = await roomPlayerRepository.GetByRoomIdAsync(roomId); + var playerDtos = players.Select(p => new + { + id = p.Id, + userId = p.UserId, + userName = p.User?.Name ?? "Unknown", + userEmail = p.User?.Email ?? "", + role = p.Role.ToString(), + status = p.Status.ToString(), + balance = p.Balance, + balanceDelta = p.BalanceDelta, + }); + return Ok(playerDtos); + } + #region SSE private readonly IRoomSSEService _roomSSEService = roomSSEService; @@ -216,6 +240,7 @@ [FromBody] LeaveRoomRequest request /// /// /// + [AllowAnonymous] [HttpGet("{roomId}/events")] public async Task GetRoomEvents(Guid roomId) { diff --git a/Project.App/Project.Api/Program.cs b/Project.App/Project.Api/Program.cs index 619fcba..c999f25 100644 --- a/Project.App/Project.Api/Program.cs +++ b/Project.App/Project.Api/Program.cs @@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Google; using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.EntityFrameworkCore; using Project.Api.Data; using Project.Api.Models.Games; @@ -22,7 +23,6 @@ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); - // use adminsetting.json as configuration builder.Configuration.AddJsonFile( "adminsetting.json", optional: true, @@ -52,14 +52,39 @@ public static void Main(string[] args) builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); + // if (File.Exists(".env.development")) + // { + // DotNetEnv.Env.Load(".env.development"); + // } + var app = builder.Build(); if (!builder.Environment.IsEnvironment("Testing")) app.ApplyMigrations(); // there's no database in testing + var fwd = new ForwardedHeadersOptions + { + ForwardedHeaders = ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedFor, + }; + fwd.KnownNetworks.Clear(); + fwd.KnownProxies.Clear(); + app.UseForwardedHeaders(fwd); + app.UseMiddleware(); - // connection string print + // var port = Environment.GetEnvironmentVariable("PORT") ?? "80"; + // var httpsPort = 7069; + + // if (!File.Exists(".env.development")) + // { + // app.Urls.Add($"http://*:{port}"); + // } + // else + // { + // app.Urls.Add($"http://localhost:{port}"); + // app.Urls.Add($"https://localhost:{httpsPort}"); + // } + app.MapGet( "/string", () => @@ -69,7 +94,6 @@ public static void Main(string[] args) } ); - // is development envrionment if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -77,7 +101,7 @@ public static void Main(string[] args) app.UseSwaggerUI(); } - app.UseCors(ProgramExtensions.CorsPolicy); // Enable CORS with our policy + app.UseCors(ProgramExtensions.CorsPolicy); app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); @@ -91,9 +115,6 @@ public static class ProgramExtensions { public const string CorsPolicy = "FrontendCors"; - /// - /// Applies the configuration for the CORS policy. - /// public static IServiceCollection AddCorsPolicy(this IServiceCollection services) { services.AddCors(options => @@ -103,19 +124,20 @@ public static IServiceCollection AddCorsPolicy(this IServiceCollection services) policy => { policy - .WithOrigins("http://localhost:3000", "https://localhost:3000") // Next.js frontend + .WithOrigins( + "http://localhost:3000", + "https://localhost:3000", + "https://dannyscasino-abhqdhcwabdaccfg.westus3-01.azurewebsites.net" + ) .AllowAnyHeader() .AllowAnyMethod() - .AllowCredentials(); // Required for cookies + .AllowCredentials(); } ); }); return services; } - /// - /// Registers the database. - /// public static IServiceCollection AddDatabase( this IServiceCollection services, IConfiguration configuration @@ -127,9 +149,6 @@ IConfiguration configuration return services; } - /// - /// Registers the services and repositories used by the application. - /// public static IServiceCollection AddApplicationServices(this IServiceCollection services) { // scoped services @@ -138,32 +157,25 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); - // singleton services services.AddHttpClient(); services.AddSingleton(); - // scoped repositories services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); - // automapper!!! services.AddAutoMapper(typeof(Program)); return services; } - /// - /// Configures and registers the authentication services. - /// public static IServiceCollection AddAuth( this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment ) { - // sanity check for auth errors logging if (!environment.IsEnvironment("Testing")) { var gid = configuration["Google:ClientId"]; @@ -180,24 +192,16 @@ IWebHostEnvironment environment ); } - // Google OAuth services .AddAuthentication(options => { - // where the app reads identity from on each request options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; - // how the app prompts an unauthenticated user to log in - // you can set DefaultChallengeScheme to Cookies instead but it didnt work before options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme; }) .AddCookie(cookie => { - // cross-site cookie SPA on :3000 to API on :7069 cookie.Cookie.SameSite = SameSiteMode.None; - // browsers require Secure when SameSite=None cookie.Cookie.SecurePolicy = CookieSecurePolicy.Always; - - // for APIs return status codes instead of 302 redirects cookie.Events = new CookieAuthenticationEvents { OnRedirectToLogin = ctx => @@ -219,16 +223,12 @@ IWebHostEnvironment environment options.CallbackPath = "/auth/google/callback"; options.Scope.Add("email"); options.Scope.Add("profile"); - - // ensure we actually fetch profile claims from Google UserInfo endpoint options.SaveTokens = true; options.UserInformationEndpoint = "https://www.googleapis.com/oauth2/v3/userinfo"; options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email"); options.ClaimActions.MapJsonKey(ClaimTypes.Name, "name"); options.ClaimActions.MapJsonKey("picture", "picture"); options.ClaimActions.MapJsonKey("email_verified", "email_verified"); - - // Upsert user into DB when Google signs in successfully options.Events = new OAuthEvents { OnCreatingTicket = async ctx => @@ -239,7 +239,7 @@ IWebHostEnvironment environment // fetch the full user JSON from Google's userinfo endpoint var request = new HttpRequestMessage( - System.Net.Http.HttpMethod.Get, + HttpMethod.Get, ctx.Options.UserInformationEndpoint ); request.Headers.Authorization = diff --git a/Project.App/Project.Api/Project.Api.csproj b/Project.App/Project.Api/Project.Api.csproj index 69ee4b5..483dca8 100644 --- a/Project.App/Project.Api/Project.Api.csproj +++ b/Project.App/Project.Api/Project.Api.csproj @@ -7,6 +7,7 @@ + diff --git a/Project.App/Project.Api/Project.Api.sln b/Project.App/Project.Api/Project.Api.sln deleted file mode 100644 index 968b159..0000000 --- a/Project.App/Project.Api/Project.Api.sln +++ /dev/null @@ -1,24 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Project.Api", "Project.Api.csproj", "{5723D184-E559-9629-4150-262D1C87CB3B}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {5723D184-E559-9629-4150-262D1C87CB3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5723D184-E559-9629-4150-262D1C87CB3B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5723D184-E559-9629-4150-262D1C87CB3B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5723D184-E559-9629-4150-262D1C87CB3B}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A7C11195-A030-40B1-8566-3C3D268D064E} - EndGlobalSection -EndGlobal diff --git a/Project.App/Project.Api/Services/DeckApiService.cs b/Project.App/Project.Api/Services/DeckApiService.cs index 651f3dc..24a4ef0 100644 --- a/Project.App/Project.Api/Services/DeckApiService.cs +++ b/Project.App/Project.Api/Services/DeckApiService.cs @@ -6,9 +6,12 @@ namespace Project.Api.Services; -public class DeckApiService(HttpClient client) : IDeckApiService +public class DeckApiService(HttpClient client, IConfiguration? configuration = null) + : IDeckApiService { private readonly HttpClient _httpClient = client; + private readonly string _baseApiUrl = + configuration?["DeckApiSettings:BaseUrl"] ?? "https://deckofcardsapi.com/api"; private readonly JsonSerializerOptions _jsonSerializerOptions = new() { @@ -16,8 +19,6 @@ public class DeckApiService(HttpClient client) : IDeckApiService NumberHandling = JsonNumberHandling.AllowReadingFromString, }; - private const string BASE_API_URL = "https://deckofcardsapi.com/api"; - /// /// Create a new shuffled deck and return the deck ID. /// The deck will consist of specified number of standard decks shuffled together and two Jokers if enabled. @@ -29,7 +30,7 @@ public class DeckApiService(HttpClient client) : IDeckApiService public async Task CreateDeck(int numOfDecks = 6, bool enableJokers = false) { string url = - $"{BASE_API_URL}/deck/new/shuffle/?deck_count={numOfDecks}&enable_Jokers={enableJokers}"; + $"{_baseApiUrl}/deck/new/shuffle/?deck_count={numOfDecks}&enable_Jokers={enableJokers}"; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) { @@ -49,7 +50,7 @@ public async Task CreateDeck(int numOfDecks = 6, bool enableJokers = fal /// true if successful public async Task CreateEmptyHand(string deckId, string handName) { - string url = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/add/?cards="; + string url = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/add/?cards="; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) { @@ -92,7 +93,7 @@ public async Task> DrawCards(string deckId, string handName, int c } // draw cards from deck - string drawUrl = $"{BASE_API_URL}/deck/{deckId}/draw/?count={count}"; + string drawUrl = $"{_baseApiUrl}/deck/{deckId}/draw/?count={count}"; var drawResponse = await _httpClient.GetAsync(drawUrl); drawResponse.EnsureSuccessStatusCode(); @@ -123,7 +124,7 @@ public async Task> DrawCards(string deckId, string handName, int c /// true if successful public async Task ReturnAllCardsToDeck(string deckId, bool shuffle = true) { - string url = $"{BASE_API_URL}/deck/{deckId}/{(shuffle ? "shuffle" : "return")}/"; + string url = $"{_baseApiUrl}/deck/{deckId}/{(shuffle ? "shuffle" : "return")}/"; var response = await _httpClient.GetAsync(url); if (!response.IsSuccessStatusCode) @@ -139,8 +140,7 @@ public async Task ReturnAllCardsToDeck(string deckId, bool shuffle = true) /// true if successful public async Task AddToHand(string deckId, string handName, string cardCodes) { - string addToPileUrl = - $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/add/?cards={cardCodes}"; + string addToPileUrl = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/add/?cards={cardCodes}"; var addResponse = await _httpClient.GetAsync(addToPileUrl); if (!addResponse.IsSuccessStatusCode) { @@ -156,7 +156,7 @@ public async Task AddToHand(string deckId, string handName, string cardCod public async Task RemoveFromHand(string deckId, string handName, string cardCodes) { string removeFromPileUrl = - $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/draw/?cards={cardCodes}"; + $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/draw/?cards={cardCodes}"; var removeResponse = await _httpClient.GetAsync(removeFromPileUrl); if (!removeResponse.IsSuccessStatusCode) { @@ -171,7 +171,7 @@ public async Task RemoveFromHand(string deckId, string handName, string ca /// A list of card DTOs public async Task> ListHand(string deckId, string handName) { - string listPileUrl = $"{BASE_API_URL}/deck/{deckId}/pile/{handName}/list/"; + string listPileUrl = $"{_baseApiUrl}/deck/{deckId}/pile/{handName}/list/"; var listResponse = await _httpClient.GetAsync(listPileUrl); listResponse.EnsureSuccessStatusCode(); diff --git a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs index e11d456..9bbb62d 100644 --- a/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs +++ b/Project.App/Project.Api/Services/Interface/IRoomSSEService.cs @@ -3,7 +3,7 @@ namespace Project.Api.Services.Interface; -public interface IRoomSSEService +public interface IRoomSSEService : IDisposable { /// /// Adds a new client connection for a specific room and keeps it open. @@ -14,4 +14,9 @@ public interface IRoomSSEService /// Broadcasts an event to all clients connected to a specific room. /// Task BroadcastEventAsync(Guid roomId, RoomEventType eventName, IRoomEventData data); + + /// + /// Closes all SSE connections for graceful shutdown. + /// + Task CloseAllConnectionsAsync(); } diff --git a/Project.App/Project.Api/Services/RoomSSEService.cs b/Project.App/Project.Api/Services/RoomSSEService.cs index 8168100..bace225 100644 --- a/Project.App/Project.Api/Services/RoomSSEService.cs +++ b/Project.App/Project.Api/Services/RoomSSEService.cs @@ -69,6 +69,21 @@ out ConcurrentDictionary? connections // broadcast event using lowercase event type string eventPayload = $"event: {eventType.ToString().ToLowerInvariant()}\ndata: {JsonSerializer.Serialize(data, data.GetType())}\n\n"; + + // // Use camelCase naming to match ASP.NET Core controller responses + // var options = new JsonSerializerOptions + // { + // PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + // }; + // string serializedData = JsonSerializer.Serialize(data, options); + // Console.WriteLine( + // $"[SSE] Broadcasting to room {roomId}: event={eventName}, data length={serializedData.Length}" + // ); + // Console.WriteLine( + // $"[SSE] Data preview: {serializedData.Substring(0, Math.Min(200, serializedData.Length))}..." + // ); + + // string eventPayload = $"event: {eventName}\ndata: {serializedData}\n\n"; List closedConnections = []; foreach ((string connectionId, StreamWriter writer) in connections) @@ -104,4 +119,34 @@ out ConcurrentDictionary? connections } } } + + public async Task CloseAllConnectionsAsync() + { + Console.WriteLine("[SSE] Closing all SSE connections for graceful shutdown..."); + + foreach (var roomConnections in _connections.Values) + { + foreach (var writer in roomConnections.Values) + { + try + { + await writer.DisposeAsync(); + } + catch + { + // Ignore errors during shutdown + } + } + roomConnections.Clear(); + } + _connections.Clear(); + + Console.WriteLine("[SSE] All SSE connections closed."); + } + + public void Dispose() + { + CloseAllConnectionsAsync().GetAwaiter().GetResult(); + GC.SuppressFinalize(this); + } } diff --git a/Project.App/Project.Api/Services/SSEShutdownService.cs b/Project.App/Project.Api/Services/SSEShutdownService.cs new file mode 100644 index 0000000..292d4c4 --- /dev/null +++ b/Project.App/Project.Api/Services/SSEShutdownService.cs @@ -0,0 +1,32 @@ +using Project.Api.Services.Interface; + +namespace Project.Api.Services; + +/// +/// Background service that ensures SSE connections are closed gracefully on application shutdown. +/// +public class SSEShutdownService : IHostedService +{ + private readonly IRoomSSEService _roomSSEService; + private readonly ILogger _logger; + + public SSEShutdownService(IRoomSSEService roomSSEService, ILogger logger) + { + _roomSSEService = roomSSEService; + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SSE Shutdown Service started"); + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("SSE Shutdown Service stopping - closing all SSE connections"); + await _roomSSEService.CloseAllConnectionsAsync(); + _logger.LogInformation("SSE Shutdown Service stopped"); + return; + } +} diff --git a/Project.App/Project.Client/Dockerfile b/Project.App/Project.Client/Dockerfile index 093678d..0d42de3 100644 --- a/Project.App/Project.Client/Dockerfile +++ b/Project.App/Project.Client/Dockerfile @@ -1,16 +1,17 @@ +# Stage 1: Build FROM node:24-alpine AS build -# set working directory -WORKDIR /src +# Set working directory inside container +WORKDIR /src/app -# install dependencies -COPY package.json package-lock.json ./ +# Copy package files and install dependencies +COPY package*.json ./ RUN npm ci -# copy frontend code -COPY . . +# Copy all source code (including src/app) +COPY . . -# build +# Build the Next.js app RUN npm run build # --- run frontend from nginx server (static) --- @@ -32,12 +33,12 @@ RUN npm run build FROM node:24-alpine AS run # set working directory -WORKDIR /app +WORKDIR /src/app # copy built files from build -COPY --from=build /src/.next ./.next +COPY --from=build /src/app/.next ./.next # copy static files -COPY --from=build /src/public ./public +COPY --from=build /src/app/public ./public # install dependencies (without dev deps) COPY package.json package-lock.json ./ diff --git a/Project.App/Project.Client/jsconfig.json b/Project.App/Project.Client/jsconfig.json index b8d6842..77093ef 100644 --- a/Project.App/Project.Client/jsconfig.json +++ b/Project.App/Project.Client/jsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": ["./node_modules/@types"] } } diff --git a/Project.App/Project.Client/next.config.mjs b/Project.App/Project.Client/next.config.mjs index f79577e..da2b777 100644 --- a/Project.App/Project.Client/next.config.mjs +++ b/Project.App/Project.Client/next.config.mjs @@ -1,8 +1,17 @@ /** @type {import('next').NextConfig} */ const nextConfig = { // output: 'export', // Commented out to support dynamic routes like /player/[id] + images: { - domains: ['www.shutterstock.com', 'lh3.googleusercontent.com'], + domains: ['www.shutterstock.com', 'lh3.googleusercontent.com'], + }, + // Disable SSL certificate validation in development + // WARNING: Only use this in development, never in production + webpack: (config, { isServer }) => { + if (isServer) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; + } + return config; }, }; diff --git a/Project.App/Project.Client/nginx.conf b/Project.App/Project.Client/nginx.conf deleted file mode 100644 index b4f465c..0000000 --- a/Project.App/Project.Client/nginx.conf +++ /dev/null @@ -1 +0,0 @@ -# add nginx config here if necessary diff --git a/Project.App/Project.Client/package.json b/Project.App/Project.Client/package.json index 2b9af80..dcbf5e3 100644 --- a/Project.App/Project.Client/package.json +++ b/Project.App/Project.Client/package.json @@ -6,6 +6,7 @@ "dev": "next dev", "build": "next build", "start": "next start", + "export": "next export", "lint": "oxlint . -D correctness --ignore-path .gitignore", "lint:fix": "oxlint . --fix -D correctness --ignore-path .gitignore", "format": "prettier --write .", diff --git a/Project.App/Project.Client/src/app/components/AddCreditsModal.js b/Project.App/Project.Client/src/app/components/AddCreditsModal.js index bbdcd9a..07bacc9 100644 --- a/Project.App/Project.Client/src/app/components/AddCreditsModal.js +++ b/Project.App/Project.Client/src/app/components/AddCreditsModal.js @@ -1,6 +1,14 @@ 'use client'; -export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd, setCreditsToAdd, onSubmit }) { +export default function AddCreditsModal({ + isOpen, + onClose, + balance, + creditsToAdd, + setCreditsToAdd, + onSubmit, + isLoading, +}) { if (!isOpen) return null; return ( @@ -23,11 +31,11 @@ export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd type="number" step="10" min="0" - max="200000" value={creditsToAdd} onChange={(e) => setCreditsToAdd(e.target.value)} placeholder="0" - className="w-full mt-1 px-4 py-3 bg-black/50 border-2 border-yellow-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500" + disabled={isLoading} + className="w-full mt-1 px-4 py-3 bg-black/50 border-2 border-yellow-600 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-yellow-500 focus:border-yellow-500 disabled:opacity-50 disabled:cursor-not-allowed" required /> @@ -35,14 +43,16 @@ export default function AddCreditsModal({ isOpen, onClose, balance, creditsToAdd
diff --git a/Project.App/Project.Client/src/app/components/CreateGameForm.js b/Project.App/Project.Client/src/app/components/CreateGameForm.js index f64f858..5c28a51 100644 --- a/Project.App/Project.Client/src/app/components/CreateGameForm.js +++ b/Project.App/Project.Client/src/app/components/CreateGameForm.js @@ -1,34 +1,104 @@ 'use client'; import { useState } from 'react'; +import { useRouter } from 'next/navigation'; -export default function CreateGameForm() { - const [roomName, setRoomName] = useState(''); - const [minBet, setMinBet] = useState(''); - const [maxPlayers, setMaxPlayers] = useState(5); - const [roomNameError, setRoomNameError] = useState(''); +export default function CreateGameForm({ userId, onRoomCreated }) { + const router = useRouter(); const [roomDescription, setRoomDescription] = useState(''); + const [minBet, setMinBet] = useState('10'); + const [startingBalance, setStartingBalance] = useState('1000'); + const [maxPlayers, setMaxPlayers] = useState(5); + const [formError, setFormError] = useState(''); + const [isCreating, setIsCreating] = useState(false); - const handleSubmit = (e) => { + const handleSubmit = async (e) => { e.preventDefault(); - // Validate roomName length - const name = roomName.trim(); - if (name.length > 20) { - setRoomNameError('Room name must be 20 characters or less.'); - const description = roomDescription.trim(); - if (description.length > 100) { - setRoomNameError('Room description must be 100 characters or less.'); - return; + + const description = roomDescription.trim(); + if (description.length > 500) { + setFormError('Room description must be 500 characters or less.'); + return; + } + + if (!description) { + setFormError('Room description is required.'); + return; + } + + const minBetValue = parseInt(minBet); + const startingBalanceValue = parseInt(startingBalance); + + if (minBetValue < 0) { + setFormError('Minimum bet cannot be negative.'); + return; + } + + if (minBetValue > startingBalanceValue) { + setFormError('Minimum bet cannot exceed starting balance.'); + return; + } + + setFormError(''); + setIsCreating(true); + + try { + const API_URL = process.env.NEXT_PUBLIC_API_URL || 'https://localhost:7069'; + + // Create initial game state for Blackjack + const initialGameState = JSON.stringify({ + currentStage: { + $type: 'init', + }, + dealerHand: [], + }); + + // Create game config with BlackjackConfig structure + const gameConfig = JSON.stringify({ + startingBalance: startingBalanceValue, + minBet: minBetValue, + bettingTimeLimit: '00:01:00', // 1 minute + turnTimeLimit: '00:00:30', // 30 seconds + }); + + const response = await fetch(`${API_URL}/api/room`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + hostId: userId, + description: description, + isPublic: true, + gameMode: 'Blackjack', + gameState: initialGameState, + gameConfig: gameConfig, + maxPlayers: maxPlayers, + minPlayers: 1, + deckId: '', + }), + }); + + if (!response.ok) { + const error = await response.json(); + console.error('Server error response:', error); + throw new Error(error.message || error.title || 'Failed to create room'); } + + const room = await response.json(); + + // Refresh the rooms list + if (onRoomCreated) { + onRoomCreated(); + } + + // Redirect to the game room immediately + router.push(`/game/${room.id}`); + } catch (error) { + console.error('Error creating room:', error); + setFormError(error.message); + setIsCreating(false); } - setRoomNameError(''); - - // Dummy submit - alert( - `Game created! Room: ${name} Description: ${roomDescription} (min bet: $${minBet}, max players: ${maxPlayers})`, - ); - setRoomName(''); - setMinBet(''); - setMaxPlayers(5); }; return ( @@ -39,52 +109,50 @@ export default function CreateGameForm() {

Create New Game

+ {formError && ( +
{formError}
+ )}
- - Room Description (max 500 chars) +