diff --git a/src/Libraries/Nop.Core/Domain/Customers/NopCustomerDefaults.cs b/src/Libraries/Nop.Core/Domain/Customers/NopCustomerDefaults.cs index a369d6ea087..fac9484bf43 100644 --- a/src/Libraries/Nop.Core/Domain/Customers/NopCustomerDefaults.cs +++ b/src/Libraries/Nop.Core/Domain/Customers/NopCustomerDefaults.cs @@ -100,6 +100,8 @@ public static partial class NopCustomerDefaults /// public static string SelectedPaymentMethodAttribute => "SelectedPaymentMethod"; + public static string ShipToSameAddressAttribute => "ShipToSameAddress"; + /// /// Gets a name of generic attribute to store the value of 'SelectedShippingOption' /// diff --git a/src/Libraries/Nop.Core/Domain/Orders/OrderSettings.cs b/src/Libraries/Nop.Core/Domain/Orders/OrderSettings.cs index 5eb987ac3fd..f8ef57054a6 100644 --- a/src/Libraries/Nop.Core/Domain/Orders/OrderSettings.cs +++ b/src/Libraries/Nop.Core/Domain/Orders/OrderSettings.cs @@ -58,6 +58,8 @@ public partial class OrderSettings : ISettings /// public bool OnePageCheckoutEnabled { get; set; } + public bool SinglePageCheckoutEnabled { get; set; } + /// /// Gets or sets a value indicating whether order totals should be displayed on 'Payment info' tab of 'One-page checkout' page /// diff --git a/src/Libraries/Nop.Core/Http/NopRouteNames.cs b/src/Libraries/Nop.Core/Http/NopRouteNames.cs index 25cfc7b615f..da0783f64fd 100644 --- a/src/Libraries/Nop.Core/Http/NopRouteNames.cs +++ b/src/Libraries/Nop.Core/Http/NopRouteNames.cs @@ -131,6 +131,8 @@ public static partial class Standard /// public const string CHECKOUT_ONE_PAGE = "CheckoutOnePage"; + public const string CHECKOUT_SINGLE_PAGE = "CheckoutSinglePage"; + /// /// Gets the checkout shipping address route name /// diff --git a/src/Presentation/Nop.Web/Controllers/CheckoutController.cs b/src/Presentation/Nop.Web/Controllers/CheckoutController.cs index 81ef5e12fa0..d26f80367c4 100644 --- a/src/Presentation/Nop.Web/Controllers/CheckoutController.cs +++ b/src/Presentation/Nop.Web/Controllers/CheckoutController.cs @@ -7,6 +7,7 @@ using Nop.Core.Domain.Payments; using Nop.Core.Domain.Security; using Nop.Core.Domain.Shipping; +using Nop.Core.Domain.Stores; using Nop.Core.Domain.Tax; using Nop.Core.Http; using Nop.Services.Attributes; @@ -382,6 +383,9 @@ public virtual async Task Index() if (_orderSettings.OnePageCheckoutEnabled) return RedirectToRoute(NopRouteNames.Standard.CHECKOUT_ONE_PAGE); + if (_orderSettings.SinglePageCheckoutEnabled) + return RedirectToRoute(NopRouteNames.Standard.CHECKOUT_SINGLE_PAGE); + return RedirectToRoute(NopRouteNames.Standard.CHECKOUT_BILLING_ADDRESS); } @@ -1723,7 +1727,7 @@ await _checkoutModelFactory.PrepareShippingAddressModelAsync(shippingAddressMode .Select(p => p.ErrorMessage)) }); } - + //try to find an address with the same values (don't duplicate records) var address = _addressService.FindAddress((await _customerService.GetAddressesByCustomerIdAsync(customer.Id)).ToList(), newAddress.FirstName, newAddress.LastName, newAddress.PhoneNumber, diff --git a/src/Presentation/Nop.Web/Controllers/SpCheckoutController.cs b/src/Presentation/Nop.Web/Controllers/SpCheckoutController.cs new file mode 100644 index 00000000000..a06cbe484a9 --- /dev/null +++ b/src/Presentation/Nop.Web/Controllers/SpCheckoutController.cs @@ -0,0 +1,1187 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.CodeAnalysis; +using Newtonsoft.Json; +using Nop.Core; +using Nop.Core.Domain.Common; +using Nop.Core.Domain.Customers; +using Nop.Core.Domain.Orders; +using Nop.Core.Domain.Payments; +using Nop.Core.Domain.Security; +using Nop.Core.Domain.Shipping; +using Nop.Core.Domain.Stores; +using Nop.Core.Http; +using Nop.Services.Attributes; +using Nop.Services.Common; +using Nop.Services.Customers; +using Nop.Services.Directory; +using Nop.Services.Helpers; +using Nop.Services.Localization; +using Nop.Services.Orders; +using Nop.Services.Payments; +using Nop.Services.Shipping; +using Nop.Services.Shipping.Pickup; +using Nop.Web.Factories; +using Nop.Web.Framework.Mvc.Filters; +using Nop.Web.Models.Checkout; +using Nop.Web.Models.Common; +using OfficeOpenXml.FormulaParsing.Excel.Functions.Math; +using ILogger = Nop.Services.Logging.ILogger; + +namespace Nop.Web.Controllers; + +/** + * Notes: + * + * In the current OPC implementation the server decides the checkout flow, + * since each responses includes goto_section. In the current design, on the + * other hand, the user can interact with any part of the page. The JavasScript + * client managees state, and decides what sections needs to be updated. + * + * Checkout is treated as a shared state, whose fields gradually become known. + * Each section is responsible for modifying one of the fields. Other sections + * react to changes if thoey affect their own available options. + * + * In modern SPAs, the rendering happens locally on the client, and components + * re-render automatically. Meanwhile, in our case, the rendering happens on the + * server, and the client must explicitly ask for this. + * + * Each checkout section has a render endpoint, and the client-side checkout manager + * decides when to re-render each component. Each rendering endpoint will build + * a ViewModel based on the current checkout state, and then returns a partial view. + * + * Design questions: + * In the billing address section, should we add an option that represents + * the null address (for example "Please select an address")? + * In our current design, the select list is only rendered when there are + * available addresses, and in this case no option is provided for selecting + * a null address. Instead, the first available address is selected. + * + * Handling "Ship To Same Address": + * - When set to true for the first time, the shipping address should be updated. + * - When true, the shipping address should change each time the billing address is changed. + * - When true, the shipping address should change when a new billing address is added. + * + * Pickup is not a shipping method. Instead, it should be treated as a shipping mode. + * Shipping methods only exists when pickup is false. + * + * I prefer having something like "Delivery Option" that could be "Shipping" or "Pickup" + * instead of the current implementation. + */ +[AutoValidateAntiforgeryToken] +public class SpCheckoutController : BasePublicController +{ + #region Fields + + protected readonly AddressSettings _addressSettings; + protected readonly CaptchaSettings _captchaSettings; + protected readonly IAddressService _addressService; + protected readonly IAttributeParser _addressAttributeParser; + protected readonly ICheckoutModelFactory _checkoutModelFactory; + protected readonly ICountryService _countryService; + protected readonly ICustomerService _customerService; + protected readonly IGenericAttributeService _genericAttributeService; + protected readonly ILocalizationService _localizationService; + protected readonly ILogger _logger; + protected readonly IOrderProcessingService _orderProcessingService; + protected readonly IOrderService _orderService; + protected readonly IPaymentPluginManager _paymentPluginManager; + protected readonly IPaymentService _paymentService; + protected readonly IShippingService _shippingService; + protected readonly IShoppingCartService _shoppingCartService; + protected readonly IStoreContext _storeContext; + protected readonly IWebHelper _webHelper; + protected readonly IWorkContext _workContext; + protected readonly OrderSettings _orderSettings; + protected readonly PaymentSettings _paymentSettings; + protected readonly RewardPointsSettings _rewardPointsSettings; + protected readonly ShippingSettings _shippingSettings; + protected readonly IAddressModelFactory _addressModelFactory; + protected readonly IPickupPluginManager _pickupPluginManager; + private static readonly string[] _separator = ["___"]; + + #endregion + + #region Ctor + + public SpCheckoutController(AddressSettings addressSettings, + CaptchaSettings captchaSettings, + IAddressService addressService, + IAttributeParser addressAttributeParser, + ICheckoutModelFactory checkoutModelFactory, + ICountryService countryService, + ICustomerService customerService, + IGenericAttributeService genericAttributeService, + ILocalizationService localizationService, + ILogger logger, + IOrderProcessingService orderProcessingService, + IOrderService orderService, + IPaymentPluginManager paymentPluginManager, + IPaymentService paymentService, + IShippingService shippingService, + IShoppingCartService shoppingCartService, + IStoreContext storeContext, + IWebHelper webHelper, + IWorkContext workContext, + OrderSettings orderSettings, + PaymentSettings paymentSettings, + RewardPointsSettings rewardPointsSettings, + ShippingSettings shippingSettings, + IAddressModelFactory addressModelFactory, + IPickupPluginManager pickupPluginManager) + { + _addressSettings = addressSettings; + _captchaSettings = captchaSettings; + _addressService = addressService; + _addressAttributeParser = addressAttributeParser; + _checkoutModelFactory = checkoutModelFactory; + _countryService = countryService; + _customerService = customerService; + _genericAttributeService = genericAttributeService; + _localizationService = localizationService; + _logger = logger; + _orderProcessingService = orderProcessingService; + _orderService = orderService; + _paymentPluginManager = paymentPluginManager; + _paymentService = paymentService; + _shippingService = shippingService; + _shoppingCartService = shoppingCartService; + _storeContext = storeContext; + _webHelper = webHelper; + _workContext = workContext; + _orderSettings = orderSettings; + _paymentSettings = paymentSettings; + _rewardPointsSettings = rewardPointsSettings; + _shippingSettings = shippingSettings; + _addressModelFactory = addressModelFactory; + _pickupPluginManager = pickupPluginManager; + } + + #endregion + + #region Utilities + + protected virtual async Task IsMinimumOrderPlacementIntervalValidAsync(Customer customer) + { + //prevent 2 orders being placed within an X seconds time frame + if (_orderSettings.MinimumOrderPlacementInterval == 0) + return true; + + var store = await _storeContext.GetCurrentStoreAsync(); + + var lastOrder = (await _orderService.SearchOrdersAsync(storeId: store.Id, + customerId: customer.Id, pageSize: 1)) + .FirstOrDefault(); + if (lastOrder == null) + return true; + + var interval = DateTime.UtcNow - lastOrder.CreatedOnUtc; + return interval.TotalMinutes > _orderSettings.MinimumOrderPlacementInterval; + } + + protected virtual async Task> ValidatePaymentInfo( + IFormCollection paymentInfoForm, + Customer customer, + Store store, + IList cart) + { + var isPaymentWorkflowRequired = await _orderProcessingService.IsPaymentWorkflowRequiredAsync(cart); + if (isPaymentWorkflowRequired) + { + var paymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + var paymentMethod = await _paymentPluginManager + .LoadPluginBySystemNameAsync(paymentMethodSystemName, customer, store.Id) + ?? throw new Exception("Payment method is not selected"); + + var warnings = await paymentMethod.ValidatePaymentFormAsync(paymentInfoForm); + foreach (var warning in warnings) + { + ModelState.AddModelError("", warning); + } + + if (ModelState.IsValid) + { + await _orderProcessingService.SetProcessPaymentRequestAsync(await paymentMethod.GetPaymentInfoAsync(paymentInfoForm)); + } + else + { + return warnings; + } + } + + return new List(); + } + + protected virtual async Task ShippingAllowedToAddressAsync(Customer customer, int addressId) + { + var address = await _customerService.GetCustomerAddressAsync(customer.Id, addressId) + ?? throw new Exception(await _localizationService.GetResourceAsync("Checkout.Address.NotFound")); + + return !_addressSettings.CountryEnabled || ((await _countryService.GetCountryByAddressAsync(address))?.AllowsShipping ?? false); + } + + #endregion + + public virtual async Task SpCheckout() + { + //validation + if (_orderSettings.CheckoutDisabled) + return RedirectToRoute(NopRouteNames.General.CART); + + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + if (!cart.Any()) + return RedirectToRoute(NopRouteNames.General.CART); + + if (!_orderSettings.OnePageCheckoutEnabled) + return RedirectToRoute(NopRouteNames.Standard.CHECKOUT); + + if (await _customerService.IsGuestAsync(customer) && !_orderSettings.AnonymousCheckoutAllowed) + return Challenge(); + + return View(); + } + + [ValidateCaptcha] + [HttpPost] + public virtual async Task ConfirmOrder(IFormCollection paymentInfoForm, bool captchaValid) + { + try + { + // Validation + + if (_orderSettings.CheckoutDisabled) + return RedirectToRoute("ShoppingCart"); + + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + if (!cart.Any()) + return RedirectToRoute("ShoppingCart"); + + if (await _customerService.IsGuestAsync(customer) && !_orderSettings.AnonymousCheckoutAllowed) + return Challenge(); + + // Corresponds to EnterPaymentInfo() + var results = await ValidatePaymentInfo(paymentInfoForm, customer, store, cart); + if (results.Any()) + { + return Json(new + { + error = 1, + message = results + }); + } + + var errors = new List(); + + var isCaptchaSettingEnabled = await _customerService.IsGuestAsync(customer) && + _captchaSettings.Enabled && _captchaSettings.ShowOnCheckoutPageForGuests; + + //captcha validation for guest customers + if (!isCaptchaSettingEnabled || (isCaptchaSettingEnabled && captchaValid)) + { + //prevent 2 orders being placed within an X seconds time frame + if (!await IsMinimumOrderPlacementIntervalValidAsync(customer)) + throw new Exception(await _localizationService.GetResourceAsync("Checkout.MinOrderPlacementInterval")); + + // Order placement + + var processPaymentRequest = await _orderProcessingService.GetProcessPaymentRequestAsync(); + processPaymentRequest.StoreId = store.Id; + processPaymentRequest.CustomerId = customer.Id; + + // Get the payment method from the state. + processPaymentRequest.PaymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + + await _orderProcessingService.SetProcessPaymentRequestAsync(processPaymentRequest); + + var placeOrderResult = await _orderProcessingService.PlaceOrderAsync(processPaymentRequest); + + // Payment processing + + if (placeOrderResult.Success) + { + await _orderProcessingService.SetProcessPaymentRequestAsync(null); + var postProcessPaymentRequest = new PostProcessPaymentRequest + { + Order = placeOrderResult.PlacedOrder + }; + + var paymentRequired = await _orderProcessingService.IsPaymentWorkflowRequiredAsync(cart); + + if (!paymentRequired) + { + // Payment workflow could be not required if order total is 0 + return Json(new { success = 1 }); + } + + var paymentMethod = await _paymentPluginManager.LoadPluginBySystemNameAsync(placeOrderResult.PlacedOrder.PaymentMethodSystemName, customer, store.Id); + + if (paymentMethod.PaymentMethodType == PaymentMethodType.Redirection) + { + // An AJAX request cannot cause the browser to navigate to a new page by returning an MVC redirect. The redirect would only be followed by the XMLHttpRequest, not by the top-level window. From the user’s point of view, nothing would happen. + // This is not an HTTP redirect. It is just data. On the client side, the JavaScript handling the AJAX response checks whether the response contains a redirect property. If it does, the script explicitly assigns window.location.href to that URL. + + return Json(new + { + // This intermediate page exists for a few reasons. One, + // it exits the one - page checkout cleanly.Once the browser navigates to that page, + // nop is back in a normal, non-AJAX request context where redirects work as expected. + redirect = $"{_webHelper.GetStoreLocation()}checkout/OpcCompleteRedirectionPayment" + }); + } + + await _paymentService.PostProcessPaymentAsync(postProcessPaymentRequest); + + return Json(new { success = 1 }); + } + + foreach (var error in placeOrderResult.Errors) + { + errors.Add(error); + } + } + else + { + errors.Add(await _localizationService.GetResourceAsync("Common.WrongCaptchaMessage")); + } + + return Json(new + { + error = 1, + message = errors + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc); + return Json(new { error = 1, message = exc.Message }); + } + } + + public virtual async Task GetCheckoutConfiguration() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + var config = await _checkoutModelFactory.PrepareCheckoutConfigurationModelAsync(cart); + return Json(config); + } + + public virtual async Task GetCheckoutState() + { + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + + #region State management + + /** + * Shipping option should be reset whenever the shipping address changes. + */ + protected virtual async Task SetShippingAddressIdAsync(int? addressId) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + + customer.ShippingAddressId = addressId; + await _customerService.UpdateCustomerAsync(customer); + + // Reset selected shipping method + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, null, store.Id); + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, null, store.Id); + } + + /** + * Payment method should be reset whenever the billing address changes. + * Also, "Ship to Same Address" should be checked, to see if the + * shipping address need to be updated too. + */ + protected virtual async Task SetBillingAddressIdAsync(int? addressId) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + + customer.BillingAddressId = addressId; + + await TryMatchShippingAddress(); + + await _customerService.UpdateCustomerAsync(customer); + + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPaymentMethodAttribute, null, store.Id); + } + + #endregion + + #region Rendering endpoints + + public virtual async Task RenderBillingAddress() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + var store = await _storeContext.GetCurrentStoreAsync(); + + var model = new CheckoutBillingAddressModel(); + + await _checkoutModelFactory + .PrepareBillingAddressModelAsync(model, cart); + + model.SelectedBillingAddressId = customer.BillingAddressId; + + if (model.ShipToSameAddressAllowed) + { + model.ShipToSameAddress = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, store.Id); + } + + return PartialView("_SpcBillingAddress", model); + } + + public virtual async Task RenderShippingAddress() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + var store = await _storeContext.GetCurrentStoreAsync(); + + var shipToSameAddress = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, store.Id); + + //if (shipToSameAddress) + // return PartialView("_SpcStaticMessage", "Same as billing address."); + + var model = new CheckoutShippingAddressModel(); + await _checkoutModelFactory + .PrepareShippingAddressModelAsync(model, cart); + model.SelectedShippingAddressId = customer.ShippingAddressId; + model.SameAsBillingAddress = shipToSameAddress; + + return PartialView("_SpcShippingAddress", model); + } + + public virtual async Task RenderShippingMethods() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + + if (customer.ShippingAddressId is null) + return PartialView("_SpcStaticMessage", "Please select a shipping address to view the available shipping methods"); + + var address = await _customerService.GetCustomerAddressAsync(customer.Id, customer.ShippingAddressId.Value); + + // The prepare method already selects the previously selected method if found. + var model = await _checkoutModelFactory + .PrepareShippingMethodModelAsync(cart, address); + + // TODO: Handle the case when there's only a single shipping method. + + return PartialView("_SpcShippingMethods", model); + } + + public virtual async Task RenderPaymentMethods() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + + if (customer.BillingAddressId is null) + return PartialView("_SpcStaticMessage", "Please select a billing address to view the available payment methods"); + + var filterByCountryId = 0; + var address = await _customerService.GetCustomerAddressAsync(customer.Id, customer.BillingAddressId.Value); + if (_addressSettings.CountryEnabled) + filterByCountryId = address?.CountryId ?? 0; + + var model = await _checkoutModelFactory + .PreparePaymentMethodModelAsync(cart, filterByCountryId); + + return PartialView("_SpcPaymentMethods", model); + } + + // Assumptions: + // If this is called, then payment is required, and entering a payment info is required. + // This means there is no need to handle this in a user-friendly way (for example, by + // displaying a message like "Payment is not required". + public virtual async Task RenderPaymentInfo() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + var store = await _storeContext.GetCurrentStoreAsync(); + + // TODO: Remove. + //var isPaymentWorkflowRequired = await _orderProcessingService.IsPaymentWorkflowRequiredAsync(cart); + //if (!isPaymentWorkflowRequired) + // return PartialView("_SpcStaticMessage", "Payment is not required."); + + var paymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + + if (paymentMethodSystemName is null) + return PartialView("_SpcStaticMessage", "Please select a payment method first."); + + var paymentMethod = await _paymentPluginManager + .LoadPluginBySystemNameAsync(paymentMethodSystemName, customer, store.Id); + + // Check whether payment info should be skipped + + // TODO: + // Should it be become part of the state, so that the section can be disabled + // from the checkout manager? + if (paymentMethod.SkipPaymentInfo || + (paymentMethod.PaymentMethodType == PaymentMethodType.Redirection && _paymentSettings.SkipPaymentInfoStepForRedirectionPaymentMethods)) + { + await _orderProcessingService.SetProcessPaymentRequestAsync(new ProcessPaymentRequest()); + + return PartialView("_SpcStaticMessage", "Entering payment info is not required."); + } + + //model + var model = await _checkoutModelFactory.PreparePaymentInfoModelAsync(paymentMethod); + + return PartialView("_SpcPaymentInfo", model); + } + + public virtual async Task RenderConfirmOrder() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer); + + var model = await _checkoutModelFactory.PrepareConfirmOrderModelAsync(cart); + + return PartialView("_SpcConfirmOrder", model); + } + + public virtual async Task RenderAddressEditor(int addressId, string addressType) + { + //model.BillingNewAddress.CountryId = selectedCountryId; + + int? selectedCountryId = null; + bool prePopulateNewAddressWithCustomerFields = false; + string overrideAttributesXml = ""; + var customer = await _workContext.GetCurrentCustomerAsync(); + + var model = new AddressModel(); + + Address? address = null; + + if (addressId > 0) + address = await _customerService.GetCustomerAddressAsync(customer.Id, addressId) + ?? throw new Exception(await _localizationService.GetResourceAsync("Checkout.Address.NotFound")); + + await _addressModelFactory.PrepareAddressModelAsync(model, + address: address, + excludeProperties: false, + addressSettings: _addressSettings, + loadCountries: async () => + { + if (addressType == "billing") + return await _countryService.GetAllCountriesForBillingAsync((await _workContext.GetWorkingLanguageAsync()).Id); + else if (addressType == "shipping") + return await _countryService.GetAllCountriesForShippingAsync((await _workContext.GetWorkingLanguageAsync()).Id); + else + throw new ArgumentOutOfRangeException(nameof(addressType)); + + }, + prePopulateWithCustomerFields: prePopulateNewAddressWithCustomerFields, + customer: customer, + overrideAttributesXml: overrideAttributesXml); + + return PartialView("_AddressModal", model); + } + + #endregion + + #region State mutation endpoints + + /** + * Check if "Ship To Same Address" is enabled and try to update the shipping address + * so that it matches the billing address. If this is not possible, disable + * shipping to the same address and return false. + */ + protected virtual async Task TryMatchShippingAddress() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + var shipToSameAddress = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, store.Id); + + if (shipToSameAddress && await _shoppingCartService.ShoppingCartRequiresShippingAsync(cart)) + { + if (customer.BillingAddressId is not null) + { + if (!await ShippingAllowedToAddressAsync(customer, customer.BillingAddressId.Value)) + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, false, store.Id); + return false; + } + } + + // Shipping is allowed or address is null. Update in both cases. + await SetShippingAddressIdAsync(customer.BillingAddressId); + } + + return true; + } + + [HttpPost] + public virtual async Task SelectShippingAddress([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + await SetShippingAddressIdAsync(request.ShippingAddressId); + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + [HttpPost] + public virtual async Task SelectBillingAddress([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + await SetBillingAddressIdAsync(request.BillingAddressId); + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + [HttpPost] + public virtual async Task SelectShippingMethod([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + if (string.IsNullOrEmpty(request.ShippingOption)) + throw new Exception("Selected shipping method can't be parsed"); + + var splittedOption = request.ShippingOption.Split(_separator, StringSplitOptions.RemoveEmptyEntries); + if (splittedOption.Length != 2) + throw new Exception("Selected shipping method can't be parsed"); + var selectedName = splittedOption[0]; + var shippingRateComputationMethodSystemName = splittedOption[1]; + + //find it + //performance optimization. try cache first + var shippingOptions = await _genericAttributeService.GetAttributeAsync>(customer, + NopCustomerDefaults.OfferedShippingOptionsAttribute, store.Id); + if (shippingOptions == null || !shippingOptions.Any()) + { + //not found? let's load them using shipping service + shippingOptions = (await _shippingService.GetShippingOptionsAsync(cart, await _customerService.GetCustomerShippingAddressAsync(customer), + customer, shippingRateComputationMethodSystemName, store.Id)).ShippingOptions.ToList(); + } + else + { + //loaded cached results. let's filter result by a chosen shipping rate computation method + shippingOptions = shippingOptions.Where(so => so.ShippingRateComputationMethodSystemName.Equals(shippingRateComputationMethodSystemName, StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + } + + var shippingOption = shippingOptions.Find(so => !string.IsNullOrEmpty(so.Name) && so.Name.Equals(selectedName, StringComparison.InvariantCultureIgnoreCase)) + ?? throw new Exception("Selected shipping method can't be loaded"); + + //save + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, shippingOption, store.Id); + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + [HttpPost] + public virtual async Task SelectPaymentMethod([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + // TODO: Handle reward points + + //if (_rewardPointsSettings.Enabled) + //{ + // await _genericAttributeService.SaveAttributeAsync(customer, + // NopCustomerDefaults.UseRewardPointsDuringCheckoutAttribute, model.UseRewardPoints, + // store.Id); + //} + + var isPaymentWorkflowRequired = await _orderProcessingService.IsPaymentWorkflowRequiredAsync(cart); + if (!isPaymentWorkflowRequired) + { + // if payment is not required, keep the method null. + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, null, store.Id); + + /** IMPORTANT: + * In the standard checkout, a null value meant that payment is not required. + * However, in the current workflow, a null value could mean that the user hasn't + * yet chosen the method (because the billing address is not selected yet for instance). + * Confirming an order wasn't possible in the previous workflow without choosing a method, + * but it is possible in the new one. To handle this, additional checks were added + * inside the new ConfirmOrder. + */ + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + + var paymentMethodInst = await _paymentPluginManager + .LoadPluginBySystemNameAsync(request.PaymentMethodSystemName, customer, store.Id); + + if (!_paymentPluginManager.IsPluginActive(paymentMethodInst)) + throw new Exception("Selected payment method can't be parsed"); + + // Save + + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, request.PaymentMethodSystemName, store.Id); + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + [HttpPost] + public virtual async Task ToggleShipToSameAddress([FromBody] UpdateCheckoutStateRequestModel request) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + if (!_shippingSettings.ShipToSameAddress) + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, false, store.Id); + return Json(new + { + error = 1, + message = "Shipping to the same address is not enabled", + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + + if (request.ShipToSameAddress) + { + if (customer.BillingAddressId is not null) + { + if (!await ShippingAllowedToAddressAsync(customer, customer.BillingAddressId.Value)) + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, false, store.Id); + return Json(new + { + error = 1, + message = "Shipping to this address is not supported", + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + } + + // Shipping is allowed or address is null. Update in both cases. + await SetShippingAddressIdAsync(customer.BillingAddressId); + } + + // If shipping to the same address is disabled, don't modify the current shipping + // address, and leave it as it is. + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, request.ShipToSameAddress, store.Id); + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + + // In the previous implementation, we had to parse the "Pickup in Store" input toggle + // when the form is submitted. We don't need this parsing anymore. + [HttpPost] + public virtual async Task TogglePickupInStore([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + if (request.PickupInStore) + { + // How is "Pickup in Store" represented internally? + // It is represented by a non-null shipping option whose property + // IsPickupInStore is set to true. + + // In order to create a shipping option for "Pickup in Store" we have to select a pickup point. + + var pickupPointProviders = await _pickupPluginManager.LoadActivePluginsAsync(customer, store.Id); + if (pickupPointProviders.Any()) + { + var languageId = (await _workContext.GetWorkingLanguageAsync()).Id; + var address = customer.BillingAddressId.HasValue + ? await _addressService.GetAddressByIdAsync(customer.BillingAddressId.Value) + : null; + + var pickupPointsResponse = await _shippingService.GetPickupPointsAsync(cart, address, + customer, storeId: store.Id); + if (pickupPointsResponse.Success) + { + var pickupPoint = pickupPointsResponse.PickupPoints.FirstOrDefault(); + + var name = !string.IsNullOrEmpty(pickupPoint.Name) ? + string.Format(await _localizationService.GetResourceAsync("Checkout.PickupPoints.Name"), pickupPoint.Name) : + await _localizationService.GetResourceAsync("Checkout.PickupPoints.NullName"); + + var pickUpInStoreShippingOption = new ShippingOption + { + Name = name, + Rate = pickupPoint.PickupFee, + Description = pickupPoint.Description, + ShippingRateComputationMethodSystemName = pickupPoint.ProviderSystemName, + IsPickupInStore = true + }; + + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, pickUpInStoreShippingOption, store.Id); + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, pickupPoint, store.Id); + } + } + } + else + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, null, store.Id); + + //set value indicating that "pick up in store" option has not been chosen + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, null, store.Id); + } + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + // In the previous implementation, we had to parse the pickup point when the form is submitted. + // We don't need this parsing anymore. + [HttpPost] + public virtual async Task SelectPickupPoint([FromBody] UpdateCheckoutStateRequestModel request) + { + try + { + // Saving a pickup point requires editing the shipping option + // that represents "Pickup in Store". + + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + var pickupPoint = request.PickupPoint.ToString().Split(_separator, StringSplitOptions.None); + + var address = customer.BillingAddressId.HasValue + ? await _addressService.GetAddressByIdAsync(customer.BillingAddressId.Value) + : null; + + var selectedPoint = (await _shippingService.GetPickupPointsAsync(cart, address, + customer, pickupPoint[1], store.Id)).PickupPoints.FirstOrDefault(x => x.Id.Equals(pickupPoint[0])) + ?? throw new Exception("Pickup point is not allowed"); + + var pickUpInStoreShippingOption = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, store.Id); + + if (pickUpInStoreShippingOption is not null) + { + var name = !string.IsNullOrEmpty(selectedPoint.Name) ? + string.Format(await _localizationService.GetResourceAsync("Checkout.PickupPoints.Name"), selectedPoint.Name) : + await _localizationService.GetResourceAsync("Checkout.PickupPoints.NullName"); + + pickUpInStoreShippingOption.Name = name; + pickUpInStoreShippingOption.Rate = selectedPoint.PickupFee; + pickUpInStoreShippingOption.Description = selectedPoint.Description; + pickUpInStoreShippingOption.ShippingRateComputationMethodSystemName = selectedPoint.ProviderSystemName; + + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, pickUpInStoreShippingOption, store.Id); + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, pickupPoint, store.Id); + } + + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + #endregion + + #region Address modification + + // The model is used for the traditional properties, while the form + // captures all the custom attributes + public virtual async Task SaveEditBillingAddress( + AddressModel model, + IFormCollection form) + { + return await EditAddressAsync(model, form, async (customer, address) => + { + /** + * When editing an existing address, the following call + * will not lead to a change in the checkout state, since + * the address is already selected in the select list. + * However, if we're creating a new address, then first we + * want to select the newly created address. Second, if we + * had 0 addresses before, then we want to select the newly + * created address as a shipping address too. That's why we're + * repreparing the state. + * + * Alternatively, we could create a endpoint for editing an + * address (without changing the state), and a separate endpoint + * for adding an address (which will change at least on of the + * the addresses). + */ + await SetBillingAddressIdAsync(address.Id); + + return Json(new { state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync() }); + }); + } + + public virtual async Task SaveEditShippingAddress( + AddressModel model, + IFormCollection form) + { + return await EditAddressAsync(model, form, async (customer, address) => + { + await SetShippingAddressIdAsync(address.Id); + + return Json(new { state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync() }); + }); + } + + // TODO: Rename to something like SaveAddress or AddOrUpdateAddress + protected virtual async Task EditAddressAsync( + AddressModel addressModel, + IFormCollection form, + Func> getResult) + { + try + { + if (!ModelState.IsValid) + { + var errors = string.Join(", ", ModelState.Values.Where(p => p.Errors.Any()).SelectMany(p => p.Errors) + .Select(p => p.ErrorMessage)); + + throw new Exception(errors); + } + + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + if (!cart.Any()) + throw new Exception("Your cart is empty"); + + Address address = null; + + if (addressModel.Id > 0) + { + //find address (ensure that it belongs to the current customer) + address = await _customerService.GetCustomerAddressAsync(customer.Id, addressModel.Id) + ?? throw new Exception("Address can't be loaded"); + + //custom address attributes + var customAttributes = await _addressAttributeParser.ParseCustomAttributesAsync(form, NopCommonDefaults.AddressAttributeControlName); + var customAttributeWarnings = await _addressAttributeParser.GetAttributeWarningsAsync(customAttributes); + + if (customAttributeWarnings.Any()) + return Json(new { error = 1, message = customAttributeWarnings }); + + address = addressModel.ToEntity(address); + address.CustomAttributes = customAttributes; + + await _addressService.UpdateAddressAsync(address); + } + else // Originally in OpcSaveBilling() + { + // TODO: Handle VAT. + + //if (await _customerService.IsGuestAsync(customer) && _taxSettings.EuVatEnabled && _taxSettings.EuVatEnabledForGuests) + //{ + // var warning = await SaveCustomerVatNumberAsync(model.VatNumber, customer); + // if (!string.IsNullOrEmpty(warning)) + // ModelState.AddModelError("", warning); + //} + + //new address + var newAddress = addressModel; + + //custom address attributes + var customAttributes = await _addressAttributeParser.ParseCustomAttributesAsync(form, NopCommonDefaults.AddressAttributeControlName); + var customAttributeWarnings = await _addressAttributeParser.GetAttributeWarningsAsync(customAttributes); + + if (customAttributeWarnings.Any()) + return Json(new { error = 1, message = customAttributeWarnings }); + + //try to find an address with the same values (don't duplicate records) + address = _addressService.FindAddress((await _customerService.GetAddressesByCustomerIdAsync(customer.Id)).ToList(), + newAddress.FirstName, newAddress.LastName, newAddress.PhoneNumber, + newAddress.Email, newAddress.FaxNumber, newAddress.Company, + newAddress.Address1, newAddress.Address2, newAddress.City, + newAddress.County, newAddress.StateProvinceId, newAddress.ZipPostalCode, + newAddress.CountryId, customAttributes); + + if (address == null) + { + //address is not found. let's create a new one + address = newAddress.ToEntity(); + address.CustomAttributes = customAttributes; + address.CreatedOnUtc = DateTime.UtcNow; + + //some validation + if (address.CountryId == 0) + address.CountryId = null; + + if (address.StateProvinceId == 0) + address.StateProvinceId = null; + + await _addressService.InsertAddressAsync(address); + + await _customerService.InsertCustomerAddressAsync(customer, address); + } + } + + return await getResult(customer, address); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + /** + * Since this implementation updates the selected address in real-time, + * deleting an address is a state changing operation, since the current selected + * address will no longer be valid. + */ + [HttpPost] + public virtual async Task DeleteAddress([FromBody] int addressId) + { + try + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + if (!cart.Any()) + throw new Exception("Your cart is empty"); + + var address = await _customerService.GetCustomerAddressAsync(customer.Id, addressId); + if (address != null) + { + // This will reset the selected addresses. + await _customerService.RemoveCustomerAddressAsync(customer, address); + await _customerService.UpdateCustomerAsync(customer); + + await _addressService.DeleteAddressAsync(address); + } + + var state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(); + return Json(new + { + state = await _checkoutModelFactory.PrepareCheckoutStateModelAsync(), + requirements = await _checkoutModelFactory.PrepareCheckoutRequirementsModelAsync(), + }); + } + catch (Exception exc) + { + await _logger.WarningAsync(exc.Message, exc, await _workContext.GetCurrentCustomerAsync()); + return Json(new { error = 1, message = exc.Message }); + } + } + + /// + /// Get specified Address by addresId + /// + /// + public virtual async Task GetAddressById(int addressId) + { + var customer = await _workContext.GetCurrentCustomerAsync(); + Address address = null; + + if (addressId != 0) + { + address = await _customerService.GetCustomerAddressAsync(customer.Id, addressId); + ArgumentNullException.ThrowIfNull(address); + } + + var addressModel = new AddressModel(); + + await _addressModelFactory.PrepareAddressModelAsync(addressModel, + address: address, + excludeProperties: false, + addressSettings: _addressSettings, + prePopulateWithCustomerFields: true, + customer: customer); + + var json = JsonConvert.SerializeObject(addressModel, Formatting.Indented, + new JsonSerializerSettings + { + ReferenceLoopHandling = ReferenceLoopHandling.Ignore + }); + + return Content(json, "application/json"); + } + + #endregion +} \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Factories/CheckoutModelFactory.cs b/src/Presentation/Nop.Web/Factories/CheckoutModelFactory.cs index 315c0909fc8..fc4b76aa23b 100644 --- a/src/Presentation/Nop.Web/Factories/CheckoutModelFactory.cs +++ b/src/Presentation/Nop.Web/Factories/CheckoutModelFactory.cs @@ -1,10 +1,12 @@ -using Nop.Core; +using DocumentFormat.OpenXml.EMMA; +using Nop.Core; using Nop.Core.Domain.Common; using Nop.Core.Domain.Customers; using Nop.Core.Domain.Orders; using Nop.Core.Domain.Payments; using Nop.Core.Domain.Security; using Nop.Core.Domain.Shipping; +using Nop.Core.Domain.Stores; using Nop.Core.Domain.Tax; using Nop.Services.Catalog; using Nop.Services.Common; @@ -362,7 +364,9 @@ await _addressModelFactory.PrepareAddressModelAsync(model.ShippingNewAddress, /// A task that represents the asynchronous operation /// The task result contains the shipping method model /// - public virtual async Task PrepareShippingMethodModelAsync(IList cart, Address shippingAddress) + public virtual async Task PrepareShippingMethodModelAsync( + IList cart, + Address shippingAddress) { var model = new CheckoutShippingMethodModel { @@ -374,6 +378,7 @@ public virtual async Task PrepareShippingMethodMode var customer = await _workContext.GetCurrentCustomerAsync(); var store = await _storeContext.GetCurrentStoreAsync(); + var getShippingOptionResponse = await _shippingService.GetShippingOptionsAsync(cart, shippingAddress, customer, storeId: store.Id); if (getShippingOptionResponse.Success) { @@ -418,6 +423,7 @@ await _genericAttributeService.SaveAttributeAsync(customer, //find a selected (previously) shipping method var selectedShippingOption = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, store.Id); + if (selectedShippingOption != null) { var shippingOptionToSelect = model.ShippingMethods.ToList() @@ -426,19 +432,20 @@ await _genericAttributeService.SaveAttributeAsync(customer, so.Name.Equals(selectedShippingOption.Name, StringComparison.InvariantCultureIgnoreCase) && !string.IsNullOrEmpty(so.ShippingRateComputationMethodSystemName) && so.ShippingRateComputationMethodSystemName.Equals(selectedShippingOption.ShippingRateComputationMethodSystemName, StringComparison.InvariantCultureIgnoreCase)); - if (shippingOptionToSelect != null) + if (shippingOptionToSelect != null) shippingOptionToSelect.Selected = true; } + //if no option has been selected, let's do it for the first one if (model.ShippingMethods.FirstOrDefault(so => so.Selected) == null) { var shippingOptionToSelect = model.ShippingMethods.FirstOrDefault(); - if (shippingOptionToSelect != null) + if (shippingOptionToSelect != null) shippingOptionToSelect.Selected = true; } //notify about shipping from multiple locations - if (_shippingSettings.NotifyCustomerAboutShippingFromMultipleLocations) + if (_shippingSettings.NotifyCustomerAboutShippingFromMultipleLocations) model.NotifyCustomerAboutShippingFromMultipleLocations = getShippingOptionResponse.ShippingFromMultipleLocations; } else @@ -459,7 +466,9 @@ await _genericAttributeService.SaveAttributeAsync(customer, /// A task that represents the asynchronous operation /// The task result contains the payment method model /// - public virtual async Task PreparePaymentMethodModelAsync(IList cart, int filterByCountryId) + public virtual async Task PreparePaymentMethodModelAsync( + IList cart, + int filterByCountryId) { var model = new CheckoutPaymentMethodModel(); @@ -511,9 +520,10 @@ public virtual async Task PreparePaymentMethodModelA model.PaymentMethods.Add(pmModel); } - //find a selected (previously) payment method + // find a selected (previously) payment method var selectedPaymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + if (!string.IsNullOrEmpty(selectedPaymentMethodSystemName)) { var paymentMethodToSelect = model.PaymentMethods.ToList() @@ -521,12 +531,19 @@ public virtual async Task PreparePaymentMethodModelA if (paymentMethodToSelect != null) paymentMethodToSelect.Selected = true; } + //if no option has been selected, let's do it for the first one if (model.PaymentMethods.FirstOrDefault(so => so.Selected) == null) { var paymentMethodToSelect = model.PaymentMethods.FirstOrDefault(); if (paymentMethodToSelect != null) + { paymentMethodToSelect.Selected = true; + + // NEW: Update the state. + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, paymentMethodToSelect.PaymentMethodSystemName , store.Id); + } } return model; @@ -642,5 +659,296 @@ public virtual async Task PrepareOnePageCheckoutModelAsync return model; } + /** + * DisableBillingAddressCheckoutStep cannot be used if checkout guest is enabled. + * Privously, enabling this setting, led to an automatic submission of the billing + * address step using JavaScript. If the sumbission didn't work, the billing step was displayed. + * + * This automatic submission amounted to selecting the first available billing address, + * since NewAddressPreselected is only true when rerendering the view in case of validation + * errors. + * + * In this new implementation, selecting the first available address is implemented by + * default, therefore there is no need for any thing similar to automatic submission + * of a form. + * + * However, what if choosing the first address led to an error? In the previous implementation + * we had something like this: + * if (response.wrong_billing_address) { + * Accordion.showSection('#opc-billing'); + * } + * + * Yet, I don't see anyway where both disableBillingAddressStep and wrong_billing_address + * could be true at the same time in the old implementation. Auto submititng the form + * executed the following branch inside OpcSaveBilling() + * + * if (billingAddressId > 0) + * { + * //existing address + * var address = await _customerService.GetCustomerAddressAsync(customer.Id, billingAddressId) + * ?? throw new Exception(await _localizationService.GetResourceAsync("Checkout.Address.NotFound")); + * + * customer.BillingAddressId = address.Id; + * await _customerService.UpdateCustomerAsync(customer); + * } + * + * which never set wrong_billing_address in the response. + */ + public virtual async Task PrepareCheckoutConfigurationModelAsync(IList cart) + { + ArgumentNullException.ThrowIfNull(cart); + + var customer = await _workContext.GetCurrentCustomerAsync(); + + var model = new CheckoutCofigurationModel + { + DisableBillingAddressSection = _orderSettings.DisableBillingAddressCheckoutStep && (await _customerService.GetAddressesByCustomerIdAsync(customer.Id)).Any(), + DisplayCaptcha = await _customerService.IsGuestAsync(await _customerService.GetShoppingCartCustomerAsync(cart)) && _captchaSettings.Enabled && _captchaSettings.ShowOnCheckoutPageForGuests, + IsReCaptchaV3 = _captchaSettings.CaptchaType == CaptchaType.ReCaptchaV3, + ReCaptchaPublicKey = _captchaSettings.ReCaptchaPublicKey + }; + + return model; + } + + public virtual async Task PrepareCheckoutStateModelAsync() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + // Try to select the first available address. + + var billingAddressId = customer.BillingAddressId; + if (billingAddressId is null) + { + var firstAddress = await (await _customerService.GetAddressesByCustomerIdAsync(customer.Id)) + .WhereAwait(async a => !a.CountryId.HasValue || await _countryService.GetCountryByAddressAsync(a) is + { + Published: true, + AllowsBilling: true + } country + && + //enabled for the current store + await _storeMappingService.AuthorizeAsync(country)) + .FirstOrDefaultAsync(); + + if (firstAddress is not null) + { + customer.BillingAddressId = firstAddress.Id; + await _customerService.UpdateCustomerAsync(customer); + + billingAddressId = firstAddress.Id; + } + else + { + // Make sure the payment method is reset if the address is null. + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, null, store.Id); + } + } + + var shippingAddressId = customer.ShippingAddressId; + if (shippingAddressId is null) + { + var firstAddress = await (await _customerService.GetAddressesByCustomerIdAsync(customer.Id)) + .WhereAwait(async a => !a.CountryId.HasValue || await _countryService.GetCountryByAddressAsync(a) is + { + Published: true, + AllowsShipping: true + } country + && + //enabled for the current store + await _storeMappingService.AuthorizeAsync(country)) + .FirstOrDefaultAsync(); + + if (firstAddress is not null) + { + customer.ShippingAddressId = firstAddress.Id; + await _customerService.UpdateCustomerAsync(customer); + + shippingAddressId = firstAddress.Id; + } + else + { + // Make sure the shipping method is reset if the address is null. + await _genericAttributeService + .SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, null, store.Id); + } + } + + // Shipping + // Return the currently selected shipping option. + // First, if an option is selected, check if it is "Pickup". + // In this case, return the pickup point (or select the first one). + // If it is not pickup, make sure that the pickup point is null. + // + // If, however, no option is selected, try to select one. + + var selectedPickupPoint = await _genericAttributeService + .GetAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, store.Id); + + var shippingOption = await _genericAttributeService + .GetAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, store.Id); + + var pickupInStore = shippingOption is not null && shippingOption.IsPickupInStore; + PickupPoint? pickupPoint = null; + + if (pickupInStore is true) + { + pickupPoint = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, store.Id); + + if (pickupPoint is null) + { + // Try to select the first available point + + var address = customer.BillingAddressId.HasValue + ? await _addressService.GetAddressByIdAsync(customer.BillingAddressId.Value) + : null; + var pickupPointsResponse = await _shippingService.GetPickupPointsAsync(cart, address, + customer, storeId: store.Id); + if (pickupPointsResponse.Success) + { + pickupPoint = pickupPointsResponse.PickupPoints.FirstOrDefault(); + } + } + } + else + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedPickupPointAttribute, null, store.Id); + } + + if (shippingOption is null && shippingAddressId is not null) + { + Address shippingAddress = await _customerService.GetCustomerAddressAsync(customer.Id, shippingAddressId.Value); + + // TODO: Does this include "Pickup In Store" + var getShippingOptionResponse = await _shippingService.GetShippingOptionsAsync(cart, shippingAddress, customer, storeId: store.Id); + if (getShippingOptionResponse.Success) + { + //performance optimization. cache returned shipping options. + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.OfferedShippingOptionsAttribute, + getShippingOptionResponse.ShippingOptions, + store.Id); + + var availableOptions = getShippingOptionResponse.ShippingOptions; + + // Sort + if (availableOptions.Count > 1) + { + availableOptions = (_shippingSettings.ShippingSorting switch + { + ShippingSortingEnum.ShippingCost => availableOptions.OrderBy(option => option.Rate), + _ => availableOptions.OrderBy(option => option.DisplayOrder) + }).ToList(); + } + + var shippingOptionToSelect = availableOptions.FirstOrDefault(); + + if (shippingOptionToSelect is not null) + { + await _genericAttributeService.SaveAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, shippingOptionToSelect, store.Id); + shippingOption = shippingOptionToSelect; + } + } + } + + var paymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + + if (paymentMethodSystemName is null && billingAddressId is not null) + { + var filterByCountryId = 0; + if (_addressSettings.CountryEnabled) + filterByCountryId = (await _customerService.GetCustomerBillingAddressAsync(customer))?.CountryId ?? 0; + + var paymentMethods = await (await _paymentPluginManager + .LoadActivePluginsAsync(customer, store.Id, filterByCountryId)) + .Where(pm => pm.PaymentMethodType == PaymentMethodType.Standard || pm.PaymentMethodType == PaymentMethodType.Redirection) + .WhereAwait(async pm => !await pm.HidePaymentMethodAsync(cart)) + .ToListAsync(); + + var availableMethods = new List(); + foreach (var pm in paymentMethods) + { + if (await _shoppingCartService.ShoppingCartIsRecurringAsync(cart) && pm.RecurringPaymentType == RecurringPaymentType.NotSupported) + continue; + + availableMethods.Add(pm); + } + + var paymentMethodToSelect = availableMethods.FirstOrDefault(); + if (paymentMethodToSelect != null) + { + await _genericAttributeService.SaveAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, paymentMethodToSelect.PluginDescriptor.SystemName, store.Id); + paymentMethodSystemName = paymentMethodToSelect.PluginDescriptor.SystemName; + } + } + + // Ship to Same Address + + return new CheckoutStateModel + { + BillingAddressId = billingAddressId, + ShippingAddressId = shippingAddressId, + ShippingOption = shippingOption is null ? null : $"{shippingOption.Name}___{shippingOption.ShippingRateComputationMethodSystemName}", + PaymentMethodSystemName = paymentMethodSystemName, + ShipToSameAddress = await _genericAttributeService.GetAttributeAsync(customer, NopCustomerDefaults.ShipToSameAddressAttribute, store.Id), + PickupInStore = pickupInStore, + PickupPoint = pickupPoint is null ? null : $"{pickupPoint.Id}___{pickupPoint.ProviderSystemName}" + }; + } + + public virtual async Task PrepareCheckoutRequirementsModelAsync() + { + var customer = await _workContext.GetCurrentCustomerAsync(); + var store = await _storeContext.GetCurrentStoreAsync(); + var cart = await _shoppingCartService.GetShoppingCartAsync(customer, ShoppingCartType.ShoppingCart, store.Id); + + var shippingRequired = await _shoppingCartService.ShoppingCartRequiresShippingAsync(cart); + + var shippingMethodRequired = shippingRequired; + + if (shippingMethodRequired) + { + var shippingOption = await _genericAttributeService + .GetAttributeAsync(customer, NopCustomerDefaults.SelectedShippingOptionAttribute, store.Id); + + var pickupInStore = shippingOption is not null && shippingOption.IsPickupInStore; + + shippingMethodRequired = !pickupInStore; + } + + var paymentRequired = await _orderProcessingService.IsPaymentWorkflowRequiredAsync(cart); + + bool paymentInfoRequired = paymentRequired; + + // Payment info won't be required only there is specific payment method that + // does not require entering payment info. If the method is null, we assume + // that it is going to be required. + if (paymentInfoRequired) + { + var paymentMethodSystemName = await _genericAttributeService.GetAttributeAsync(customer, + NopCustomerDefaults.SelectedPaymentMethodAttribute, store.Id); + var paymentMethod = await _paymentPluginManager + .LoadPluginBySystemNameAsync(paymentMethodSystemName, customer, store.Id); + if (paymentMethod is not null) + { + paymentInfoRequired = !(paymentMethod.SkipPaymentInfo || + (paymentMethod.PaymentMethodType == PaymentMethodType.Redirection && _paymentSettings.SkipPaymentInfoStepForRedirectionPaymentMethods)); + } + } + + return new CheckoutRequirementsModel + { + ShippingRequired = shippingRequired, + ShippingMethodRequired = shippingMethodRequired, + PaymentRequired = paymentRequired, + PaymentInfoRequired = paymentInfoRequired + }; + } + #endregion } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Factories/ICheckoutModelFactory.cs b/src/Presentation/Nop.Web/Factories/ICheckoutModelFactory.cs index ea3dcb4ad6e..46ffb5a04ff 100644 --- a/src/Presentation/Nop.Web/Factories/ICheckoutModelFactory.cs +++ b/src/Presentation/Nop.Web/Factories/ICheckoutModelFactory.cs @@ -104,4 +104,10 @@ Task PrepareShippingAddressModelAsync(CheckoutShippingAddressModel model, IList< /// The task result contains the one page checkout model /// Task PrepareOnePageCheckoutModelAsync(IList cart); + + Task PrepareCheckoutConfigurationModelAsync(IList cart); + + Task PrepareCheckoutStateModelAsync(); + + Task PrepareCheckoutRequirementsModelAsync(); } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Infrastructure/RouteProvider.cs b/src/Presentation/Nop.Web/Infrastructure/RouteProvider.cs index 8f598a4fd20..a329f10660a 100644 --- a/src/Presentation/Nop.Web/Infrastructure/RouteProvider.cs +++ b/src/Presentation/Nop.Web/Infrastructure/RouteProvider.cs @@ -1,4 +1,5 @@ -using Nop.Core.Http; +using Nop.Core.Http; +using Microsoft.AspNetCore.Routing; using Nop.Services.Installation; using Nop.Web.Framework.Mvc.Routing; @@ -264,6 +265,104 @@ public virtual void RegisterRoutes(IEndpointRouteBuilder endpointRouteBuilder) pattern: $"download/getfileupload/{{downloadId}}", defaults: new { controller = "Download", action = "GetFileUpload" }); + + + + + + // Single-page checkout + + endpointRouteBuilder.MapControllerRoute(name: NopRouteNames.Standard.CHECKOUT_SINGLE_PAGE, + pattern: $"{lang}/spcheckout/", + defaults: new { controller = "SpCheckout", action = "SpCheckout" }); + + // Single-page checkout rendering routes (return partial views) + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderBillingAddress", + "spcheckout/render/billing-address", + new { controller = "SpCheckout", action = "RenderBillingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderShippingAddress", + "spcheckout/render/shipping-address", + new { controller = "SpCheckout", action = "RenderShippingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderShippingMethods", + "spcheckout/render/shipping-methods", + new { controller = "SpCheckout", action = "RenderShippingMethods" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderPaymentMethods", + "spcheckout/render/payment-methods", + new { controller = "SpCheckout", action = "RenderPaymentMethods" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderPaymentInfo", + "spcheckout/render/payment-info", + new { controller = "SpCheckout", action = "RenderPaymentInfo" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderConfirmOrder", + "spcheckout/render/confirm-order", + new { controller = "SpCheckout", action = "RenderConfirmOrder" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutRenderAddressEditor", + "spcheckout/render/address-editor", + new { controller = "SpCheckout", action = "RenderAddressEditor" }); + + // Single-page checkout state updating routes (AJAX / fetch) + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSelectShippingAddress", + "spcheckout/shipping-address/select", + new { controller = "SpCheckout", action = "SelectShippingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSelectBillingAddress", + "spcheckout/billing-address/select", + new { controller = "SpCheckout", action = "SelectBillingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSelectShippingMethod", + "spcheckout/shipping-method/select", + new { controller = "SpCheckout", action = "SelectShippingMethod" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSelectPickupPoint", + "spcheckout/pickup-point/select", + new { controller = "SpCheckout", action = "SelectPickupPoint" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSelectPaymentMethod", + "spcheckout/payment-method/select", + new { controller = "SpCheckout", action = "SelectPaymentMethod" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutToggleShipToSameAddress", + "spcheckout/billing-address/toggle-same-address", + new { controller = "SpCheckout", action = "ToggleShipToSameAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutTogglePickupInStore", + "spcheckout/billing-address/toggle-pickup-in-store", + new { controller = "SpCheckout", action = "TogglePickupInStore" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutConfirmOrder", + "spcheckout/confirm-order", + new { controller = "SpCheckout", action = "ConfirmOrder" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutConfiguration", + "spcheckout/configuration", + new { controller = "SpCheckout", action = "GetCheckoutConfiguration" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutState", + "spcheckout/state", + new { controller = "SpCheckout", action = "GetCheckoutState" }); + + // Single-page checkout address modification routes (AJAX / fetch) + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSaveBillingAddress", + "spcheckout/save-billing-address", + new { controller = "SpCheckout", action = "SaveEditBillingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutSaveShippingAddress", + "spcheckout/save-shipping-address", + new { controller = "SpCheckout", action = "SaveEditShippingAddress" }); + + endpointRouteBuilder.MapControllerRoute("SpCheckoutDeleteAddress", + "spcheckout/delete-address", + new { controller = "SpCheckout", action = "DeleteAddress" }); + + //checkout pages endpointRouteBuilder.MapControllerRoute(name: NopRouteNames.Standard.CHECKOUT, pattern: $"{lang}/checkout/", @@ -294,7 +393,7 @@ public virtual void RegisterRoutes(IEndpointRouteBuilder endpointRouteBuilder) defaults: new { controller = "Checkout", action = "ShippingMethod" }); endpointRouteBuilder.MapControllerRoute(name: NopRouteNames.Standard.CHECKOUT_PAYMENT_METHOD, - pattern: $"{lang}/checkout/paymentmethod", + pattern: $"{lang}/checkout/paymentmethod", defaults: new { controller = "Checkout", action = "PaymentMethod" }); endpointRouteBuilder.MapControllerRoute(name: NopRouteNames.Standard.CHECKOUT_PAYMENT_INFO, diff --git a/src/Presentation/Nop.Web/Models/Checkout/CheckoutBillingAddressModel.cs b/src/Presentation/Nop.Web/Models/Checkout/CheckoutBillingAddressModel.cs index fd4b9cf1683..89e7c65d3de 100644 --- a/src/Presentation/Nop.Web/Models/Checkout/CheckoutBillingAddressModel.cs +++ b/src/Presentation/Nop.Web/Models/Checkout/CheckoutBillingAddressModel.cs @@ -31,4 +31,8 @@ public CheckoutBillingAddressModel() [NopResourceDisplayName("Checkout.VatNumber")] public string VatNumber { get; set; } + + + // New properties + public int? SelectedBillingAddressId { get; set; } } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Models/Checkout/CheckoutConfigurationModel.cs b/src/Presentation/Nop.Web/Models/Checkout/CheckoutConfigurationModel.cs new file mode 100644 index 00000000000..213f62691aa --- /dev/null +++ b/src/Presentation/Nop.Web/Models/Checkout/CheckoutConfigurationModel.cs @@ -0,0 +1,12 @@ +namespace Nop.Web.Models.Checkout; + +public partial record CheckoutCofigurationModel +{ + public bool DisableBillingAddressSection { get; init; } + + public bool DisplayCaptcha { get; init; } + + public bool IsReCaptchaV3 { get; init; } + + public string ReCaptchaPublicKey { get; init; } +} diff --git a/src/Presentation/Nop.Web/Models/Checkout/CheckoutRequirementsModel.cs b/src/Presentation/Nop.Web/Models/Checkout/CheckoutRequirementsModel.cs new file mode 100644 index 00000000000..7dca2fa0b14 --- /dev/null +++ b/src/Presentation/Nop.Web/Models/Checkout/CheckoutRequirementsModel.cs @@ -0,0 +1,20 @@ +namespace Nop.Web.Models.Checkout; + +// The checkout requirements are dynamic, and should be derived from the cart +// and the current state. Unlike state, they cannot be changed directly by the user. +public class CheckoutRequirementsModel +{ + // Can be false when shipping is not required (no physical products for instance) + public bool ShippingRequired { get; set; } + + // Can be false when shipping is not required, or (int Nop's current implementation) + // when pickup in store is elected. + public bool ShippingMethodRequired { get; set; } + + // Can be false when payment is not required. + public bool PaymentRequired { get; set; } + + // Can be false when payment is not required, or when the selected payment method + // doesn't require payment info. + public bool PaymentInfoRequired { get; set; } +} diff --git a/src/Presentation/Nop.Web/Models/Checkout/CheckoutShippingAddressModel.cs b/src/Presentation/Nop.Web/Models/Checkout/CheckoutShippingAddressModel.cs index a4fd4ee8ceb..86a5e519915 100644 --- a/src/Presentation/Nop.Web/Models/Checkout/CheckoutShippingAddressModel.cs +++ b/src/Presentation/Nop.Web/Models/Checkout/CheckoutShippingAddressModel.cs @@ -21,4 +21,9 @@ public CheckoutShippingAddressModel() public bool DisplayPickupInStore { get; set; } public CheckoutPickupPointsModel PickupPointsModel { get; set; } + + // New properties. + + public int? SelectedShippingAddressId { get; set; } + public bool SameAsBillingAddress { get; set; } } \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Models/Checkout/CheckoutStateModel.cs b/src/Presentation/Nop.Web/Models/Checkout/CheckoutStateModel.cs new file mode 100644 index 00000000000..0d7b692d7cd --- /dev/null +++ b/src/Presentation/Nop.Web/Models/Checkout/CheckoutStateModel.cs @@ -0,0 +1,30 @@ + +namespace Nop.Web.Models.Checkout; + +public class CheckoutStateModel +{ + public int? ShippingAddressId { get; set; } + + public string? ShippingOption { get; set; } + + public int? BillingAddressId { get; set; } + + public string? PaymentMethodSystemName { get; set; } + + public bool ShipToSameAddress { get; set; } + + // Pickup + + /** + * How is pickup represented in the system? + * It is represented as a ShippingOption (stored as a generic attribute under + * NopCustomerDefaults.SelectedShippingOptionAttribute) like other options, + * but whose property IsPickupInStore is true. + * + * The pickup point on the other hand is stored under NopCustomerDefaults.SelectedPickupPointAttribute. + */ + + public bool PickupInStore { get; set; } + + public string? PickupPoint { get; set; } +} diff --git a/src/Presentation/Nop.Web/Models/Checkout/UpdateCheckoutStateRequestModel.cs b/src/Presentation/Nop.Web/Models/Checkout/UpdateCheckoutStateRequestModel.cs new file mode 100644 index 00000000000..5c69676f266 --- /dev/null +++ b/src/Presentation/Nop.Web/Models/Checkout/UpdateCheckoutStateRequestModel.cs @@ -0,0 +1,18 @@ +namespace Nop.Web.Models.Checkout; + +public partial record UpdateCheckoutStateRequestModel +{ + public int? BillingAddressId { get; set; } + + public int? ShippingAddressId { get; set; } + + public string? ShippingOption { get; set; } + + public string? PaymentMethodSystemName { get; set; } + + public bool ShipToSameAddress { get; set; } + + public bool PickupInStore { get; set; } + + public string PickupPoint { get; set; } +} diff --git a/src/Presentation/Nop.Web/Nop.Web.csproj b/src/Presentation/Nop.Web/Nop.Web.csproj index 7f29a44983a..59ac1b18e54 100644 --- a/src/Presentation/Nop.Web/Nop.Web.csproj +++ b/src/Presentation/Nop.Web/Nop.Web.csproj @@ -63,6 +63,10 @@ + + + + diff --git a/src/Presentation/Nop.Web/Plugins/Uploaded/placeholder.txt b/src/Presentation/Nop.Web/Plugins/Uploaded/placeholder.txt deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/SpCheckout.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/SpCheckout.cshtml new file mode 100644 index 00000000000..8e2c4273a6d --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/SpCheckout.cshtml @@ -0,0 +1,140 @@ +@using Nop.Services.Helpers +@using Nop.Web.Controllers +@inject IWebHelper webHelper + +@{ + Layout = "_ColumnsOne"; + var storeLocation = webHelper.GetStoreLocation(); + NopHtml.AddTitleParts(T("PageTitle.Checkout").Text); +} + + + +@* Temporarily here until the desired design is reached. *@ + + +
+
+

