diff --git a/MidrandBookshop/Components/Layout/MainLayout.razor.cs b/MidrandBookshop/Components/Layout/MainLayout.razor.cs index 0c82f33..a9c8944 100644 --- a/MidrandBookshop/Components/Layout/MainLayout.razor.cs +++ b/MidrandBookshop/Components/Layout/MainLayout.razor.cs @@ -27,13 +27,9 @@ public partial class MainLayout(CartService cartService) : IDisposable Navigation.LocationChanged += OnLocationChanged; cartService.OnCartChanged += CartService_OnCartChanged; - SyncSearchQueryFromUrl(); - } + await cartService.LoadCartFromStorageAsync(); - protected override async Task OnAfterRenderAsync(bool firstRender) - { - if (firstRender) - await cartService.LoadCartFromStorageAsync(); + SyncSearchQueryFromUrl(); } private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged); @@ -104,7 +100,7 @@ public partial class MainLayout(CartService cartService) : IDisposable private void ToggleCart() => IsCartOpen = !IsCartOpen; - private async void ChangeQuantity(CartItem item, int delta) + private async Task ChangeQuantity(CartItem item, int delta) { var peekQuantity = item.Quantity + delta; @@ -115,7 +111,7 @@ public partial class MainLayout(CartService cartService) : IDisposable await cartService.SaveCartToStorageAsync(); } - private async void RemoveFromCart(CartItem item) + private async Task RemoveFromCart(CartItem item) { cartService.RemoveOneItem(item.Price!.Id); diff --git a/MidrandBookshop/Components/Pages/Checkout.razor.cs b/MidrandBookshop/Components/Pages/Checkout.razor.cs index 1316149..ce4f920 100644 --- a/MidrandBookshop/Components/Pages/Checkout.razor.cs +++ b/MidrandBookshop/Components/Pages/Checkout.razor.cs @@ -1,13 +1,10 @@ using LiteCharms.Features.Api.Configuration; using LiteCharms.Features.Hasher; using LiteCharms.Features.MidrandBooks.AuthorBooks; -using LiteCharms.Features.MidrandBooks.Customers; -using LiteCharms.Features.MidrandBooks.Customers.Models; using LiteCharms.Features.MidrandBooks.Orders; using LiteCharms.Features.MidrandBooks.Orders.Models; using LiteCharms.Features.MidrandBooks.Payments; using LiteCharms.Features.MidrandBooks.Payments.Models; -using LiteCharms.Features.MidrandBooks.Products; namespace MidrandBookshop.Components.Pages; @@ -19,14 +16,13 @@ public partial class Checkout() [Inject] public BooksService BooksService { get; set; } = default!; [Inject] public CartService CartService { get; set; } = default!; [Inject] public PayfastService PayfastService { get; set; } = default!; - [Inject] public CustomerService CustomerService { get; set; } = default!; - [Inject] public ProductService ProductService { get; set; } = default!; [Inject] public IOptions PayfastOptions { get; set; } = default!; [Inject] private AuthenticationStateProvider AuthStateProvider { get; set; } = default!; [Inject] public IJSRuntime JSRuntime { get; set; } = default!; + [Inject] private HydrationService HydrationService { get; set; } = default!; + [Inject] private CancellationToken CancellationToken { get; set; } = default!; private Cart ShoppingCart => CartService.ShoppingCart; - private AuthenticationState? AuthState { get; set; } private ClaimsPrincipal? User { get; set; } private bool IsProcessing { get; set; } @@ -37,18 +33,31 @@ public partial class Checkout() protected override async Task OnInitializedAsync() { - AuthState = await AuthStateProvider.GetAuthenticationStateAsync(); - User = AuthState!.User; + var authState = await AuthStateProvider.GetAuthenticationStateAsync(); + User = authState!.User; Navigation.LocationChanged += OnLocationChanged; CartService.OnCartChanged += CartService_OnCartChanged; + + await CartService.LoadCartFromStorageAsync(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender == false && HydrationService.CartHydrated == false) + { + await HydrationService.EnsureCustomerExistsAsync(CancellationToken); + await HydrationService.RehydrateCartFromPendingOrderAsync(CancellationToken); + + CartService.NotifyStateChanged(); + } } private async void CartService_OnCartChanged() => await InvokeAsync(StateHasChanged); private void OnLocationChanged(object? sender, LocationChangedEventArgs e) => StateHasChanged(); - private async void ChangeQuantity(CartItem item, int delta) + private async Task ChangeQuantity(CartItem item, int delta) { var peekQuantity = item.Quantity + delta; @@ -59,7 +68,7 @@ public partial class Checkout() await CartService.SaveCartToStorageAsync(); } - private async void RemoveFromCart(CartItem item) + private async Task RemoveFromCart(CartItem item) { CartService.RemoveOneItem(item.Price!.Id); @@ -72,37 +81,31 @@ public partial class Checkout() try { - // 1. Instantly disable the button to prevent duplicate click submissions IsProcessing = true; - StateHasChanged(); // Force Blazor Server to push the disabled state over SignalR immediately - var customerEmail = User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!; + StateHasChanged(); - // 2. Create customer if ShoppingCart.CustomerId is null - if (ShoppingCart.CustomerId == null) + Result orderResult; + + var customerId = (long)ShoppingCart.CustomerId!; + + if (!ShoppingCart.OrderId.HasValue) { - var existingCustomer = await CustomerService.GetCustomerAsync(customerEmail); + CreateOrder request = new(ShoppingCart.TotalAmount, null); - if (existingCustomer.IsSuccess) - ShoppingCart.CustomerId = existingCustomer.Value.Id; - - if (existingCustomer.IsFailed) - { - var customerCreate = await CustomerService.CreateCustomerAsync(new CreateCustomer { Email = customerEmail }); - - if (customerCreate.IsSuccess) - ShoppingCart.CustomerId = customerCreate.Value; - } + orderResult = await OrderService.CreateOrderAsync(customerId, request, CancellationToken); + ShoppingCart.OrderId = orderResult.Value; } - // 3. Create order using shopping cart and assign the ShoppingCart.OrderId - - var order = await OrderService.CreateOrderAsync(ShoppingCart.CustomerId!.Value, new CreateOrder(ShoppingCart.TotalAmount, null)); List orderItems = []; + var orderId = (long)ShoppingCart.OrderId; + + await OrderService.ClearOrderItemsAsync(orderId, CancellationToken); + foreach (var item in ShoppingCart.Items) { - var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id); + var bookRequest = await BooksService.GetBookByProductIdAsync(item.Price!.Id, CancellationToken); if (bookRequest.IsSuccess) { @@ -111,22 +114,21 @@ public partial class Checkout() } } - var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, order.Value, HashService.HashEncodeLongId(order.Value).Value); - var merchantPaymentId = HashService.HashEncodeLongId(order.Value).Value; + var orderHash = HashService.HashEncodeLongId(orderId).Value; + var paymentGen = await PaymentService.CreatePaymentAsync(ShoppingCart.TotalAmount, orderId, orderHash, CancellationToken); - await PaymentService.WriteLedgerEntryAsync(new CreateLedgerEntry + CreateLedgerEntry ledgerRequest = new() { - OrderId = order.Value, - CustomerId = ShoppingCart.CustomerId.Value, - PaymentGatewayId = 1, - PaymentGatewayReference = merchantPaymentId, + OrderId = orderId, + CustomerId = customerId, + PaymentGatewayId = 1, // TODO: lookup value to match user selection + PaymentGatewayReference = orderHash, PaymentId = paymentGen.Value, Status = LiteCharms.Features.LedgerStatuses.Sent, - }); + }; + await PaymentService.WriteLedgerEntryAsync(ledgerRequest, CancellationToken); - var addItemsResult = await OrderService.AddItemsToOrderAsync(order.Value, [.. orderItems]); - - // 4. Generate the signed Payfast form payload using your backend service + var addItemsResult = await OrderService.AddItemsToOrderAsync(orderId, [.. orderItems], CancellationToken); var hostAddress = Navigation.BaseUri.TrimEnd('/'); CheckoutPayload = new Dictionary @@ -136,8 +138,8 @@ public partial class Checkout() { "return_url", $"{hostAddress}/payment-success" }, { "cancel_url", $"{hostAddress}/payment-failed" }, { "notify_url", "https://api.uat.midrandbooks.co.za/v1/payments/payfast/confirm" }, - { "email_address", customerEmail }, - { "m_payment_id", merchantPaymentId }, + { "email_address", User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value! }, + { "m_payment_id", orderHash }, { "amount", ShoppingCart.TotalAmount.ToString("F2", CultureInfo.InvariantCulture) }, { "item_name", "MidrandBooks Sale" }, }; @@ -147,7 +149,6 @@ public partial class Checkout() StateHasChanged(); - // 6. Execute programmatic submit directly into the sandbox await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('payfastForm').submit();"); } catch diff --git a/MidrandBookshop/Components/Pages/Home.razor.cs b/MidrandBookshop/Components/Pages/Home.razor.cs index 4933680..65598a6 100644 --- a/MidrandBookshop/Components/Pages/Home.razor.cs +++ b/MidrandBookshop/Components/Pages/Home.razor.cs @@ -2,6 +2,7 @@ using LiteCharms.Features.MidrandBooks.AuthorBooks; using LiteCharms.Features.MidrandBooks.Authors; using LiteCharms.Features.MidrandBooks.Categories; +using LiteCharms.Features.MidrandBooks.Payments; using LiteCharms.Features.MidrandBooks.Products; using LiteCharms.Features.MidrandBooks.Products.Models; using LiteCharms.Features.Models; @@ -15,6 +16,9 @@ public partial class Home : ComponentBase [Inject] private AuthorService AuthorService { get; set; } = default!; [Inject] private CategoryService CategoryService { get; set; } = default!; [Inject] private NavigationManager Navigation { get; set; } = default!; + [Inject] private HydrationService HydrationService { get; set; } = default!; + [Inject] private CancellationToken CancellationToken { get; set; } = default!; + [Inject] private CartService CartService { get; set; } = default!; [SupplyParameterFromQuery(Name = "q")] public string? SharedSearchQuery { get; set; } [SupplyParameterFromQuery] public long? AuthorId { get; set; } @@ -95,6 +99,17 @@ public partial class Home : ComponentBase private bool HasMoreItems => FilteredData.Count() > VisibleCount; + protected override async Task OnInitializedAsync() => await CartService.LoadCartFromStorageAsync(); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender == false && HydrationService.CartHydrated == false) + { + await HydrationService.EnsureCustomerExistsAsync(CancellationToken); + await HydrationService.RehydrateCartFromPendingOrderAsync(CancellationToken); + } + } + protected override async Task OnParametersSetAsync() => await LoadCatalogDataAsync(); private async Task LoadCatalogDataAsync() diff --git a/MidrandBookshop/HydrationService.cs b/MidrandBookshop/HydrationService.cs new file mode 100644 index 0000000..3939f7d --- /dev/null +++ b/MidrandBookshop/HydrationService.cs @@ -0,0 +1,116 @@ +using LiteCharms.Features.MidrandBooks.AuthorBooks; +using LiteCharms.Features.MidrandBooks.Authors; +using LiteCharms.Features.MidrandBooks.Customers; +using LiteCharms.Features.MidrandBooks.Customers.Models; +using LiteCharms.Features.MidrandBooks.Orders; +using LiteCharms.Features.MidrandBooks.Payments; +using LiteCharms.Features.MidrandBooks.Payments.Models; +using LiteCharms.Features.MidrandBooks.Products; + +namespace MidrandBookshop; + +public sealed class HydrationService(AuthenticationStateProvider authStateProvider, CustomerService customerService, + ProductService productService, OrderService orderService, BooksService booksService, AuthorService authorService, + CartService cartService) +{ + private Cart ShoppingCart => cartService.ShoppingCart; + + private AuthenticationState? AuthState { get; set; } + private ClaimsPrincipal? User { get; set; } + + public bool CartHydrated { get; set; } + + public async Task EnsureCustomerExistsAsync(CancellationToken cancellationToken = default) + { + if (ShoppingCart.CustomerId > 0) return; + + AuthState = await authStateProvider.GetAuthenticationStateAsync(); + User = AuthState!.User; + + if (User?.Identity?.IsAuthenticated == false) return; + + var email = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)!.Value!; + + var existingCustomer = await customerService.GetCustomerAsync(email, cancellationToken); + + if (existingCustomer.IsSuccess) + ShoppingCart.CustomerId = existingCustomer.Value.Id; + + if (existingCustomer.IsFailed) + { + var name = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)!.Value!; + var lastname = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Surname)!.Value!; + var mobile = User!.Claims.FirstOrDefault(c => c.Type == ClaimTypes.MobilePhone)!.Value!; + + var customerCreate = await customerService.CreateCustomerAsync(new CreateCustomer { Email = email }, cancellationToken); + + if (customerCreate.IsSuccess) + { + ShoppingCart.CustomerId = customerCreate.Value; + + if (!string.IsNullOrWhiteSpace(name)) + await customerService.CreateCustomerContactAsync((long)ShoppingCart.CustomerId, new CreateCustomerContact + { + Email = email, + Name = name, + LastName = lastname, + Phone = mobile, + Type = LiteCharms.Features.ContactTypes.Personal + }, cancellationToken); + } + } + } + + public async Task RehydrateCartFromPendingOrderAsync(CancellationToken cancellationToken = default) + { + if (User?.Identity?.IsAuthenticated == false) return; + + if (ShoppingCart.OrderId > 0 && ShoppingCart.CustomerId > 0) + { + cartService.CalculateTotalPrice(); + CartHydrated = true; + + return; + } + + var orderResult = await orderService.GetPendingOrderAsync((long)ShoppingCart.CustomerId!, cancellationToken); + + if (orderResult.IsFailed) return; + + if(orderResult.Value.Id == ShoppingCart.OrderId) + { + cartService.CalculateTotalPrice(); + CartHydrated = true; + + return; + } + + var orderItemsResult = await orderService.GetOrderItemsAsync((long)orderResult.Value.Id!, cancellationToken); + + ShoppingCart.OrderId = orderResult.Value.Id; + + if(orderItemsResult.IsFailed) return; + + foreach (var item in orderItemsResult.Value) + { + var priceFetchResult = await productService.GetProductPriceAsync(item.ProductPriceId, cancellationToken); + + if (priceFetchResult.IsFailed) continue; + + var bookFetchResult = await booksService.GetBookByProductIdAsync(priceFetchResult.Value.ProductId, cancellationToken); + + if (bookFetchResult.IsFailed) continue; + + var authorFetchResult = await authorService.GetAuthorAsync(bookFetchResult.Value.AuthorId, cancellationToken); + + if (authorFetchResult.IsFailed) continue; + + if(!ShoppingCart.Items.Any(i => i.Price!.Id == item.ProductPriceId)) + cartService.AddItem(priceFetchResult.Value, bookFetchResult.Value.Product!, authorFetchResult.Value); + } + + CartHydrated = true; + + await cartService.SaveCartToStorageAsync(); + } +} diff --git a/MidrandBookshop/MidrandBookshop.csproj b/MidrandBookshop/MidrandBookshop.csproj index cf7b0c2..5232087 100644 --- a/MidrandBookshop/MidrandBookshop.csproj +++ b/MidrandBookshop/MidrandBookshop.csproj @@ -18,13 +18,13 @@ - + - + diff --git a/MidrandBookshop/Properties/launchSettings.json b/MidrandBookshop/Properties/launchSettings.json index 3a5b85d..65af79e 100644 --- a/MidrandBookshop/Properties/launchSettings.json +++ b/MidrandBookshop/Properties/launchSettings.json @@ -14,7 +14,7 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "https://localhost:8440;http://localhost:8083", + "applicationUrl": "https://localhost:8443;http://localhost:8080", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/MidrandBookshop/Setup.cs b/MidrandBookshop/Setup.cs index 502eea2..b1ea61c 100644 --- a/MidrandBookshop/Setup.cs +++ b/MidrandBookshop/Setup.cs @@ -10,6 +10,7 @@ public static class Setup { public static IServiceCollection RegisterServices(this IServiceCollection services, IConfiguration configuration) { + services.AddCancellationToken(); services.AddAntiforgery(); services.AddRazorComponents() @@ -28,6 +29,7 @@ public static class Setup services.AddHttpClient(); services.AddScoped(); + services.AddScoped(); services.AddShopServices(includeLocalStorage: true); services.AddHashServices(configuration); services.AddPayfastServices(configuration);