From fe3928bbc78f1cbc209eefbefcc23303d3a14581 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 26 Dec 2025 08:00:04 -0600 Subject: [PATCH 01/21] feat: setup initial create payment endpoint --- .../package-lock.json | 64 ++++++++++++++++++- src/GroundsForSupport.Client/package.json | 2 + .../GroundsForSupport.Server.csproj | 1 + src/GroundsForSupport.Server/Program.cs | 17 +++++ .../ExampleTests.cs | 10 --- .../GroundsForSupport.API.Tests.csproj | 2 + .../Integration/Infra/TestApi.cs | 7 ++ .../Integration/PaymentTests.cs | 25 ++++++++ 8 files changed, 117 insertions(+), 11 deletions(-) delete mode 100644 tests/GroundsForSupport.Server.Tests/ExampleTests.cs create mode 100644 tests/GroundsForSupport.Server.Tests/Integration/Infra/TestApi.cs create mode 100644 tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs diff --git a/src/GroundsForSupport.Client/package-lock.json b/src/GroundsForSupport.Client/package-lock.json index be14638..8dfdda5 100644 --- a/src/GroundsForSupport.Client/package-lock.json +++ b/src/GroundsForSupport.Client/package-lock.json @@ -8,6 +8,8 @@ "name": "groundsforsupport-client", "version": "0.0.0", "dependencies": { + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1123,6 +1125,29 @@ "dev": true, "license": "MIT" }, + "node_modules/@stripe/react-stripe-js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-5.4.1.tgz", + "integrity": "sha512-ipeYcAHa4EPmjwfv0lFE+YDVkOQ0TMKkFWamW+BqmnSkEln/hO8rmxGPPWcd9WjqABx6Ro8Xg4pAS7evCcR9cw==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=8.0.0 <9.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-8.6.0.tgz", + "integrity": "sha512-EB0/GGgs4hfezzkiMkinlRgWtjz8fSdwVQhwYS7Sg/RQrSvuNOz+ssPjD+lAzqaYTCB0zlbrt0fcqVziLJrufQ==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -2851,7 +2876,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -3262,6 +3286,18 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3403,6 +3439,15 @@ "dev": true, "license": "MIT" }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3620,6 +3665,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", diff --git a/src/GroundsForSupport.Client/package.json b/src/GroundsForSupport.Client/package.json index 04f08ca..d9c8a5e 100644 --- a/src/GroundsForSupport.Client/package.json +++ b/src/GroundsForSupport.Client/package.json @@ -12,6 +12,8 @@ "test": "vitest --coverage" }, "dependencies": { + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.6.0", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj b/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj index f5531b4..7b6b496 100644 --- a/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj +++ b/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj @@ -8,6 +8,7 @@ + diff --git a/src/GroundsForSupport.Server/Program.cs b/src/GroundsForSupport.Server/Program.cs index ed49d31..45b5c9e 100644 --- a/src/GroundsForSupport.Server/Program.cs +++ b/src/GroundsForSupport.Server/Program.cs @@ -9,6 +9,23 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } +// We need a /create-payment-intent endpoint + +app.MapPost("/create-payment-intent", static () => +{ + // we need to get the amount and email + // from the frontend + + // we need to validate the amount and email + + // we are going to use the Stripe API to create + // a payment intent + + // return the secret for that intent + // to the frontend + return Results.Ok("Hello"); +}); + app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/tests/GroundsForSupport.Server.Tests/ExampleTests.cs b/tests/GroundsForSupport.Server.Tests/ExampleTests.cs deleted file mode 100644 index 7f219a9..0000000 --- a/tests/GroundsForSupport.Server.Tests/ExampleTests.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace GroundsForSupport.Server.Tests; - -public class ExampleTests -{ - [Fact] - public void SampleTest() - { - Assert.True(true); - } -} \ No newline at end of file diff --git a/tests/GroundsForSupport.Server.Tests/GroundsForSupport.API.Tests.csproj b/tests/GroundsForSupport.Server.Tests/GroundsForSupport.API.Tests.csproj index 6e71ac6..31a8799 100644 --- a/tests/GroundsForSupport.Server.Tests/GroundsForSupport.API.Tests.csproj +++ b/tests/GroundsForSupport.Server.Tests/GroundsForSupport.API.Tests.csproj @@ -14,6 +14,7 @@ + @@ -26,6 +27,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/tests/GroundsForSupport.Server.Tests/Integration/Infra/TestApi.cs b/tests/GroundsForSupport.Server.Tests/Integration/Infra/TestApi.cs new file mode 100644 index 0000000..913d514 --- /dev/null +++ b/tests/GroundsForSupport.Server.Tests/Integration/Infra/TestApi.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace GroundsForSupport.API.Tests.Integration.Infra; + +public sealed class TestApi : WebApplicationFactory +{ +} diff --git a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs new file mode 100644 index 0000000..5651db7 --- /dev/null +++ b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs @@ -0,0 +1,25 @@ +using System.Net; +using System.Net.Http.Json; +using GroundsForSupport.API.Tests.Integration.Infra; + +namespace GroundsForSupport.API.Tests.Integration; + +public sealed class PaymentTests(TestApi api) : IClassFixture +{ + private readonly TestApi _api = api; + + [Fact] + public async Task CreatePaymentIntent_WhenGivenInvalidAmount_ItShouldReturnBadRequest() + { + var client = _api.CreateClient(); + + var request = new { + amount = 0, + email = string.Empty, + }; + + var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } +} \ No newline at end of file From 2df6bff9a7428f585e7e494f0e2059cb7299842a Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 27 Dec 2025 08:38:31 -0600 Subject: [PATCH 02/21] feat: use stripe sdk to create intent --- .editorconfig | 5 + src/GroundsForSupport.Server/Program.cs | 149 +++++++++++++++--- .../appsettings.Example.json | 5 + tests/.editorconfig | 2 + .../Integration/PaymentTests.cs | 36 ++++- 5 files changed, 178 insertions(+), 19 deletions(-) create mode 100644 src/GroundsForSupport.Server/appsettings.Example.json create mode 100644 tests/.editorconfig diff --git a/.editorconfig b/.editorconfig index 937b121..bc9a7ae 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,11 @@ insert_final_newline = false #### .NET Coding Conventions #### [*.{cs,vb}] +# Analyzer severity levels +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.IDE3000.severity = none + # Organize usings dotnet_separate_import_directive_groups = true dotnet_sort_system_directives_first = true diff --git a/src/GroundsForSupport.Server/Program.cs b/src/GroundsForSupport.Server/Program.cs index 45b5c9e..5daa99e 100644 --- a/src/GroundsForSupport.Server/Program.cs +++ b/src/GroundsForSupport.Server/Program.cs @@ -1,7 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; + +using Microsoft.Extensions.Options; + +using Stripe; + var builder = WebApplication.CreateBuilder(args); builder.Services.AddOpenApi(); +builder.Services.AddValidation(); + +builder.Services.AddProblemDetails(); + +builder.Services.AddHttpClient(); + +builder.Services.ConfigureOptions(); +builder.Services.AddSingleton(); + +builder.Services.ConfigureHttpJsonOptions( + static options => options.SerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase +); + var app = builder.Build(); if (app.Environment.IsDevelopment()) @@ -9,26 +29,119 @@ if (app.Environment.IsDevelopment()) app.MapOpenApi(); } -// We need a /create-payment-intent endpoint - -app.MapPost("/create-payment-intent", static () => -{ - // we need to get the amount and email - // from the frontend - - // we need to validate the amount and email - - // we are going to use the Stripe API to create - // a payment intent - - // return the secret for that intent - // to the frontend - return Results.Ok("Hello"); -}); - app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseStatusCodePages(); + app.UseHttpsRedirection(); -app.Run(); \ No newline at end of file +app.MapPost("/create-payment-intent", CreatePaymentIntentHandler); + +app.Run(); + +static async Task CreatePaymentIntentHandler( + CreatePaymentIntentRequest request, + IStripeService stripeService +) +{ + var validationErrors = request.Validate(new ValidationContext(request)); + + if (validationErrors.Any()) + { + return Results.ValidationProblem(validationErrors + .GroupBy(static e => e.MemberNames.FirstOrDefault() ?? string.Empty) + .ToDictionary(static g => g.Key, static g => g.Select(static e => e.ErrorMessage ?? string.Empty).ToArray())); + } + + var (isSuccess, intent) = await stripeService.CreatePaymentIntentAsync(request.Amount, request.Email); + + if (isSuccess is false) + { + return Results.InternalServerError(); + } + + return Results.Ok(intent); +} + +internal sealed record CreatePaymentIntentRequest(decimal Amount, string? Email) +{ + public IEnumerable Validate(ValidationContext validationContext) + { + if (Amount <= 0) + { + yield return new ValidationResult("Amount must be greater than zero", [nameof(Amount)]); + } + + if (string.IsNullOrWhiteSpace(Email) is false && new EmailAddressAttribute().IsValid(Email) is false) + { + yield return new ValidationResult("Email is not valid", [nameof(Email)]); + } + } +} + +internal sealed record StripeOptions +{ + public string ApiKey { get; init; } = string.Empty; +} + +internal sealed record StripeOptionsSetup : IConfigureOptions +{ + private const string SectionName = nameof(StripeOptions); + private readonly IConfiguration _configuration; + + public StripeOptionsSetup(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Configure(StripeOptions options) + { + _configuration.GetSection(SectionName).Bind(options); + } +} + +internal sealed record Intent(string ClientSecret); + +internal interface IStripeService +{ + Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(decimal amount, string? email); +} + +internal sealed class StripeService( + IOptions options, + HttpClient httpClient +) : IStripeService +{ + private readonly StripeClient _client = new(options.Value.ApiKey, httpClient: new SystemNetHttpClient(httpClient)); + + public async Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(decimal amount, string? email) + { + try + { + var createOptions = new PaymentIntentCreateOptions + { + Amount = (long)(amount * 100), + Currency = "usd", + AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions + { + Enabled = true, + }, + }; + + if (string.IsNullOrWhiteSpace(email) is false) + { + createOptions.ReceiptEmail = email; + } + + var intent = await _client.V1.PaymentIntents.CreateAsync(createOptions); + + return (true, new Intent(intent.ClientSecret)); + } + catch (Exception) + { + // TODO: Log exception + return (false, new Intent(string.Empty)); + } + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/appsettings.Example.json b/src/GroundsForSupport.Server/appsettings.Example.json new file mode 100644 index 0000000..743e09a --- /dev/null +++ b/src/GroundsForSupport.Server/appsettings.Example.json @@ -0,0 +1,5 @@ +{ + "StripeOptions": { + "ApiKey": "ApiKey" + } +} diff --git a/tests/.editorconfig b/tests/.editorconfig new file mode 100644 index 0000000..79bfd7f --- /dev/null +++ b/tests/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.CA1707.severity = none diff --git a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs index 5651db7..86b1d85 100644 --- a/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs +++ b/tests/GroundsForSupport.Server.Tests/Integration/PaymentTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Http.Json; + using GroundsForSupport.API.Tests.Integration.Infra; namespace GroundsForSupport.API.Tests.Integration; @@ -13,7 +14,8 @@ public sealed class PaymentTests(TestApi api) : IClassFixture { var client = _api.CreateClient(); - var request = new { + var request = new + { amount = 0, email = string.Empty, }; @@ -22,4 +24,36 @@ public sealed class PaymentTests(TestApi api) : IClassFixture response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + + [Fact] + public async Task CreatePaymentIntent_WhenGivenInvalidEmail_ItShouldReturnBadRequest() + { + var client = _api.CreateClient(); + + var request = new + { + amount = 5000, + email = "invalid-email", + }; + + var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task CreatePaymentIntent_WhenGivenValidRequest_ItShouldReturnCreated() + { + var client = _api.CreateClient(); + + var request = new + { + amount = 5000, + email = "test@test.com", + }; + + var response = await client.PostAsJsonAsync("/create-payment-intent", request, TestContext.Current.CancellationToken); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } } \ No newline at end of file From 18b8dec3744092e81617e92566523b6c9fcc3028 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:09:17 -0600 Subject: [PATCH 03/21] chore: add project configuration and infrastructure This commit establishes the base configuration and deployment settings. --- .editorconfig | 1 + .github/workflows/deploy.yml | 6 +++++- .gitignore | 3 +++ src/Dockerfile | 2 ++ src/GroundsForSupport.Server/appsettings.Example.json | 6 +++++- 5 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.editorconfig b/.editorconfig index bc9a7ae..2e009f1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -27,6 +27,7 @@ insert_final_newline = false dotnet_diagnostic.IDE0058.severity = none dotnet_diagnostic.IDE0100.severity = none dotnet_diagnostic.IDE3000.severity = none +dotnet_diagnostic.CA1848.severity = none # Organize usings dotnet_separate_import_directive_groups = true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index cb7cf60..c991a48 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,7 +32,7 @@ jobs: - name: Build and push server image run: | TAG=${{ secrets.DOCKERHUB_USERNAME }}/groundsforsupport.stevanfreeborn.com:${{ steps.version.outputs.version }} - docker build -t $TAG src/ + docker build --build-arg VITE_STRIPE_API_KEY=${{ secrets.VITE_STRIPE_API_KEY }} -t $TAG src/ docker push $TAG deploy: name: Deploy to server @@ -60,5 +60,9 @@ jobs: --restart always \ -d \ -p 4343:8080 \ + -e StripeOptions__ApiKey=${{ secrets.STRIPE_API_KEY }} \ + -e StripeOptions__EventsWebhookSecret=${{ secrets.STRIPE_EVENTS_WEBHOOK_SECRET }} \ + -e ContextOptions__DatabaseFilePath=/app/data/groundsforsupport.db \ + -v /home/${{ secrets.SSH_USERNAME }}/groundsforsupport.stevanfreeborn.com/data:/app/data \ --name groundsforsupport.stevanfreeborn.com \ $TAG diff --git a/.gitignore b/.gitignore index f588023..ab2381a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ appsettings*.json !appsettings.Example.json wwwroot/ +*.db +*.db-shm +*.db-wal # dotenv files .env diff --git a/src/Dockerfile b/src/Dockerfile index 8fbac17..8c42e2c 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -12,6 +12,8 @@ RUN apt-get update && \ COPY ["GroundsForSupport.Server/GroundsForSupport.Server.csproj", "GroundsForSupport.Server/"] RUN dotnet restore "GroundsForSupport.Server/GroundsForSupport.Server.csproj" +ARG VITE_STRIPE_API_KEY +ENV VITE_STRIPE_API_KEY=$VITE_STRIPE_API_KEY COPY . . WORKDIR "/src/GroundsForSupport.Server" RUN dotnet build "GroundsForSupport.Server.csproj" -c Release -o /app/build diff --git a/src/GroundsForSupport.Server/appsettings.Example.json b/src/GroundsForSupport.Server/appsettings.Example.json index 743e09a..07f8b1e 100644 --- a/src/GroundsForSupport.Server/appsettings.Example.json +++ b/src/GroundsForSupport.Server/appsettings.Example.json @@ -1,5 +1,9 @@ { "StripeOptions": { - "ApiKey": "ApiKey" + "ApiKey": "ApiKey", + "EventsWebhookSecret": "EventsWebhookSecret" + }, + "ContextOptions": { + "DatabaseFilePath": "DatabaseFilePath" } } From 7ba17ea0e3f74edf6253f9450d9b7966a80e57c5 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:10:44 -0600 Subject: [PATCH 04/21] feat(server): add database context and migrations --- src/GroundsForSupport.Server/Data/Context.cs | 32 +++++++++++++ .../Data/ContextOptions.cs | 28 +++++++++++ .../Data/MigrationService.cs | 27 +++++++++++ .../GroundsForSupport.Server.csproj | 5 ++ ...0251230125831_AddPaymentsModel.Designer.cs | 34 ++++++++++++++ .../20251230125831_AddPaymentsModel.cs | 32 +++++++++++++ ...131814_AddAmountToPaymentModel.Designer.cs | 37 +++++++++++++++ .../20251231131814_AddAmountToPaymentModel.cs | 29 ++++++++++++ ...essagePropertiesToPaymentModel.Designer.cs | 44 +++++++++++++++++ ..._AddNameMessagePropertiesToPaymentModel.cs | 39 +++++++++++++++ ...atedAtTimeStampToPaymentsModel.Designer.cs | 47 +++++++++++++++++++ ...26_AddCreatedAtTimeStampToPaymentsModel.cs | 29 ++++++++++++ 12 files changed, 383 insertions(+) create mode 100644 src/GroundsForSupport.Server/Data/Context.cs create mode 100644 src/GroundsForSupport.Server/Data/ContextOptions.cs create mode 100644 src/GroundsForSupport.Server/Data/MigrationService.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.Designer.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.Designer.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.Designer.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.Designer.cs create mode 100644 src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.cs diff --git a/src/GroundsForSupport.Server/Data/Context.cs b/src/GroundsForSupport.Server/Data/Context.cs new file mode 100644 index 0000000..5672d7d --- /dev/null +++ b/src/GroundsForSupport.Server/Data/Context.cs @@ -0,0 +1,32 @@ +using GroundsForSupport.Server.Payments; + +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace GroundsForSupport.Server.Data; + +internal sealed class Context( + IOptions ctxOptions, + DbContextOptions options +) : DbContext(options) +{ + private const string DataSourceKey = "Data Source="; + private readonly ContextOptions _ctxOptions = ctxOptions.Value; + + public DbSet Payments => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath(); + var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined."); + + if (Directory.Exists(dbDirectory) is false) + { + Directory.CreateDirectory(dbDirectory); + } + + var connectionString = $"{DataSourceKey}{dbPath}"; + + optionsBuilder.UseSqlite(connectionString); + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Data/ContextOptions.cs b/src/GroundsForSupport.Server/Data/ContextOptions.cs new file mode 100644 index 0000000..1413737 --- /dev/null +++ b/src/GroundsForSupport.Server/Data/ContextOptions.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.Options; + +namespace GroundsForSupport.Server.Data; + +internal sealed record ContextOptions +{ + public string DatabaseFilePath { get; init; } = string.Empty; + public string GetFullyQualifiedDatabasePath() + { + return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory); + } +} + +internal sealed record ContextOptionsSetup : IConfigureOptions +{ + private const string SectionName = nameof(ContextOptions); + private readonly IConfiguration _configuration; + + public ContextOptionsSetup(IConfiguration configuration) + { + _configuration = configuration; + } + + public void Configure(ContextOptions options) + { + _configuration.GetSection(SectionName).Bind(options); + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/Data/MigrationService.cs b/src/GroundsForSupport.Server/Data/MigrationService.cs new file mode 100644 index 0000000..94714f2 --- /dev/null +++ b/src/GroundsForSupport.Server/Data/MigrationService.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; + +namespace GroundsForSupport.Server.Data; + +internal sealed class MigrationService( + IServiceProvider serviceProvider, + ILogger logger +) : IHostedService +{ + private readonly IServiceProvider _serviceProvider = serviceProvider; + + public async Task StartAsync(CancellationToken cancellationToken) + { + logger.LogInformation("Applying database migrations..."); + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(cancellationToken); + + logger.LogInformation("Database migrations applied."); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj b/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj index 7b6b496..74aa631 100644 --- a/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj +++ b/src/GroundsForSupport.Server/GroundsForSupport.Server.csproj @@ -8,6 +8,11 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.Designer.cs b/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.Designer.cs new file mode 100644 index 0000000..29df42f --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.Designer.cs @@ -0,0 +1,34 @@ +// +using GroundsForSupport.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + [DbContext(typeof(Context))] + [Migration("20251230125831_AddPaymentsModel")] + partial class AddPaymentsModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Payments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.cs b/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.cs new file mode 100644 index 0000000..92aae8d --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20251230125831_AddPaymentsModel.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + /// + public partial class AddPaymentsModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Payments", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Payments", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Payments"); + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.Designer.cs b/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.Designer.cs new file mode 100644 index 0000000..a0b15d4 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.Designer.cs @@ -0,0 +1,37 @@ +// +using GroundsForSupport.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + [DbContext(typeof(Context))] + [Migration("20251231131814_AddAmountToPaymentModel")] + partial class AddAmountToPaymentModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Payments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.cs b/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.cs new file mode 100644 index 0000000..1cb7a51 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20251231131814_AddAmountToPaymentModel.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + /// + public partial class AddAmountToPaymentModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Amount", + table: "Payments", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Amount", + table: "Payments"); + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.Designer.cs b/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.Designer.cs new file mode 100644 index 0000000..d9618c6 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.Designer.cs @@ -0,0 +1,44 @@ +// +using GroundsForSupport.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + [DbContext(typeof(Context))] + [Migration("20260102131600_AddNameMessagePropertiesToPaymentModel")] + partial class AddNameMessagePropertiesToPaymentModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Payments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.cs b/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.cs new file mode 100644 index 0000000..b1ac7d1 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20260102131600_AddNameMessagePropertiesToPaymentModel.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + /// + public partial class AddNameMessagePropertiesToPaymentModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Message", + table: "Payments", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Name", + table: "Payments", + type: "TEXT", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Message", + table: "Payments"); + + migrationBuilder.DropColumn( + name: "Name", + table: "Payments"); + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.Designer.cs b/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.Designer.cs new file mode 100644 index 0000000..5dfccda --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.Designer.cs @@ -0,0 +1,47 @@ +// +using GroundsForSupport.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + [DbContext(typeof(Context))] + [Migration("20260102140326_AddCreatedAtTimeStampToPaymentsModel")] + partial class AddCreatedAtTimeStampToPaymentsModel + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUnix") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Payments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.cs b/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.cs new file mode 100644 index 0000000..d693557 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/20260102140326_AddCreatedAtTimeStampToPaymentsModel.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + /// + public partial class AddCreatedAtTimeStampToPaymentsModel : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CreatedAtUnix", + table: "Payments", + type: "INTEGER", + nullable: false, + defaultValue: 0L); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CreatedAtUnix", + table: "Payments"); + } + } +} From 3e2e0b5690f19dd81013c57d60225de7a30127b9 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:11:09 -0600 Subject: [PATCH 05/21] feat(server): add rate limiting --- .../RateLimiting/Extensions.cs | 32 +++++++++++++++++++ .../RateLimiting/FixedRateLimitPolicy.cs | 20 ++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/GroundsForSupport.Server/RateLimiting/Extensions.cs create mode 100644 src/GroundsForSupport.Server/RateLimiting/FixedRateLimitPolicy.cs diff --git a/src/GroundsForSupport.Server/RateLimiting/Extensions.cs b/src/GroundsForSupport.Server/RateLimiting/Extensions.cs new file mode 100644 index 0000000..7a31aae --- /dev/null +++ b/src/GroundsForSupport.Server/RateLimiting/Extensions.cs @@ -0,0 +1,32 @@ +using System.Globalization; +using System.Threading.RateLimiting; + +namespace GroundsForSupport.Server.RateLimiting; + +internal static class Extensions +{ + public static void AddRateLimingPolicies(this IServiceCollection services) + { + services.AddRateLimiter(static o => + { + o.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + o.OnRejected = static (context, ct) => + { + var response = context.HttpContext.Response; + + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString(CultureInfo.InvariantCulture); + var resetTime = DateTimeOffset.UtcNow.Add(retryAfter); + var unixTimeMilliseconds = resetTime.ToUnixTimeMilliseconds(); + response.Headers.Append("X-RateLimit-Reset", unixTimeMilliseconds.ToString(CultureInfo.InvariantCulture)); + } + + return ValueTask.CompletedTask; + }; + + o.AddPolicy(FixedRateLimitPolicy.Name, FixedRateLimitPolicy.Partitioner); + }); + } +} \ No newline at end of file diff --git a/src/GroundsForSupport.Server/RateLimiting/FixedRateLimitPolicy.cs b/src/GroundsForSupport.Server/RateLimiting/FixedRateLimitPolicy.cs new file mode 100644 index 0000000..b0e9004 --- /dev/null +++ b/src/GroundsForSupport.Server/RateLimiting/FixedRateLimitPolicy.cs @@ -0,0 +1,20 @@ +using System.Threading.RateLimiting; + +namespace GroundsForSupport.Server.RateLimiting; + +internal static class FixedRateLimitPolicy +{ + public const string Name = "fixed"; + + public static Func> Partitioner => + static context => RateLimitPartition.GetFixedWindowLimiter( + partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: static partition => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromHours(1), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + } + ); +} \ No newline at end of file From 05686f193ffc4ebd7f9dfcbd94924ae965ef4834 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:11:31 -0600 Subject: [PATCH 06/21] feat(migrations): add ContextModelSnapshot for Payments entity --- .../Migrations/ContextModelSnapshot.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/GroundsForSupport.Server/Migrations/ContextModelSnapshot.cs diff --git a/src/GroundsForSupport.Server/Migrations/ContextModelSnapshot.cs b/src/GroundsForSupport.Server/Migrations/ContextModelSnapshot.cs new file mode 100644 index 0000000..2769218 --- /dev/null +++ b/src/GroundsForSupport.Server/Migrations/ContextModelSnapshot.cs @@ -0,0 +1,44 @@ +// +using GroundsForSupport.Server.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GroundsForSupport.Server.Migrations +{ + [DbContext(typeof(Context))] + partial class ContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.1"); + + modelBuilder.Entity("GroundsForSupport.Server.Payments.Payment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUnix") + .HasColumnType("INTEGER"); + + b.Property("Message") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Payments"); + }); +#pragma warning restore 612, 618 + } + } +} From 4e18abfa3dcaca6dc019b65746d62e65897c8c04 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 4 Jan 2026 21:12:08 -0600 Subject: [PATCH 07/21] feat(client): add payment components --- .../public/espresso.png | Bin 0 -> 3915 bytes .../src/CheckoutForm.tsx | 48 +++++ .../src/PaymentCard.tsx | 44 ++++ .../src/PaymentConfirmationCard.tsx | 100 +++++++++ .../src/PaymentForm.tsx | 200 ++++++++++++++++++ .../src/PreviousPaymentsList.tsx | 130 ++++++++++++ 6 files changed, 522 insertions(+) create mode 100644 src/GroundsForSupport.Client/public/espresso.png create mode 100644 src/GroundsForSupport.Client/src/CheckoutForm.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentCard.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx create mode 100644 src/GroundsForSupport.Client/src/PaymentForm.tsx create mode 100644 src/GroundsForSupport.Client/src/PreviousPaymentsList.tsx diff --git a/src/GroundsForSupport.Client/public/espresso.png b/src/GroundsForSupport.Client/public/espresso.png new file mode 100644 index 0000000000000000000000000000000000000000..a8ec147ef4cb82ccc46a0d88c2b2c5a858da9d13 GIT binary patch literal 3915 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 RFV_F=+FLtWRa*MT{|jvukO2Sy literal 0 HcmV?d00001 diff --git a/src/GroundsForSupport.Client/src/CheckoutForm.tsx b/src/GroundsForSupport.Client/src/CheckoutForm.tsx new file mode 100644 index 0000000..6e7cac6 --- /dev/null +++ b/src/GroundsForSupport.Client/src/CheckoutForm.tsx @@ -0,0 +1,48 @@ +import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'; +import { useState } from 'react'; + +export default function CheckoutForm() { + const [isSubmitting, setIsSubmitting] = useState(false); + const stripe = useStripe(); + const elements = useElements(); + const isLoading = !stripe || !elements; + + async function handleSubmit(e: React.FormEvent) { + setIsSubmitting(true); + + try { + e.preventDefault(); + + if (!stripe || !elements) { + return; + } + + const { error } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: window.location.origin, + }, + }); + + console.error(error); + alert(error?.message || 'An unexpected error occurred.'); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+ + + + ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentCard.tsx b/src/GroundsForSupport.Client/src/PaymentCard.tsx new file mode 100644 index 0000000..5179570 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentCard.tsx @@ -0,0 +1,44 @@ +export type Payment = { + name: string; + amount: number; + message?: string; + createdAtUnix: number; +}; + +export default function PaymentCard({ payment }: { payment: Payment }) { + const formattedPayment = payment.amount / 100; + + const date = new Date(payment.createdAtUnix); + const userLocale = navigator.language || 'en-US'; + const formattedDate = date.toLocaleDateString(userLocale, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + return ( +
+
+
+ Avatar +
+
+
+
{payment.name}
+
{formattedDate}
+
+
+
${formattedPayment.toFixed(2)}
+
+
+
+ {payment.message &&
{payment.message}
} +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx new file mode 100644 index 0000000..0290688 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentConfirmationCard.tsx @@ -0,0 +1,100 @@ +import { useStripe } from '@stripe/react-stripe-js'; +import type { PaymentIntent } from '@stripe/stripe-js'; +import { useEffect, useState } from 'react'; + +type PaymentConfirmationCardProps = { + clientSecret: string; +}; + +type PaymentIntentData = + | { + status: 'loading'; + } + | { + status: 'loaded'; + intent: PaymentIntent; + } + | { + status: 'error'; + error: string; + }; + +export default function PaymentConfirmationCard({ clientSecret }: PaymentConfirmationCardProps) { + const stripe = useStripe(); + + const [data, setData] = useState({ status: 'loading' }); + + useEffect(() => { + let isMounted = true; + + async function fetchPaymentIntent() { + if (!stripe) { + return; + } + + try { + const { paymentIntent, error } = await stripe.retrievePaymentIntent(clientSecret); + + if (!isMounted) return; + + if (error) { + setData({ status: 'error', error: error.message ?? 'Unknown error' }); + return; + } + + if (paymentIntent) { + setData({ status: 'loaded', intent: paymentIntent }); + return; + } + + setData({ status: 'error', error: 'PaymentIntent not found' }); + } catch (err) { + if (!isMounted) { + return; + } + + console.error(err); + setData({ status: 'error', error: (err as Error).message }); + } + } + + fetchPaymentIntent(); + + return () => { + isMounted = false; + }; + }, [stripe, clientSecret]); + + if (data.status === 'loading') { + return ( +
+
+ Checking payment status... +
+ ); + } + + return ( +
+ {data.status === 'error' ? ( + <> +

Payment Error

+

There was an error processing your payment: {data.error}

+ + ) : ( + <> +

Thank you!

+

Your payment was successful. I appreciate your support!

+ + )} + +
+ ); +} diff --git a/src/GroundsForSupport.Client/src/PaymentForm.tsx b/src/GroundsForSupport.Client/src/PaymentForm.tsx new file mode 100644 index 0000000..9b46693 --- /dev/null +++ b/src/GroundsForSupport.Client/src/PaymentForm.tsx @@ -0,0 +1,200 @@ +import { useState, useRef } from 'react'; + +type DonationFormProps = { + onValidSubmit: (data: { name: string; amount: number; message?: string; email?: string }) => void; + isSubmitting?: boolean; +}; + +export default function PaymentForm({ onValidSubmit, isSubmitting }: DonationFormProps) { + const [name, setName] = useState(''); + const [amount, setAmount] = useState(''); + const [message, setMessage] = useState(''); + const [email, setEmail] = useState(''); + const [errors, setErrors] = useState<{ + name?: string; + amount?: string; + email?: string; + message?: string; + }>({}); + + const nameInputRef = useRef(null); + const amountInputRef = useRef(null); + const messageTextAreaRef = useRef(null); + const emailInputRef = useRef(null); + + function handleNameInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, name: undefined })); + setName(event.target.value); + } + + function handleAmountInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, amount: undefined })); + const value = parseInt(event.target.value); + setAmount(isNaN(value) ? '' : value); + } + + function handleMessageInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, message: undefined })); + setMessage(event.target.value); + } + + function handleEmailInput(event: React.ChangeEvent) { + setErrors(prevErrors => ({ ...prevErrors, email: undefined })); + setEmail(event.target.value); + } + + function validateForm() { + const newErrors: { amount?: string; email?: string; name?: string; message?: string } = {}; + + if (name.trim() === '') { + newErrors.name = 'Please enter your name.'; + } + + if (nameInputRef.current?.validity.tooLong) { + newErrors.name = 'Name must be 60 characters or less.'; + } + + if (amount === '' || amountInputRef.current?.validity.rangeUnderflow || amount <= 0) { + newErrors.amount = 'Please enter a valid amount greater than 0.'; + } + + if (message.length > 250) { + newErrors.message = 'Message must be 250 characters or less.'; + } + + if (email.trim() !== '' && emailInputRef.current?.validity.typeMismatch) { + newErrors.email = 'Please enter a valid email address.'; + } + + setErrors(newErrors); + + const isValid = Object.keys(newErrors).length === 0; + + if (isValid === false) { + if (newErrors.name) { + nameInputRef.current?.focus(); + } else if (newErrors.amount) { + amountInputRef.current?.focus(); + } else if (newErrors.message) { + messageTextAreaRef.current?.focus(); + } else if (newErrors.email) { + emailInputRef.current?.focus(); + } + } + + return isValid; + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const isValid = validateForm(); + + if (isValid === false) { + return; + } + + if (amount === '') { + throw new Error('amount after validation should never be an empty string'); + } + + onValidSubmit({ name, amount, message, email }); + } + + return ( +
+
+ + + + {errors.name} + +
+
+ + + + {errors.amount} + +
+
+ +