@T("Checkout")

+
+
+
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+
+
+
+ + +
+ +@* Temporarily here until the desired design is reached *@ + + +@* \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_AddressModal.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_AddressModal.cshtml new file mode 100644 index 00000000000..ff21899856f --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_AddressModal.cshtml @@ -0,0 +1,50 @@ +@model AddressModel +@using Nop.Services.Helpers +@inject IWebHelper webHelper + +@{ + var storeLocation = webHelper.GetStoreLocation(); +} + +@* The form was originally in OnePageCheckout.cshtml *@ +
+ + @* TODO: Handle Vat. Only in billing address *@ +@* @if (Model.EuVatEnabled) + { +
+ + @if (Model.EuVatEnabledForGuests) + { + + + } + else + { + + @T("Checkout.VatNumber.Disabled", Url.RouteUrl(NopRouteNames.General.CUSTOMER_INFO)) + + } +
+ } *@ + + +
+
+ @{ + // TODO: Fix here. + + // var dataDictAddress = new ViewDataDictionary(ViewData); + // dataDictAddress.TemplateInfo.HtmlFieldPrefix = "BillingNewAddress"; + @await Html.PartialAsync("_CreateOrUpdateAddress", Model) + } +
+ +
+ +
+ +
\ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_PickupPoints.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_PickupPoints.cshtml new file mode 100644 index 00000000000..e99b8a2904a --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_PickupPoints.cshtml @@ -0,0 +1,190 @@ +@model CheckoutPickupPointsModel + +@using System.Text + +@if (!Model.PickupInStoreOnly && Model.PickupPoints.Any()) +{ +
+
+ + +
+
+ @T("Checkout.PickupPoints.Description") +
+
+} + +@* We used to hide the form using events when toggling "Pickup in Store" *@ +@if (Model.PickupInStore) +{ +
+ @if (Model.PickupPoints.Any()) + { + if (Model.PickupInStoreOnly) + { + + } +
+ @if (Model.PickupPoints.Count == 1) + { + var point = Model.PickupPoints.First(); + +
    +
  • @point.Name
  • +
  • @($"{point.AddressLine}")
  • + @if (!string.IsNullOrEmpty(point.PickupFee)) + { +
  • @point.PickupFee
  • + } +
+ } + else + { + + + } +
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.CheckoutPickUpPointsAfter, additionalData = Model }) + + if (Model.DisplayPickupPointsOnMap) + { + var src = $"https://maps.googleapis.com/maps/api/js{(string.IsNullOrEmpty(Model.GoogleMapsApiKey) ? string.Empty : $"?key={Model.GoogleMapsApiKey}")}"; +
+ +
+ } + } +
+
    + @foreach (var warning in Model.Warnings) + { +
  • @warning
  • + } +
