+ {currentPageNumber === totalNumberOfPages ? null : (
+
+ )}
+ >
+ )}
+
+ );
+}
From 26f163d8789430f0da04e8bb90c025d295bbacdb Mon Sep 17 00:00:00 2001
From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com>
Date: Sun, 4 Jan 2026 21:12:53 -0600
Subject: [PATCH 08/21] feat(client): add payment components
---
.../Endpoints/CreatePaymentIntentEndpoint.cs | 78 ++++++++++
.../Payments/Endpoints/EventsEndpoint.cs | 65 ++++++++
.../Payments/Endpoints/GetPaymentsEndpoint.cs | 107 ++++++++++++++
.../Payments/Intent.cs | 3 +
.../Payments/Payment.cs | 10 ++
.../Payments/Stripe/IStripeService.cs | 11 ++
.../Payments/Stripe/StripeOptions.cs | 25 ++++
.../Payments/Stripe/StripeService.cs | 50 +++++++
src/GroundsForSupport.Server/Program.cs | 139 ++++--------------
9 files changed, 377 insertions(+), 111 deletions(-)
create mode 100644 src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Intent.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Payment.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs
create mode 100644 src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs
diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs
new file mode 100644
index 0000000..0ac607b
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Endpoints/CreatePaymentIntentEndpoint.cs
@@ -0,0 +1,78 @@
+using System.ComponentModel.DataAnnotations;
+
+using GroundsForSupport.Server.Payments.Stripe;
+using GroundsForSupport.Server.RateLimiting;
+
+namespace GroundsForSupport.Server.Payments.Endpoints;
+
+internal static class CreatePaymentIntentEndpoint
+{
+ private const string Route = "/payments/create-intent";
+
+ public static IEndpointConventionBuilder MapCreatePaymentIntentEndpoint(this WebApplication app)
+ {
+ return app.MapPost(Route, CreatePaymentIntentHandler).RequireRateLimiting(FixedRateLimitPolicy.Name);
+ }
+
+ public static async Task CreatePaymentIntentHandler(
+ Request 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.Name,
+ request.Amount,
+ request.Message,
+ request.Email
+ );
+
+ if (isSuccess is false)
+ {
+ return Results.InternalServerError();
+ }
+
+ return Results.Ok(intent);
+ }
+
+ internal sealed record Request(
+ string Name,
+ decimal Amount,
+ string? Message,
+ string? Email
+ )
+ {
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (string.IsNullOrWhiteSpace(Name))
+ {
+ yield return new ValidationResult("Name is required", [nameof(Name)]);
+ }
+
+ if (Amount <= 0)
+ {
+ yield return new ValidationResult("Amount must be greater than zero", [nameof(Amount)]);
+ }
+
+ if (Message?.Length > 250)
+ {
+ yield return new ValidationResult("Message cannot exceed 250 characters", [nameof(Message)]);
+ }
+
+ 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 Response(string ClientSecret);
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs
new file mode 100644
index 0000000..50945f1
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Endpoints/EventsEndpoint.cs
@@ -0,0 +1,65 @@
+using GroundsForSupport.Server.Data;
+using GroundsForSupport.Server.Payments.Stripe;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+using Stripe;
+
+namespace GroundsForSupport.Server.Payments.Endpoints;
+
+internal static class EventsEndpoint
+{
+ private const string Route = "/events";
+ private const string StripeSignatureHeader = "Stripe-Signature";
+
+ internal static IEndpointConventionBuilder MapEventsEndpoint(this WebApplication app)
+ {
+ return app.MapPost(Route, EventsHandler);
+ }
+
+ internal static async Task EventsHandler(
+ HttpContext httpContext,
+ [FromServices] IOptions options,
+ [FromServices] Context dbContext,
+ [FromServices] TimeProvider timeProvider
+ )
+ {
+ var json = await new StreamReader(httpContext.Request.Body).ReadToEndAsync();
+
+ try
+ {
+ var stripeEvent = EventUtility.ParseEvent(json);
+ var signatureHeader = httpContext.Request.Headers[StripeSignatureHeader];
+ stripeEvent = EventUtility.ConstructEvent(json, signatureHeader, options.Value.EventsWebhookSecret);
+
+ if (stripeEvent.Type is not EventTypes.PaymentIntentSucceeded)
+ {
+ return Results.Ok();
+ }
+
+ var paymentIntent = (PaymentIntent)stripeEvent.Data.Object;
+ var name = paymentIntent.Metadata[nameof(Payment.Name)];
+ var message = paymentIntent.Metadata[nameof(Payment.Message)];
+
+ var payment = new Payment()
+ {
+ Id = paymentIntent.Id,
+ Amount = paymentIntent.AmountReceived,
+ Name = name,
+ Message = message,
+ CreatedAtUnix = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(),
+ };
+
+ dbContext.Payments.Add(payment);
+ await dbContext.SaveChangesAsync();
+
+ return Results.Ok();
+ }
+ catch (StripeException e)
+ {
+ Console.WriteLine($"Stripe exception: {e.Message}");
+ return Results.BadRequest(new { error = e.Message });
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs b/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs
new file mode 100644
index 0000000..413108f
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Endpoints/GetPaymentsEndpoint.cs
@@ -0,0 +1,107 @@
+using System.ComponentModel.DataAnnotations;
+using System.Globalization;
+
+using GroundsForSupport.Server.Data;
+
+using Microsoft.AspNetCore.Mvc;
+
+namespace GroundsForSupport.Server.Payments.Endpoints;
+
+internal static class GetPaymentsEndpoint
+{
+ private const string Route = "/payments";
+
+ public static IEndpointConventionBuilder MapGetPaymentsEndpoint(this WebApplication app)
+ {
+ return app.MapGet(Route, GetPaymentsHandler);
+ }
+
+ public static async Task GetPaymentsHandler(
+ [AsParameters] Request request,
+ [FromServices] Context context
+ )
+ {
+ 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 totalNumberOfPayments = context.Payments.Count();
+ var totalNumberOfPages = (int)Math.Ceiling(totalNumberOfPayments / (double)request.PageSize);
+
+ var paymentsQuery = request.SortDirection?.ToLower(CultureInfo.CurrentCulture) is "asc"
+ ? context.Payments
+ .OrderBy(static p => p.CreatedAtUnix)
+ : context.Payments
+ .OrderByDescending(static p => p.CreatedAtUnix);
+
+ var payments = paymentsQuery
+ .Skip((request.PageNumber - 1) * request.PageSize)
+ .Take(request.PageSize)
+ .Select(PaymentRecord.From)
+ .ToList();
+
+ var response = new Response(
+ totalNumberOfPayments,
+ totalNumberOfPages,
+ request.PageNumber,
+ payments
+ );
+
+ return Results.Ok(response);
+ }
+
+ internal sealed record Request(int PageSize = 50, int PageNumber = 1, string? SortDirection = null) : IValidatableObject
+ {
+ public IEnumerable Validate(ValidationContext validationContext)
+ {
+ if (PageSize <= 0)
+ {
+ yield return new ValidationResult("PageSize must be greater than zero", [nameof(PageSize)]);
+ }
+
+ if (PageNumber <= 0)
+ {
+ yield return new ValidationResult("PageNumber must be greater than zero", [nameof(PageNumber)]);
+ }
+
+ if (
+ SortDirection is not null &&
+ SortDirection.ToLower(CultureInfo.CurrentCulture) is not "asc" and not "desc"
+ )
+ {
+ yield return new ValidationResult("SortDirection must be either 'asc' or 'desc'", [nameof(SortDirection)]);
+ }
+ }
+ }
+
+ internal sealed record Response(
+ int TotalNumberOfPayments,
+ int TotalNumberOfPages,
+ int CurrentPageNumber,
+ List Payments
+ );
+
+ internal sealed record PaymentRecord
+ {
+ public string Name { get; init; } = string.Empty;
+ public long Amount { get; init; }
+ public string? Message { get; init; }
+ public long CreatedAtUnix { get; init; }
+
+ public static PaymentRecord From(Payment payment)
+ {
+ return new PaymentRecord
+ {
+ Amount = payment.Amount,
+ Name = payment.Name,
+ Message = payment.Message,
+ CreatedAtUnix = payment.CreatedAtUnix,
+ };
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Intent.cs b/src/GroundsForSupport.Server/Payments/Intent.cs
new file mode 100644
index 0000000..62aa1d2
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Intent.cs
@@ -0,0 +1,3 @@
+namespace GroundsForSupport.Server.Payments;
+
+internal sealed record Intent(string ClientSecret);
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Payment.cs b/src/GroundsForSupport.Server/Payments/Payment.cs
new file mode 100644
index 0000000..7652f2e
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Payment.cs
@@ -0,0 +1,10 @@
+namespace GroundsForSupport.Server.Payments;
+
+internal sealed class Payment
+{
+ public required string Id { get; init; }
+ public required string Name { get; init; }
+ public required long Amount { get; init; }
+ public string? Message { get; init; }
+ public long CreatedAtUnix { get; init; }
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs b/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs
new file mode 100644
index 0000000..3481c94
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Stripe/IStripeService.cs
@@ -0,0 +1,11 @@
+namespace GroundsForSupport.Server.Payments.Stripe;
+
+internal interface IStripeService
+{
+ Task<(bool IsSuccess, Intent Intent)> CreatePaymentIntentAsync(
+ string name,
+ decimal amount,
+ string? message,
+ string? email
+ );
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs b/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs
new file mode 100644
index 0000000..73f0fcc
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Stripe/StripeOptions.cs
@@ -0,0 +1,25 @@
+using Microsoft.Extensions.Options;
+
+namespace GroundsForSupport.Server.Payments.Stripe;
+
+internal sealed record StripeOptions
+{
+ public string ApiKey { get; init; } = string.Empty;
+ public string EventsWebhookSecret { 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);
+ }
+}
\ No newline at end of file
diff --git a/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs b/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs
new file mode 100644
index 0000000..d0a401a
--- /dev/null
+++ b/src/GroundsForSupport.Server/Payments/Stripe/StripeService.cs
@@ -0,0 +1,50 @@
+using GroundsForSupport.Server.Data;
+
+using Microsoft.Extensions.Options;
+
+using Stripe;
+
+namespace GroundsForSupport.Server.Payments.Stripe;
+
+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(string name, decimal amount, string? message, string? email)
+ {
+ try
+ {
+ var createOptions = new PaymentIntentCreateOptions
+ {
+ Amount = (long)(amount * 100),
+ Currency = "usd",
+ AutomaticPaymentMethods = new PaymentIntentAutomaticPaymentMethodsOptions
+ {
+ Enabled = true,
+ },
+ Metadata = new Dictionary
+ {
+ { nameof(Payment.Name), name },
+ { nameof(Payment.Message), message ?? string.Empty },
+ },
+ };
+
+ 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/Program.cs b/src/GroundsForSupport.Server/Program.cs
index 5daa99e..d76b978 100644
--- a/src/GroundsForSupport.Server/Program.cs
+++ b/src/GroundsForSupport.Server/Program.cs
@@ -1,9 +1,11 @@
-using System.ComponentModel.DataAnnotations;
using System.Text.Json;
-using Microsoft.Extensions.Options;
+using GroundsForSupport.Server.Data;
+using GroundsForSupport.Server.Payments.Endpoints;
+using GroundsForSupport.Server.Payments.Stripe;
+using GroundsForSupport.Server.RateLimiting;
-using Stripe;
+using Microsoft.AspNetCore.HttpOverrides;
var builder = WebApplication.CreateBuilder(args);
@@ -15,6 +17,18 @@ builder.Services.AddProblemDetails();
builder.Services.AddHttpClient();
+builder.Services.AddSingleton(TimeProvider.System);
+
+builder.Services.Configure(
+ static options => options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
+);
+
+builder.Services.AddRateLimingPolicies();
+
+builder.Services.ConfigureOptions();
+builder.Services.AddDbContext();
+builder.Services.AddHostedService();
+
builder.Services.ConfigureOptions();
builder.Services.AddSingleton();
@@ -29,119 +43,22 @@ if (app.Environment.IsDevelopment())
app.MapOpenApi();
}
+if (app.Environment.IsProduction())
+{
+ app.UseForwardedHeaders();
+ app.UseRateLimiter();
+}
+
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseStatusCodePages();
app.UseHttpsRedirection();
+app.UseHsts();
-app.MapPost("/create-payment-intent", CreatePaymentIntentHandler);
+app.MapCreatePaymentIntentEndpoint();
+app.MapGetPaymentsEndpoint();
+app.MapEventsEndpoint();
-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
+app.Run();
\ No newline at end of file
From 52c6c8288600efae7cdbc24182d2afc82461adcd Mon Sep 17 00:00:00 2001
From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com>
Date: Sun, 4 Jan 2026 21:13:08 -0600
Subject: [PATCH 09/21] feat(client): add payment components
---
src/GroundsForSupport.Client/src/App.css | 195 +++++++++++++++++-
src/GroundsForSupport.Client/src/App.tsx | 182 ++++++++--------
src/GroundsForSupport.Client/src/index.css | 9 -
.../tests/App.spec.tsx | 33 ++-
.../Integration/PaymentTests.cs | 7 +-
5 files changed, 316 insertions(+), 110 deletions(-)
diff --git a/src/GroundsForSupport.Client/src/App.css b/src/GroundsForSupport.Client/src/App.css
index c4d5437..e4b0f96 100644
--- a/src/GroundsForSupport.Client/src/App.css
+++ b/src/GroundsForSupport.Client/src/App.css
@@ -1,3 +1,19 @@
+main {
+ display: flex;
+ justify-content: center;
+ flex-wrap: wrap;
+ padding: 1rem;
+ padding-top: 2rem;
+ gap: 1rem;
+}
+
+.payment,
+.previous-donations {
+ --max-section-width: 31.25rem;
+ flex: 1 1 var(--max-section-width);
+ max-width: var(--max-section-width);
+}
+
header {
background-color: #181818;
flex-direction: column;
@@ -25,15 +41,17 @@ header .info {
text-align: center;
}
+header .info p {
+ text-align: left;
+}
+
form {
+ display: flex;
+ flex-direction: column;
background-color: #181818;
border-radius: 0.25rem;
- flex-direction: column;
gap: 0.5rem;
- width: 100%;
- max-width: 31.25rem;
padding: 1rem;
- display: flex;
box-shadow: 0 4px 8px #0000001a;
}
@@ -47,6 +65,17 @@ form label {
font-weight: 700;
}
+form label .detail {
+ font-size: 0.875rem;
+ color: #ccc;
+}
+
+form label:has(+ input:required)::after {
+ content: ' [required]';
+ font-size: 0.875rem;
+ color: #ccc;
+}
+
form input {
color: #e4e4e4;
background-color: #282828;
@@ -69,6 +98,15 @@ form input[type='number'] {
appearance: textfield;
}
+form textarea {
+ color: #e4e4e4;
+ background-color: #282828;
+ border: 1px solid #444;
+ border-radius: 0.25rem;
+ padding: 0.5rem;
+ resize: vertical;
+}
+
.error-message {
color: #ff6b6b;
font-size: 0.875rem;
@@ -83,3 +121,152 @@ form button {
font-weight: 700;
transition: background-color 0.3s;
}
+
+.payment-confirmation-loading {
+ text-align: center;
+ font-weight: 700;
+ padding: 2rem 0;
+}
+
+.payment-confirmation-loading .spinner {
+ border: 4px solid #444;
+ border-top: 4px solid #b39cd0;
+ border-radius: 50%;
+ width: 3rem;
+ height: 3rem;
+ margin: 0 auto 1rem auto;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.payment-confirmation-card {
+ background-color: #181818;
+ border-radius: 0.25rem;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ max-width: 31.25rem;
+ box-shadow: 0 4px 8px #0000001a;
+}
+
+.previous-donations {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.previous-donations ul {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.previous-donations ul li {
+ display: flex;
+}
+
+.previous-donations .load-more-button {
+ color: #fff;
+ background-color: #b39cd0;
+ border: none;
+ border-radius: 0.25rem;
+ padding: 0.75rem;
+ font-weight: 700;
+ transition: background-color 0.3s;
+}
+
+.payment-card {
+ flex: 1;
+ background-color: #282828;
+ border-radius: 0.25rem;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.payment-card .header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.payment-card .left,
+.payment-card .right {
+ display: flex;
+}
+
+.payment-card .left {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.payment-card .left img {
+ width: 3rem;
+ height: 3rem;
+ border-radius: 0.25rem;
+}
+
+.payment-card .header .right {
+ flex: 1;
+ gap: 1rem;
+}
+
+.payment-card .header .info,
+.payment-card .header .details {
+ flex: 1;
+}
+
+.payment-card .header .right .info .date {
+ font-size: 0.875rem;
+ color: #aaa;
+}
+
+.payment-card .header .right .details {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+}
+
+.payment-card .header .right .details .amount {
+ font-weight: 700;
+ font-size: 1.25rem;
+}
+
+.payment-card .header .right .info,
+.payment-card .header .right .details .amount,
+.payment-card .message {
+ display: block;
+ min-width: 0;
+ overflow-wrap: break-word;
+ word-break: break-word;
+}
+
+.payment-card .message {
+ font-style: italic;
+ color: #ccc;
+}
+
+.return-home-link {
+ color: #b39cd0;
+ text-align: center;
+ margin-top: 1rem;
+ font-weight: 700;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
diff --git a/src/GroundsForSupport.Client/src/App.tsx b/src/GroundsForSupport.Client/src/App.tsx
index 01e704f..ac4a4b0 100644
--- a/src/GroundsForSupport.Client/src/App.tsx
+++ b/src/GroundsForSupport.Client/src/App.tsx
@@ -1,64 +1,57 @@
import '@/App.css';
-import { useRef, useState } from 'react';
+import { Elements } from '@stripe/react-stripe-js';
+import { loadStripe } from '@stripe/stripe-js';
+import { useState } from 'react';
+import CheckoutForm from './CheckoutForm';
+import PaymentForm from './PaymentForm';
+import PaymentConfirmationCard from './PaymentConfirmationCard';
+import PreviousPaymentsList from './PreviousPaymentsList';
+
+const stripe = loadStripe(import.meta.env.VITE_STRIPE_API_KEY);
function App() {
- const [amount, setAmount] = useState('');
- const [email, setEmail] = useState('');
- const [errors, setErrors] = useState<{ amount?: string; email?: string }>({});
+ const queryParams = new URLSearchParams(window.location.search);
+ const clientSecretFromUrl = queryParams.get('payment_intent_client_secret');
+ const [secret, setSecret] = useState(clientSecretFromUrl ?? undefined);
+ const [isSubmitting, setIsSubmitting] = useState(false);
- const amountInputRef = useRef(null);
- const emailInputRef = useRef(null);
+ async function handleDonationFormSubmit(formData: {
+ name: string;
+ amount: number;
+ message?: string;
+ email?: string;
+ }) {
+ setIsSubmitting(true);
- function handleAmountInput(event: React.ChangeEvent) {
- setErrors((prevErrors) => ({ ...prevErrors, amount: undefined }));
- const value = parseInt(event.target.value);
- setAmount(isNaN(value) ? '' : value);
- }
+ try {
+ const url = new URL('/payments/create-intent', import.meta.url);
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ name: formData.name,
+ amount: formData.amount,
+ message: formData.message,
+ email: formData.email,
+ }),
+ });
- function handleEmailInput(event: React.ChangeEvent) {
- setErrors((prevErrors) => ({ ...prevErrors, email: undefined }));
- setEmail(event.target.value);
- }
-
- function validateForm() {
- const newErrors: { amount?: string; email?: string } = {};
-
- if (amount === '' || amount <= 0) {
- newErrors.amount = 'Please enter a valid amount greater than 0.';
- }
-
- 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.amount) {
- amountInputRef.current?.focus();
- } else if (newErrors.email) {
- emailInputRef.current?.focus();
+ if (!res.ok) {
+ console.error('Failed to create payment intent');
+ alert('An error occurred while creating the payment. Please try again.');
+ return;
}
+
+ const data = await res.json();
+ setSecret(data.clientSecret);
+ } catch (err) {
+ console.error(err);
+ alert('An error occurred while creating the payment. Please try again.');
+ } finally {
+ setIsSubmitting(false);
}
-
- return isValid;
- }
-
- function handleSubmit(event: React.FormEvent) {
- event.preventDefault();
-
- const isValid = validateForm();
-
- if (isValid === false) {
- return;
- }
-
- // TODO: We will stop displaying our form
- // and we will initialize the stripe embedded
- // elements and pass along the amount and email
- alert(`Form is valid! Amount: ${amount}, Email: ${email}`);
}
return (
@@ -72,49 +65,58 @@ function App() {
Stevan Freeborn
I'm a dad of 2 who enjoys drinking coffee, lifting weights, and solving problems with
- code. You really don't need to buy me a coffee.
+ code. If you have found my open helpful, consider supporting me with a donation which I
+ will more than likely spend on more coffee!