+
+
+} \ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcBillingAddress.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcBillingAddress.cshtml new file mode 100644 index 00000000000..aa7607e2a89 --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcBillingAddress.cshtml @@ -0,0 +1,94 @@ +@model CheckoutBillingAddressModel +@using Nop.Services.Helpers +@inject IWebHelper webHelper + +@{ + var storeLocation = webHelper.GetStoreLocation(); +} + +
+ +
+

@T("Checkout.BillingAddress")

+
+ + +
+
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutBillingAddressTop, additionalData = Model }) + + @* This already will be false if shipping is not required *@ + @if (Model.ShipToSameAddressAllowed) + { +
+

+ + +

+
+ } + + @if (Model.ExistingAddresses.Count > 0) + { +
+ + @if (Model.InvalidExistingAddresses.Count > 0) + { + + } +
+ + + + + + +
+
+ } + +
+ +
+ + @* TODO: Should probably be removed since there is no middle location now. *@ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutBillingAddressMiddle, additionalData = Model }) + + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutBillingAddressBottom, additionalData = Model }) + +
+
+
diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcConfirmOrder.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcConfirmOrder.cshtml new file mode 100644 index 00000000000..4d8b85f1185 --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcConfirmOrder.cshtml @@ -0,0 +1,75 @@ +@model CheckoutConfirmModel + +
+ +
+

@T("Checkout.ConfirmOrder")

+
+ +
+
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutConfirmTop, additionalData = Model }) + + @if (!string.IsNullOrEmpty(Model.MinOrderTotalWarning) || Model.Warnings.Count > 0) + { +
+ @if (!string.IsNullOrEmpty(Model.MinOrderTotalWarning)) + { +
+ @Model.MinOrderTotalWarning +
+ } + @if (Model.Warnings.Count > 0) + { +
+
    + @foreach (var warning in Model.Warnings) + { +
  • @warning
  • + } +
+
+ } +
+ } + + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutConfirmBottom, additionalData = Model }) + +
+ @await Component.InvokeAsync(typeof(OrderSummaryViewComponent), new { prepareAndDisplayOrderReviewData = false }) +
+ + @if (string.IsNullOrEmpty(Model.MinOrderTotalWarning) && Model.TermsOfServiceOnOrderConfirmPage) + { + +
+ + + @if (Model.TermsOfServicePopup) + { + @T("Checkout.TermsOfService.Read") + + } + else + { + @T("Checkout.TermsOfService.Read") + } +
+ } + @if (Model.DisplayCaptcha) + { + + } +
+
+
\ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentInfo.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentInfo.cshtml new file mode 100644 index 00000000000..41c861cb7b2 --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentInfo.cshtml @@ -0,0 +1,34 @@ +@model CheckoutPaymentInfoModel + +
+ +
+

@T("Checkout.PaymentInfo")

+
+ + + +
+ +
+ @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutPaymentInfoTop, additionalData = Model }) + +
+
+ @await Component.InvokeAsync(Model.PaymentViewComponent) +
+
+
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutPaymentInfoBottom, additionalData = Model }) + + @if (Model.DisplayOrderTotals) + { +
+ @await Component.InvokeAsync(typeof(OrderSummaryViewComponent)) +
+ } +
+
+ +
\ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentMethods.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentMethods.cshtml new file mode 100644 index 00000000000..dd01104b521 --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcPaymentMethods.cshtml @@ -0,0 +1,87 @@ +@model CheckoutPaymentMethodModel + +
+ +
+

@T("Checkout.PaymentMethod")

+
+ + + +
+
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutPaymentMethodTop, additionalData = Model }) + +
+ @if (Model.DisplayRewardPoints && Model.PaymentMethods.Count > 0) + { +
+ @if (Model.RewardPointsEnoughToPayForOrder) + { + + } + else + { + + } + + @if (Model.RewardPointsEnoughToPayForOrder) + { + + } +
+ } + @if (Model.PaymentMethods.Count > 0) + { +
    + @for (var i = 0; i < Model.PaymentMethods.Count; i++) + { + var paymentMethod = Model.PaymentMethods[i]; + var paymentMethodName = paymentMethod.Name; + if (!string.IsNullOrEmpty(paymentMethod.Fee)) + { + paymentMethodName = T("Checkout.SelectPaymentMethod.MethodAndFee", paymentMethodName, paymentMethod.Fee).Text; + } +
  • +
    + @if (!string.IsNullOrEmpty(paymentMethod.LogoUrl)) + { + + } +
    + + + @if (!string.IsNullOrEmpty(paymentMethod.Description)) + { +
    @paymentMethod.Description
    + } +
    +
    +
  • + } +
+ } + else + { +
+ @T("Checkout.NoPaymentMethods") +
+ } +
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutPaymentMethodBottom, additionalData = Model }) +
+
+
diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingAddress.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingAddress.cshtml new file mode 100644 index 00000000000..1c05454d82b --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingAddress.cshtml @@ -0,0 +1,113 @@ +@model CheckoutShippingAddressModel +@using Nop.Services.Helpers +@inject IWebHelper webHelper + +@{ + var storeLocation = webHelper.GetStoreLocation(); +} + +
+ +
+

@T("Checkout.ShippingAddress")

+
+ + + +
+ @if (!Model.SameAsBillingAddress) + { +
+ @if (Model.DisplayPickupInStore && Model.PickupPointsModel.AllowPickupInStore) + { + @await Html.PartialAsync("_PickupPoints", Model.PickupPointsModel) + } + + @* + If there are not shipping providers, PickupInStoreOnly will be true + PickupPointsModel won't be null even if pickup in store is not toggled + *@ + @if (Model.PickupPointsModel == null || !Model.PickupPointsModel.PickupInStoreOnly) + { + @* We used to hide the form when the toggle is clicked *@ + @if (!(Model.PickupPointsModel != null && Model.PickupPointsModel.PickupInStore)) + { +
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutShippingAddressTop, additionalData = Model }) + + @if (Model.ExistingAddresses.Count > 0) + { +
+ + @if (Model.InvalidExistingAddresses.Count > 0) + { + + } +
+ + + + + + + +
+
+ } + +
+ +
+ + @* TODO: Should probably be removed since there is no middle location now. *@ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutShippingAddressMiddle, additionalData = Model }) + + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutShippingAddressBottom, additionalData = Model }) + +
+ } + } + +
+ } + else + { +

Same as billing address.

+ } +
+
\ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingMethods.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingMethods.cshtml new file mode 100644 index 00000000000..875f05ba51b --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcShippingMethods.cshtml @@ -0,0 +1,84 @@ +@model CheckoutShippingMethodModel + +
+ +
+

@T("Checkout.ShippingMethod")

+
+ + + +
+
+ @if (Model.DisplayPickupInStore && Model.PickupPointsModel.AllowPickupInStore) + { + @await Html.PartialAsync("_PickupPoints", Model.PickupPointsModel) + + } + @if (Model.PickupPointsModel == null || !Model.PickupPointsModel.PickupInStoreOnly) + { +
+ + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutShippingMethodTop, additionalData = Model }) + + @if (!Model.Warnings.Any()) + { +
    + @for (var i = 0; i < Model.ShippingMethods.Count; i++) + { + var shippingMethod = Model.ShippingMethods[i]; +
  • +
    + + +
    + @if (!string.IsNullOrEmpty(shippingMethod.Description)) + { +
    + @Html.Raw(shippingMethod.Description) +
    + } +
  • + } +
+ if (Model.NotifyCustomerAboutShippingFromMultipleLocations) + { +
+ @T("Checkout.ShippingMethod.ShippingFromMultipleLocations") +
+ } + } + else + { +
+
    + @foreach (var warning in Model.Warnings) + { +
  • @warning
  • + } +
+
+ } + + @await Component.InvokeAsync(typeof(WidgetViewComponent), new { widgetZone = PublicWidgetZones.OpCheckoutShippingMethodBottom, additionalData = Model }) + +
+ } +
+
+ +
\ No newline at end of file diff --git a/src/Presentation/Nop.Web/Views/SpCheckout/_SpcStaticMessage.cshtml b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcStaticMessage.cshtml new file mode 100644 index 00000000000..de79c5d52ba --- /dev/null +++ b/src/Presentation/Nop.Web/Views/SpCheckout/_SpcStaticMessage.cshtml @@ -0,0 +1,3 @@ +@model string + +

@Model

\ No newline at end of file diff --git a/src/Presentation/Nop.Web/wwwroot/js/public.countryselect.js b/src/Presentation/Nop.Web/wwwroot/js/public.countryselect.js index 493a162e0a0..58ae3e1b545 100644 --- a/src/Presentation/Nop.Web/wwwroot/js/public.countryselect.js +++ b/src/Presentation/Nop.Web/wwwroot/js/public.countryselect.js @@ -35,7 +35,9 @@ alert('Failed to retrieve states.'); }, complete: function(jqXHR, textStatus) { - var stateId = (typeof Billing !== "undefined") ? Billing.selectedStateId : (typeof CheckoutBilling !== "undefined") ? CheckoutBilling.selectedStateId : 0; + var stateId = (typeof Billing !== "undefined") ? Billing.selectedStateId + : (typeof CheckoutBilling !== "undefined") ? CheckoutBilling.selectedStateId + : (typeof AddressForm !== "undefined") ? AddressForm.selectedStateId : 0; $('#' + stateProvince[0].id + ' option[value=' + stateId + ']').prop('selected', true); loading.hide(); diff --git a/src/Presentation/Nop.Web/wwwroot/js/public.onepagecheckout.js b/src/Presentation/Nop.Web/wwwroot/js/public.onepagecheckout.js index 864d9575196..9ca7e4c7aa6 100644 --- a/src/Presentation/Nop.Web/wwwroot/js/public.onepagecheckout.js +++ b/src/Presentation/Nop.Web/wwwroot/js/public.onepagecheckout.js @@ -177,6 +177,7 @@ var Billing = { nextStep: function(response) { //ensure that response.wrong_billing_address is set //if not set, "true" is the default value + // TODO: Is the above comment wrong or the current implementation? if (typeof response.wrong_billing_address === 'undefined') { response.wrong_billing_address = false; } @@ -339,7 +340,7 @@ var Shipping = { this.saveUrl = saveUrl; }, - newAddress: function (id, billingAddressId) { + newAddress: function (id, billingAddressId) { isNew = !id; if (isNew) { this.resetSelectedAddress(); diff --git a/src/Presentation/Nop.Web/wwwroot/js/public.singlepagecheckout.js b/src/Presentation/Nop.Web/wwwroot/js/public.singlepagecheckout.js new file mode 100644 index 00000000000..295ac256cef --- /dev/null +++ b/src/Presentation/Nop.Web/wwwroot/js/public.singlepagecheckout.js @@ -0,0 +1,704 @@ +var CheckoutManager = { + urls: null, + + state: { + billingAddressId: null, + shippingAddressId: null, + shippingOption: null, + paymentMethodSystemName: null, + shipToSameAddress: false, + pickupInStore: false, + }, + + requirements: { + shippingRequired: false, + shippingMethodRequired: false, + paymentRequired: false, + paymentInfoRequired: false, + }, + + // Connect each "state component" with the sections that depends on it. + dependencyGraph: { + billingAddressId: ['paymentMethod', 'paymentInfo', 'confirmOrder'], + shippingAddressId: ['shippingMethod', 'confirmOrder'], + shippingOption: ['confirmOrder'], + paymentMethodSystemName: ['paymentInfo', 'confirmOrder'], + shipToSameAddress: ['shippingAddress', 'shippingMethod'], + pickupInStore: ['shippingAddress', 'shippingMethod', 'confirmOrder'], + pickupPoint: ['shippingAddress', 'shippingMethod', 'confirmOrder'] + }, + + domIds: null, + + // Requirements determines visibility. + activationRules: new Map([ + ['billingAddress', req => true], + ['shippingAddress', req => req.shippingRequired], + ['shippingMethod', req => req.shippingMethodRequired], + ['paymentMethod', req => req.paymentRequired], + ['paymentInfo', req => req.paymentInfoRequired], + ['confirmOrder', req => true] + ]), + + // A set deduplicates automatically so adding the same section key twice + // won't cause any problems. + pendingRenders: new Set(), + + // Connect each "section" with its render method. + renderMap: null, + + domIds: { + billingAddress: 'billing-address-section-content', + shippingAddress: 'shipping-address-section-content', + shippingMethod: 'shipping-methods-section-content', + paymentMethod: 'payment-methods-section-content', + paymentInfo: 'payment-info-section-content', + confirmOrder: 'confirm-order-section-content' + }, + + config: { + isCaptchaEnabled: false, + isReCaptchaV3: false, + recaptchaPublicKey: null, + }, + + // Bootstrapping + + init: async function (urls) { + this.urls = urls; + + this.renderMap = new Map([ + ['billingAddress', () => this.renderBillingAddress()], + ['shippingAddress', () => this.renderShippingAddress()], + ['shippingMethod', () => this.renderShippingMethods()], + ['paymentMethod', () => this.renderPaymentMethods()], + ['paymentInfo', () => this.renderPaymentInfo()], + ['confirmOrder', () => this.renderConfirmOrder()], + ]); + + // TODO: Handle failure. + const [config, checkout] = await Promise.all([ + fetch(this.urls.getCheckoutConfiguration).then(r => r.json()), + fetch(this.urls.getCheckoutState).then(r => r.json()) + ]); + + // TODO: Fix naming. Either send camelCase from the sever or use PascalCase everywhere here. + + this.state.billingAddressId = checkout.state.BillingAddressId; + this.state.shippingAddressId = checkout.state.ShippingAddressId; + this.state.shippingOption = checkout.state.ShippingOption; + this.state.paymentMethodSystemName = checkout.state.PaymentMethodSystemName; + this.state.shipToSameAddress = checkout.state.ShipToSameAddress; + + this.requirements.shippingRequired = checkout.requirements.ShippingRequired; + this.requirements.shippingMethodRequired = checkout.requirements.ShippingMethodRequired; + this.requirements.paymentRequired = checkout.requirements.PaymentRequired; + this.requirements.paymentInfoRequired = checkout.requirements.PaymentInfoRequired; + + this.config.isCaptchaEnabled = config.IsCaptchaEnabled; + this.config.isReCaptchaV3 = config.IsReCaptchaV3; + this.config.recaptchaPublicKey = config.RecaptchaPublicKey; + this.config.shippingRequired = config.ShippingRequired; + + //this.initialActivation(); + await this.initialRender(); + }, + + initialRender: async function () { + for (const section of this.renderMap.keys()) { + const isActive = this.activationRules.get(section)?.(this.requirements); + + if (isActive) { + this.pendingRenders.add(section); + } else { + this.hideSection(section); + } + } + + await this.flushRenders(); + }, + + // State management + + updateLocalRequirements: function (newRequirements) { + const old = this.requirements; + this.requirements = newRequirements; + + this.evaluateSections(old, newRequirements); + }, + + evaluateSections: function (oldRequirements, newRequirements) { + for (const section of this.renderMap.keys()) { + + const rule = this.activationRules.get(section); + const wasActive = rule?.(oldRequirements); + const isActive = rule?.(newRequirements); + + if (wasActive && !isActive) { + this.hideSection(section); + } + + if (!wasActive && isActive) { + this.showSection(section); + this.pendingRenders.add(section); + } + } + }, + + updateLocalState: async function(newState) { + for (const [key, value] of Object.entries(newState)) { + if (this.state[key] === value) { + continue; + } + + this.state[key] = value; + this.scheduleRenderingOfDependents(key); + } + + await this.flushRenders(); + }, + + scheduleRenderingOfDependents: function (stateKey) { + const affectedSections = this.dependencyGraph[stateKey] ?? []; + + for (const section of affectedSections) { + const isActive = this.activationRules.get(section)?.(this.requirements); + if (isActive) { + this.showSection(section); + this.pendingRenders.add(section); + } else { + this.hideSection(section); + } + } + }, + + flushRenders: async function () { + if (this.pendingRenders.size === 0) { + return; + } + + for (const [key, render] of this.renderMap) { + if (this.pendingRenders.has(key)) { + await render(); + } + } + + this.pendingRenders.clear(); + }, + + hideSection: function (sectionKey) { + const id = this.domIds[sectionKey]; + const section = document.getElementById(id); + if (!section) return; + + section.classList.add('hidden'); + }, + + showSection: function (sectionKey) { + const id = this.domIds[sectionKey]; + const section = document.getElementById(id); + if (!section) return; + + section.classList.remove('hidden'); + }, + + // Render methods (do not change state.) + + renderBillingAddress: async function () { + WaitingManager.begin('billing-address'); + + try { + const html = await fetch(this.urls.renderBillingAddress).then(r => r.text()); + document.getElementById('billing-address-section-content').innerHTML = html; + + this.bindBillingAddressEvents(); + } + catch { + this.ajaxFailure(); + } + finally { + WaitingManager.end('billing-address'); + } + }, + + renderShippingAddress: async function () { + WaitingManager.begin('shipping-address'); + + try { + const html = await fetch(this.urls.renderShippingAddress).then(r => r.text()); + document.getElementById('shipping-address-section-content').innerHTML = html; + + this.bindShippingAddressEvents(); + } + catch { + this.ajaxFailure(); + } + finally { + WaitingManager.end('shipping-address'); + } + }, + + renderShippingMethods: async function () { + WaitingManager.begin('shipping-methods'); + + try { + const html = await fetch(this.urls.renderShippingMethods).then(r => r.text()); + document.getElementById('shipping-methods-section-content').innerHTML = html; + + this.bindShippingMethodEvents(); + } + catch { + this.ajaxFailure(); + } + finally { + WaitingManager.end('shipping-methods'); + } + }, + + renderPaymentMethods: async function () { + WaitingManager.begin('payment-methods'); + + try { + const html = await fetch(this.urls.renderPaymentMethods).then(r => r.text()); + document.getElementById('payment-methods-section-content').innerHTML = html; + + this.bindPaymentMethodEvents(); + } + catch { + this.ajaxFailure(); + } + finally { + WaitingManager.end('payment-methods'); + } + }, + + renderPaymentInfo: async function () { + WaitingManager.begin('payment-info'); + + try { + const html = await fetch(this.urls.renderPaymentInfo).then(r => r.text()); + document.getElementById('payment-info-section-content').innerHTML = html; + } + finally { + WaitingManager.end('payment-info'); + } + }, + + renderConfirmOrder: async function () { + WaitingManager.begin('confirm-order'); + + try { + const html = await fetch(this.urls.renderConfirmOrder).then(r => r.text()); + document.getElementById('confirm-order-section-content').innerHTML = html; + } + catch { + this.ajaxFailure(); + } + finally { + WaitingManager.end('confirm-order'); + } + }, + + // Event binding + + bindBillingAddressEvents: function () { + document + .querySelector('input[type="checkbox"][name="ShipToSameAddress"]') + ?.addEventListener('change', async e => { + await this.updateCheckoutState({ shipToSameAddress: e.target.checked }, this.urls.toggleShipToSameAddress, 'billing-address'); + }); + + document + .getElementById('billing-address-select') + ?.addEventListener('change', async e => { + // TODO: Explain. + const id = e.target.value || null; + + await this.updateCheckoutState({ billingAddressId: id }, this.urls.selectBillingAddress, 'billing-address'); + }); + }, + + bindShippingAddressEvents: function () { + document + .getElementById('shipping-address-select') + ?.addEventListener('change', async e => { + const id = e.target.value || null; + + await this.updateCheckoutState({ shippingAddressId: id }, this.urls.selectShippingAddress, 'shipping-address'); + }); + + document + .querySelector('input[type="checkbox"][name="PickupInStore"]') + ?.addEventListener('change', async e => { + await this.updateCheckoutState({ pickupInStore: e.target.checked }, this.urls.togglePickupInStore, 'shipping-address'); + }); + + document + .getElementById('pickup-points-select') + ?.addEventListener('change', async e => { + const id = e.target.value || null; + + await this.updateCheckoutState({ pickupPoint: id }, this.urls.selectPickupPoint, 'shipping-address'); + }); + }, + + bindShippingMethodEvents: function () { + document + .querySelectorAll('#shipping-method-block input[type="radio"][name="shippingoption"]') + .forEach(radio => { + radio.addEventListener('change', async e => { + if (e.target.checked) { + await this.updateCheckoutState({ shippingOption: e.target.value }, this.urls.selectShippingMethod, 'shipping-method'); + } + }); + }); + }, + + bindPaymentMethodEvents: function () { + document + .querySelectorAll('#payment-method-block input[type="radio"][name="paymentmethod"]') + .forEach(radio => { + radio.addEventListener('change', async e => { + if (e.target.checked) { + await this.updateCheckoutState({ paymentMethodSystemName: e.target.value }, this.urls.selectPaymentMethod, 'payment-method'); + } + }); + }); + }, + + // Order confirmation + + confirmOrder: async function () { + WaitingManager.begin('confirm-button'); + + var termOfServiceOk = true; + + // This element could appear in the confirm order section. + if ($('#termsofservice').length > 0) { + if (!$('#termsofservice').is(':checked')) { + $("#terms-of-service-warning-box").dialog(); + termOfServiceOk = false; + } else { + termOfServiceOk = true; + } + } + + if (termOfServiceOk) { + var form = $('#co-payment-info-form').serialize(); + + if (this.isCaptchaEnabled) { + var captchaTok = await this.getCaptchaToken('OpcConfirmOrder'); + form['g-recaptcha-response'] = captchaTok; + } + + addAntiForgeryToken(form); + $.ajax({ + cache: false, + url: this.urls.confirmOrder, + data: form, + type: "POST", + success: this.handleConfirmationSuccess, + complete: WaitingManager.end('confirm-button'), + error: this.ajaxFailure + }); + } else { + // TODO: Handle this. + return false; + } + }, + + getCaptchaToken: async function (action) { + var recaptchaToken = ''; + + if (this.isReCaptchaV3) { + grecaptcha.ready(() => { + grecaptcha.execute(this.recaptchaPublicKey, { action: action }).then((token) => { + recaptchaToken = token; + }); + }); + while (recaptchaToken == '') { + await new Promise(t => setTimeout(t, 100)); + } + } else { + recaptchaToken = $(this.div).find('.captcha-box textarea[name="g-recaptcha-response"]').val(); + } + + return recaptchaToken; + }, + + handleConfirmationSuccess: function (response) { + if (response.error) { + if (typeof response.message === 'string') { + alert(response.message); + } else { + alert(response.message.join("\n")); + } + + return false; + } + + if (response.redirect) { + location.href = response.redirect; + return; + } + if (response.success) { + window.location = CheckoutManager.urls.confirmSuccess; + } + }, + + // Address deletion + + deleteAddress: async function (addressId) { + try { + const token = document.querySelector( + 'input[name="__RequestVerificationToken"][value]:not([value=""])' + )?.value; + + const response = await fetch(this.urls.deleteAddress, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'RequestVerificationToken': token + }, + body: addressId + }); + + if (!response.ok) { + throw new Error('HTTP error'); + } + + var result = await response.json(); + await this.resyncAddresses(result.state); + } + catch(e) { + this.ajaxFailure(); + } + }, + + resyncAddresses: async function (state) { + // Force re-rendering the addresses. + + this.pendingRenders.add('billingAddress'); + this.pendingRenders.add('shippingAddress'); + + await this.updateLocalState({ + billingAddressId: state.BillingAddressId, + shippingAddressId: state.ShippingAddressId, + shippingOption: state.ShippingOption, + paymentMethodSystemName: state.PaymentMethodSystemName, + shipToSameAddress: state.ShipToSameAddress, + pickupInStore: state.PickupInStore, + pickupPoint: state.PickupPoint + }); + }, + + // Network requests + + updateCheckoutState: async function (patchRequest, url, waitingElementKey) { + WaitingManager.begin(waitingElementKey); + + try { + const token = document.querySelector( + 'input[name="__RequestVerificationToken"][value]:not([value=""])' + )?.value; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'RequestVerificationToken': token + }, + body: JSON.stringify(patchRequest) + }); + + if (!response.ok) { + throw new Error('HTTP error'); + } + + const result = await response.json(); + + if (result.requirements) { + this.updateLocalRequirements({ + shippingRequired: result.requirements.ShippingRequired, + shippingMethodRequired: result.requirements.ShippingMethodRequired, + paymentRequired: result.requirements.PaymentRequired, + paymentInfoRequired: result.requirements.PaymentInfoRequired + }); + } + + if (result.state) { + await this.updateLocalState({ + billingAddressId: result.state.BillingAddressId, + shippingAddressId: result.state.ShippingAddressId, + shippingOption: result.state.ShippingOption, + paymentMethodSystemName: result.state.PaymentMethodSystemName, + shipToSameAddress: result.state.ShipToSameAddress, + pickupInStore: result.state.PickupInStore, + pickupPoint: result.state.PickupPoint + }); + } + } + catch (e) { + this.ajaxFailure(); + } + finally { + WaitingManager.end(waitingElementKey); + } + }, + + // More like 'fetchFailure' in the current implementation. + ajaxFailure: function () { + location.href = this.urls.failureUrl; + }, +} + +var WaitingManager = { + waiting: new Set(), + + begin: function (sectionKey) { + if (this.waiting.has(sectionKey)) return; + + this.waiting.add(sectionKey); + + const section = document.getElementById(`${sectionKey}-section`); + if (!section) return; + + section.classList.add('is-waiting'); + + const waitingEl = section.querySelector('.waiting-indicator'); + if (waitingEl) { + waitingEl.classList.remove('hidden'); + } + + this.disableInputs(section, true); + }, + + end: function (sectionKey) { + if (!this.waiting.has(sectionKey)) return; + + this.waiting.delete(sectionKey); + + const section = document.getElementById(`${sectionKey}-section`); + if (!section) return; + + section.classList.remove('is-waiting'); + + const waitingEl = section.querySelector('.waiting-indicator'); + if (waitingEl) { + waitingEl.classList.add('hidden'); + } + + this.disableInputs(section, false); + }, + + disableInputs: function (container, disabled) { + container + .querySelectorAll('input, select, textarea, button') + .forEach(el => { + el.disabled = disabled; + }); + } +} + +var AddressEditor = { + // In the OPC implementation, the form represented the whole billing section. + // However, in our current implementation, it only represents the form that appears + // inside the modal. + form: false, + addressType: '', + urls: null, + + // Used in public.countryselect.js + selectedStateId: 0, + + init: function (form, urls) { + this.urls = urls; + this.form = form; + }, + + editAddress: async function (addressId, addressType) { + this.addressType = addressType; + + const params = new URLSearchParams({ + addressId: addressId, + addressType: addressType + }); + + const response = await fetch(this.urls.renderEditor + `?${params.toString()}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + }); + + const html = await response.text(); + + document.getElementById('address-editor-content').innerHTML = html; + + this.initializeCountrySelect(); + + $('#edit-address-form').dialog({ width: 700 }); + }, + + saveEditAddress: function () { + var dataArray = $(this.form).serializeArray(); + var data = {}; + dataArray.forEach(item => data[item.name] = item.value); + + const tokenInput = document.querySelector( + 'input[name="__RequestVerificationToken"][value]:not([value=""])' + ); + data.__RequestVerificationToken = tokenInput.value; + + $.ajax({ + cache: false, + url: (this.addressType === 'billing' ? this.urls.saveBillingAddress : this.urls.saveShippingAddress), + data: data, + type: "POST", + success: async function (result) { + if (result.error) { + alert(result.message); + return false; + } else { + await CheckoutManager.resyncAddresses(result.state); + + AddressEditor.closeModal(); + } + }, + error: CheckoutManager.ajaxFailure + }); + }, + + resetAddressForm: function () { + $(':input', '#edit-address-form') + .not(':button, :submit, :reset, :hidden') + .removeAttr('checked').removeAttr('selected') + $(':input', '#edit-address-form') + .not(':checkbox, :radio, select') + .val(''); + + $('.address-id', '#edit-address-form').val('0'); + $('select option[value="0"]', '#edit-address-form').prop('selected', true); + }, + + // Modal methods + + showModal: function () { + $('#edit-address-form').dialog({ width: 700 }); + }, + + closeModal: function () { + $('#edit-address-form').dialog('close'); + }, + + // Utilities + + initializeCountrySelect: function () { + if ($('#edit-address-form').has('select[data-trigger="country-select"]')) { + $('#edit-address-form select[data-trigger="country-select"]').countrySelect(); + } + }, + + setSelectedStateId: function (id) { + this.selectedStateId = id; + }, +}; \ No newline at end of file