From 350fad45cde66388d78819b3b254d4928b5ee36f Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 10:52:56 +0200 Subject: [PATCH 1/3] Let shipping-by-weight records be queried per store The store-owner panel needs to list only the rates belonging to its own store, and doing that in the controller would page over every store's records first. Filtering in the service keeps paging correct and matches how TaxRateService.GetAllTaxRates already scopes its query. The store id joins the cache key so a per-store read cannot serve a previously cached all-stores page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bf2UUL1avH6UD5Mxxzu7UK --- .../Admin/Controllers/ShippingByWeightController.cs | 3 ++- .../Services/IShippingByWeightService.cs | 9 ++++++++- .../Services/ShippingByWeightService.cs | 13 ++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs b/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs index 758218bbe0..35d5c4cbfe 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Controllers/ShippingByWeightController.cs @@ -92,7 +92,8 @@ public async Task SaveGeneralSettings(ShippingByWeightListModel m [AutoValidateAntiforgeryToken] public async Task RatesList(DataSourceRequest command) { - var records = await _shippingByWeightService.GetAll(command.Page - 1, command.PageSize); + //main admin sees the records of every store + var records = await _shippingByWeightService.GetAll("", command.Page - 1, command.PageSize); var sbwModel = new List(); foreach (var x in records) diff --git a/src/Plugins/Shipping.ByWeight/Services/IShippingByWeightService.cs b/src/Plugins/Shipping.ByWeight/Services/IShippingByWeightService.cs index 38b269f5b4..28048e90a4 100644 --- a/src/Plugins/Shipping.ByWeight/Services/IShippingByWeightService.cs +++ b/src/Plugins/Shipping.ByWeight/Services/IShippingByWeightService.cs @@ -7,7 +7,14 @@ public interface IShippingByWeightService { Task DeleteShippingByWeightRecord(ShippingByWeightRecord shippingByWeightRecord); - Task> GetAll(int pageIndex = 0, int pageSize = int.MaxValue); + /// + /// Gets shipping by weight records + /// + /// The store identifier; pass "" to load records of all stores + /// Page index + /// Page size + Task> GetAll(string storeId = "", int pageIndex = 0, + int pageSize = int.MaxValue); Task FindRecord(string shippingMethodId, string storeId, string warehouseId, diff --git a/src/Plugins/Shipping.ByWeight/Services/ShippingByWeightService.cs b/src/Plugins/Shipping.ByWeight/Services/ShippingByWeightService.cs index 737616e991..88a7c2138d 100644 --- a/src/Plugins/Shipping.ByWeight/Services/ShippingByWeightService.cs +++ b/src/Plugins/Shipping.ByWeight/Services/ShippingByWeightService.cs @@ -20,7 +20,7 @@ public ShippingByWeightService(ICacheBase cacheBase, #region Constants - private const string SHIPPINGBYWEIGHT_ALL_KEY = "Grand.shippingbyweight.all-{0}-{1}"; + private const string SHIPPINGBYWEIGHT_ALL_KEY = "Grand.shippingbyweight.all-{0}-{1}-{2}"; private const string SHIPPINGBYWEIGHT_PATTERN_KEY = "Grand.shippingbyweight."; #endregion @@ -43,13 +43,16 @@ public virtual async Task DeleteShippingByWeightRecord(ShippingByWeightRecord sh await _cacheBase.RemoveByPrefix(SHIPPINGBYWEIGHT_PATTERN_KEY); } - public virtual async Task> GetAll(int pageIndex = 0, int pageSize = int.MaxValue) + public virtual async Task> GetAll(string storeId = "", int pageIndex = 0, + int pageSize = int.MaxValue) { - var key = string.Format(SHIPPINGBYWEIGHT_ALL_KEY, pageIndex, pageSize); + var key = string.Format(SHIPPINGBYWEIGHT_ALL_KEY, storeId, pageIndex, pageSize); return await _cacheBase.GetAsync(key, async () => { - var query = from sbw in _sbwRepository.Table - select sbw; + var query = _sbwRepository.Table.AsQueryable(); + //filter by store when requested; an empty storeId returns records of all stores + if (!string.IsNullOrEmpty(storeId)) + query = query.Where(sbw => sbw.StoreId == storeId); return await _sbwRepository.PagedAsync(query, pageIndex, pageSize); }); From 5175d8357fc5994fe7d8465ee0a18f98662c38d8 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 10:53:04 +0200 Subject: [PATCH 2/3] Give store owners their own shipping plugin configuration screens A provider's ConfigurationUrl is relative, so the same value resolves under /Admin/ for the main admin and under /Store/ for the store-owner panel. Both shipping plugins only shipped an [Area("Admin")] controller, so following the link from Store/Shipping/Providers hit a route that does not exist and returned 404. Both plugins store their data per store already, so the screens are genuinely usable by a store owner - they just had nowhere to live. The Store controllers mirror Tax.CountryStateZip's: the store id always comes from StaffStoreId rather than the posted model, and every read, edit and delete is confined to that store, so a store owner can neither see nor change another store's rates. The by-weight plugin settings are written as a per-store override so one owner cannot change the others' behaviour. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bf2UUL1avH6UD5Mxxzu7UK --- .../Controllers/ShippingByWeightController.cs | 351 ++++++++++++++++++ .../Views/ShippingByWeight/AddPopup.cshtml | 41 ++ .../Views/ShippingByWeight/Configure.cshtml | 235 ++++++++++++ .../Views/ShippingByWeight/EditPopup.cshtml | 41 ++ .../ShippingByWeight/_CreateOrUpdate.cshtml | 148 ++++++++ .../Areas/Store/Views/_ViewImports.cshtml | 9 + .../Controllers/ShippingPointController.cs | 178 +++++++++ .../Views/ShippingPoint/Configure.cshtml | 144 +++++++ .../Store/Views/ShippingPoint/Create.cshtml | 64 ++++ .../Store/Views/ShippingPoint/Edit.cshtml | 64 ++++ .../ShippingPoint/_CreateOrUpdate.cshtml | 75 ++++ .../Areas/Store/Views/_ViewImports.cshtml | 9 + 12 files changed, 1359 insertions(+) create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/AddPopup.cshtml create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/Configure.cshtml create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/EditPopup.cshtml create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/_CreateOrUpdate.cshtml create mode 100644 src/Plugins/Shipping.ByWeight/Areas/Store/Views/_ViewImports.cshtml create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Controllers/ShippingPointController.cs create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Configure.cshtml create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Create.cshtml create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Edit.cshtml create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/_CreateOrUpdate.cshtml create mode 100644 src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/_ViewImports.cshtml diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs b/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs new file mode 100644 index 0000000000..c48f82d2dd --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Controllers/ShippingByWeightController.cs @@ -0,0 +1,351 @@ +using Grand.Business.Core.Interfaces.Catalog.Directory; +using Grand.Business.Core.Interfaces.Checkout.Shipping; +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Directory; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Shipping.ByWeight.Domain; +using Shipping.ByWeight.Models; +using Shipping.ByWeight.Services; +using System.Text; + +namespace Shipping.ByWeight.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the "by weight" shipping rate provider. +/// A store owner can add, edit and delete rate records, but only the ones that belong +/// to his own store (). Records owned by other stores or +/// global (store = *) records are never returned nor mutated, and the plugin settings +/// are saved as a per-store override rather than system wide. +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.ShippingSettings)] +public class ShippingByWeightController : BaseController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ICountryService _countryService; + private readonly ICurrencyService _currencyService; + private readonly CurrencySettings _currencySettings; + private readonly IMeasureService _measureService; + private readonly MeasureSettings _measureSettings; + private readonly ISettingService _settingService; + private readonly IShippingByWeightService _shippingByWeightService; + private readonly IShippingMethodService _shippingMethodService; + private readonly ITranslationService _translationService; + private readonly IWarehouseService _warehouseService; + + public ShippingByWeightController( + IWarehouseService warehouseService, + IShippingMethodService shippingMethodService, + ICountryService countryService, + IShippingByWeightService shippingByWeightService, + ISettingService settingService, + ITranslationService translationService, + ICurrencyService currencyService, + CurrencySettings currencySettings, + IMeasureService measureService, + MeasureSettings measureSettings, + IContextAccessor contextAccessor) + { + _warehouseService = warehouseService; + _shippingMethodService = shippingMethodService; + _countryService = countryService; + _shippingByWeightService = shippingByWeightService; + _settingService = settingService; + _translationService = translationService; + _currencyService = currencyService; + _currencySettings = currencySettings; + _measureService = measureService; + _measureSettings = measureSettings; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current staff/store-manager is bound to. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + //per-store override of the plugin settings - the global values are the fallback + var settings = await _settingService.LoadSetting(CurrentStoreId); + + var model = new ShippingByWeightListModel { + LimitMethodsToCreated = settings.LimitMethodsToCreated, + DisplayOrder = settings.DisplayOrder + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task SaveGeneralSettings(ShippingByWeightListModel model) + { + var settings = await _settingService.LoadSetting(CurrentStoreId); + settings.LimitMethodsToCreated = model.LimitMethodsToCreated; + settings.DisplayOrder = model.DisplayOrder; + + //save as a per-store override - never touch the system-wide value + await _settingService.SaveSetting(settings, CurrentStoreId); + + return Json(new { Result = true }); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task RatesList(DataSourceRequest command) + { + //only the current store records - filtered and paged in the service layer + var records = await _shippingByWeightService.GetAll(CurrentStoreId, command.Page - 1, command.PageSize); + + var sbwModel = new List(); + foreach (var x in records) + { + var m = new ShippingByWeightModel { + Id = x.Id, + StoreId = x.StoreId, + WarehouseId = x.WarehouseId, + ShippingMethodId = x.ShippingMethodId, + CountryId = x.CountryId, + From = x.From, + To = x.To, + AdditionalFixedCost = x.AdditionalFixedCost, + PercentageRateOfSubtotal = x.PercentageRateOfSubtotal, + RatePerWeightUnit = x.RatePerWeightUnit, + LowerWeightLimit = x.LowerWeightLimit + }; + //shipping method + var shippingMethod = await _shippingMethodService.GetShippingMethodById(x.ShippingMethodId); + m.ShippingMethodName = shippingMethod != null ? shippingMethod.Name : "Unavailable"; + //warehouse + var warehouse = await _warehouseService.GetWarehouseById(x.WarehouseId); + m.WarehouseName = warehouse != null ? warehouse.Name : "*"; + //country + var c = await _countryService.GetCountryById(x.CountryId); + m.CountryName = c != null ? c.Name : "*"; + //state + var s = c?.StateProvinces.FirstOrDefault(y => y.Id == x.StateProvinceId); + m.StateProvinceName = s != null ? s.Name : "*"; + //zip + m.Zip = !string.IsNullOrEmpty(x.Zip) ? x.Zip : "*"; + + var htmlSb = new StringBuilder("
"); + htmlSb.Append($"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.From")}: {m.From}"); + htmlSb.Append("
"); + htmlSb.Append($"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.To")}: {m.To}"); + htmlSb.Append("
"); + htmlSb.Append( + $"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.AdditionalFixedCost")}: {m.AdditionalFixedCost}"); + htmlSb.Append("
"); + htmlSb.Append( + $"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.RatePerWeightUnit")}: {m.RatePerWeightUnit}"); + htmlSb.Append("
"); + htmlSb.Append( + $"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.LowerWeightLimit")}: {m.LowerWeightLimit}"); + htmlSb.Append("
"); + htmlSb.Append( + $"{_translationService.GetResource("Plugins.Shipping.ByWeight.Fields.PercentageRateOfSubtotal")}: {m.PercentageRateOfSubtotal}"); + htmlSb.Append("
"); + m.DataHtml = htmlSb.ToString(); + + sbwModel.Add(m); + } + + var gridModel = new DataSourceResult { + Data = sbwModel, + Total = records.TotalCount + }; + + return Json(gridModel); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Delete)] + public async Task RateDelete(string id) + { + var sbw = await _shippingByWeightService.GetById(id); + //guard: a store owner can only delete his own store records + if (sbw == null || sbw.StoreId != CurrentStoreId) + return new JsonResult(""); + + await _shippingByWeightService.DeleteShippingByWeightRecord(sbw); + + return new JsonResult(""); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task AddPopup() + { + var model = new ShippingByWeightModel { + //the owner cannot create records for another store + StoreId = CurrentStoreId, + //CurrencySettings is resolved per current store, so this already honours a store override + PrimaryStoreCurrencyCode = + (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name, + To = 1000000 + }; + + var shippingMethods = await _shippingMethodService.GetAllShippingMethods(storeId: CurrentStoreId); + if (shippingMethods.Count == 0) + return Content("No shipping methods can be loaded"); + + await PrepareSelectLists(model, shippingMethods, null, null, null); + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task AddPopup(ShippingByWeightModel model) + { + var sbw = new ShippingByWeightRecord { + //force the current store - the owner cannot create records for another store + StoreId = CurrentStoreId, + WarehouseId = model.WarehouseId, + CountryId = model.CountryId, + StateProvinceId = model.StateProvinceId, + Zip = model.Zip == "*" ? null : model.Zip, + ShippingMethodId = model.ShippingMethodId, + From = model.From, + To = model.To, + AdditionalFixedCost = model.AdditionalFixedCost, + RatePerWeightUnit = model.RatePerWeightUnit, + PercentageRateOfSubtotal = model.PercentageRateOfSubtotal, + LowerWeightLimit = model.LowerWeightLimit + }; + await _shippingByWeightService.InsertShippingByWeightRecord(sbw); + + ViewBag.RefreshPage = true; + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task EditPopup(string id) + { + var sbw = await _shippingByWeightService.GetById(id); + //guard: a store owner can only open his own store records + if (sbw == null || sbw.StoreId != CurrentStoreId) + return RedirectToAction("Configure"); + + var model = new ShippingByWeightModel { + Id = sbw.Id, + StoreId = sbw.StoreId, + WarehouseId = sbw.WarehouseId, + CountryId = sbw.CountryId, + StateProvinceId = sbw.StateProvinceId, + Zip = sbw.Zip, + ShippingMethodId = sbw.ShippingMethodId, + From = sbw.From, + To = sbw.To, + AdditionalFixedCost = sbw.AdditionalFixedCost, + PercentageRateOfSubtotal = sbw.PercentageRateOfSubtotal, + RatePerWeightUnit = sbw.RatePerWeightUnit, + LowerWeightLimit = sbw.LowerWeightLimit, + //CurrencySettings is resolved per current store, so this already honours a store override + PrimaryStoreCurrencyCode = + (await _currencyService.GetCurrencyById(_currencySettings.PrimaryStoreCurrencyId))?.CurrencyCode, + BaseWeightIn = (await _measureService.GetMeasureWeightById(_measureSettings.BaseWeightId)).Name + }; + + var shippingMethods = await _shippingMethodService.GetAllShippingMethods(storeId: CurrentStoreId); + if (shippingMethods.Count == 0) + return Content("No shipping methods can be loaded"); + + await PrepareSelectLists(model, shippingMethods, sbw.WarehouseId, sbw.ShippingMethodId, sbw.CountryId, + sbw.StateProvinceId); + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task EditPopup(ShippingByWeightModel model) + { + var sbw = await _shippingByWeightService.GetById(model.Id); + //guard: a store owner can only edit his own store records + if (sbw == null || sbw.StoreId != CurrentStoreId) + return RedirectToAction("Configure"); + + //StoreId is deliberately not taken from the model - the record stays in the owner's store + sbw.WarehouseId = model.WarehouseId; + sbw.CountryId = model.CountryId; + sbw.StateProvinceId = model.StateProvinceId; + sbw.Zip = model.Zip == "*" ? null : model.Zip; + sbw.ShippingMethodId = model.ShippingMethodId; + sbw.From = model.From; + sbw.To = model.To; + sbw.AdditionalFixedCost = model.AdditionalFixedCost; + sbw.RatePerWeightUnit = model.RatePerWeightUnit; + sbw.PercentageRateOfSubtotal = model.PercentageRateOfSubtotal; + sbw.LowerWeightLimit = model.LowerWeightLimit; + await _shippingByWeightService.UpdateShippingByWeightRecord(sbw); + + ViewBag.RefreshPage = true; + + return View(model); + } + + /// + /// Fills the drop-downs of the add/edit form. No store selector is offered - the record + /// always belongs to the owner's own store - and warehouses are limited to the ones the + /// store may use (its own, plus the ones shared by every store). + /// + [NonAction] + private async Task PrepareSelectLists(ShippingByWeightModel model, + IList shippingMethods, + string selectedWarehouseId, string selectedShippingMethodId, string selectedCountryId, + string selectedStateProvinceId = null) + { + //warehouses + model.AvailableWarehouses.Add(new SelectListItem { Text = "*", Value = "" }); + var warehouses = (await _warehouseService.GetAllWarehouses()) + .Where(x => string.IsNullOrEmpty(x.StoreId) || x.StoreId == CurrentStoreId); + foreach (var warehouse in warehouses) + model.AvailableWarehouses.Add(new SelectListItem { + Text = warehouse.Name, Value = warehouse.Id, + Selected = warehouse.Id == selectedWarehouseId + }); + //shipping methods + foreach (var sm in shippingMethods) + model.AvailableShippingMethods.Add(new SelectListItem { + Text = sm.Name, Value = sm.Id, + Selected = sm.Id == selectedShippingMethodId + }); + //countries + model.AvailableCountries.Add(new SelectListItem { Text = "*", Value = "" }); + var countries = await _countryService.GetAllCountries(showHidden: true); + foreach (var c in countries) + model.AvailableCountries.Add(new SelectListItem { + Text = c.Name, Value = c.Id, + Selected = c.Id == selectedCountryId + }); + //states + model.AvailableStates.Add(new SelectListItem { Text = "*", Value = "" }); + var selectedCountry = countries.FirstOrDefault(x => x.Id == selectedCountryId); + if (selectedCountry == null) return; + + foreach (var s in await _countryService.GetStateProvincesByCountryId(selectedCountry.Id)) + model.AvailableStates.Add(new SelectListItem { + Text = s.Name, Value = s.Id, + Selected = s.Id == selectedStateProvinceId + }); + } +} diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/AddPopup.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/AddPopup.cshtml new file mode 100644 index 0000000000..cd5e9f4a60 --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/AddPopup.cshtml @@ -0,0 +1,41 @@ +@{ + Layout = ""; +} +@model Shipping.ByWeight.Models.ShippingByWeightModel +
+ +
+
+
+
+
+ + @Loc["Admin.Common.AddNew"] +
+
+
+ +
+
+
+
+ +
diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/Configure.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/Configure.cshtml new file mode 100644 index 0000000000..360e3fd9af --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/Configure.cshtml @@ -0,0 +1,235 @@ +@model Shipping.ByWeight.Models.ShippingByWeightListModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Shipping.Providers"]; +} + + +
+ +
+ @Loc["Plugins.Shipping.ByWeight.Formula"]: + @Loc["Plugins.Shipping.ByWeight.Formula.Value"] +
+ + @{ + var addNewUrl = Url.Action("AddPopup", "ShippingByWeight", new { area = "Store", btnId = "btnRefresh", formId = "shipping-byweight-form" }); + } + +
+
+
+
+
+ + @Loc["Shipping.ByWeight.FriendlyName"] +
+
+
+
+
+ +
+
+
+ + + + + +
+
+
+
+
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/EditPopup.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/EditPopup.cshtml new file mode 100644 index 0000000000..3d2caf8deb --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/EditPopup.cshtml @@ -0,0 +1,41 @@ +@{ + Layout = ""; +} +@model Shipping.ByWeight.Models.ShippingByWeightModel +
+ +
+
+
+
+
+ + @Loc["Admin.Common.Edit"] +
+
+
+ +
+
+
+
+ +
diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/_CreateOrUpdate.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/_CreateOrUpdate.cshtml new file mode 100644 index 0000000000..afd540a081 --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/_CreateOrUpdate.cshtml @@ -0,0 +1,148 @@ +@using Microsoft.AspNetCore.Routing +@model Shipping.ByWeight.Models.ShippingByWeightModel +
+ + +@if (ViewBag.RefreshPage == true) +{ + +} + + +@*the record always belongs to the owner's own store, so no store selector is offered*@ +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ [@Model.BaseWeightIn] + +
+
+
+
+ +
+
+ [@Model.BaseWeightIn] + +
+
+
+
+ +
+
+ [@Model.PrimaryStoreCurrencyCode] + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+ [@Model.PrimaryStoreCurrencyCode] + +
+
+
+
+ +
+
+ + +
+
+
+
+ +
+
+
+
diff --git a/src/Plugins/Shipping.ByWeight/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..254d01f31e --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Controllers/ShippingPointController.cs b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Controllers/ShippingPointController.cs new file mode 100644 index 0000000000..fe39d11154 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Controllers/ShippingPointController.cs @@ -0,0 +1,178 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Shipping.ShippingPoint.Models; +using Shipping.ShippingPoint.Services; + +namespace Shipping.ShippingPoint.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the shipping point (pickup point) provider. +/// A store owner can add, edit and delete pickup points, but only the ones that belong +/// to his own store (). Points owned by other stores or +/// global (store = *) points are never returned nor mutated. +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.ShippingSettings)] +public class ShippingPointController : BaseController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ICountryService _countryService; + private readonly IShippingPointService _shippingPointService; + private readonly ITranslationService _translationService; + + public ShippingPointController( + ITranslationService translationService, + IShippingPointService shippingPointService, + ICountryService countryService, + IContextAccessor contextAccessor + ) + { + _translationService = translationService; + _shippingPointService = shippingPointService; + _countryService = countryService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current staff/store-manager is bound to. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public IActionResult Configure() + { + return View(); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task List(DataSourceRequest command) + { + //only the current store points - filtered and paged in the service layer + var shippingPoints = await _shippingPointService.GetAllStoreShippingPoint(CurrentStoreId, + command.Page - 1, command.PageSize); + + var viewModel = shippingPoints.Select(shippingPoint => new ShippingPointModel { + ShippingPointName = shippingPoint.ShippingPointName, + Description = shippingPoint.Description, + Id = shippingPoint.Id, + OpeningHours = shippingPoint.OpeningHours, + PickupFee = shippingPoint.PickupFee + }).ToList(); + + return Json(new DataSourceResult { + Data = viewModel, + Total = shippingPoints.TotalCount + }); + } + + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create() + { + var model = new ShippingPointModel { + //the owner cannot create points for another store + StoreId = CurrentStoreId + }; + await PrepareShippingPointModel(model); + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task Create(ShippingPointModel model) + { + if (ModelState.IsValid) + { + var shippingPoint = model.ToEntity(); + //force the current store - the owner cannot create points for another store + shippingPoint.StoreId = CurrentStoreId; + await _shippingPointService.InsertStoreShippingPoint(shippingPoint); + + ViewBag.RefreshPage = true; + return Content(""); + } + + await PrepareShippingPointModel(model); + + return View(model); + } + + [PermissionAuthorizeAction(PermissionActionName.Preview)] + public async Task Edit(string id) + { + var shippingPoint = await _shippingPointService.GetStoreShippingPointById(id); + //guard: a store owner can only open his own store points + if (shippingPoint == null || shippingPoint.StoreId != CurrentStoreId) + return RedirectToAction("Configure"); + + var model = shippingPoint.ToModel(); + await PrepareShippingPointModel(model); + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task Edit(ShippingPointModel model) + { + var existing = await _shippingPointService.GetStoreShippingPointById(model.Id); + //guard: a store owner can only edit his own store points + if (existing == null || existing.StoreId != CurrentStoreId) + return RedirectToAction("Configure"); + + if (ModelState.IsValid) + { + var shippingPoint = model.ToEntity(); + //StoreId is deliberately not taken from the model - the point stays in the owner's store + shippingPoint.StoreId = existing.StoreId; + await _shippingPointService.UpdateStoreShippingPoint(shippingPoint); + + return Content(""); + } + + ViewBag.RefreshPage = true; + + await PrepareShippingPointModel(model); + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + [PermissionAuthorizeAction(PermissionActionName.Delete)] + public async Task Delete(string id) + { + var shippingPoint = await _shippingPointService.GetStoreShippingPointById(id); + //guard: a store owner can only delete his own store points + if (shippingPoint == null || shippingPoint.StoreId != CurrentStoreId) + return new JsonResult(""); + + await _shippingPointService.DeleteStoreShippingPoint(shippingPoint); + + return new JsonResult(""); + } + + /// + /// Fills the drop-downs of the add/edit form. No store selector is offered - the point + /// always belongs to the owner's own store. + /// + [NonAction] + private async Task PrepareShippingPointModel(ShippingPointModel model) + { + model.AvailableCountries.Add(new SelectListItem + { Text = _translationService.GetResource("Admin.Address.SelectCountry"), Value = string.Empty }); + foreach (var country in await _countryService.GetAllCountries(showHidden: true)) + model.AvailableCountries.Add(new SelectListItem { Text = country.Name, Value = country.Id }); + } +} diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Configure.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Configure.cshtml new file mode 100644 index 0000000000..ad6d55b091 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Configure.cshtml @@ -0,0 +1,144 @@ +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Shipping.Providers"]; +} + + +
+
+
+
+
+
+ + @Loc["Shipping.ShippingPoint.FriendlyName"] +
+
+
+
+
+ +
+
+
+ + + + +
diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Create.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Create.cshtml new file mode 100644 index 0000000000..e9a346befc --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Create.cshtml @@ -0,0 +1,64 @@ +@{ + Layout = ""; +} +@model Shipping.ShippingPoint.Models.ShippingPointModel + +
+ +
+
+
+
+
+ @Loc["Admin.Common.AddNew"] +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Edit.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Edit.cshtml new file mode 100644 index 0000000000..1f3d9e4719 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Edit.cshtml @@ -0,0 +1,64 @@ +@{ + Layout = ""; +} +@model Shipping.ShippingPoint.Models.ShippingPointModel + +
+ +
+
+
+
+
+ @Loc["Admin.Common.Edit"] +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/_CreateOrUpdate.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/_CreateOrUpdate.cshtml new file mode 100644 index 0000000000..bcbf042fea --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/_CreateOrUpdate.cshtml @@ -0,0 +1,75 @@ +@model Shipping.ShippingPoint.Models.ShippingPointModel + +
+ + +@if (ViewBag.RefreshPage == true) +{ + +} + +@*the point always belongs to the owner's own store, so no store selector is offered*@ +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..254d01f31e --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,9 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc From 2708b3de092baaf23f4d75af92dfc3ca1818efb2 Mon Sep 17 00:00:00 2001 From: KrzysztofPajak Date: Sun, 13 Sep 2026 10:53:11 +0200 Subject: [PATCH 3/3] Stop linking store owners to configuration screens that do not exist Not every shipping provider can be configured per store - the fixed rate plugin keeps its rates in system-wide setting keys, so it has no Store area controller and never will. Linking to its relative ConfigurationUrl from the store-owner panel produced the same 404 as the plugins that were just fixed. Rather than hardcoding which providers are multi-store, ask the plugin assembly whether it exposes an [Area("Store")] controller at all; a third-party provider then gets the right answer without changes here. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Bf2UUL1avH6UD5Mxxzu7UK --- .../Store/Views/Shipping/Providers.cshtml | 4 +- .../Controllers/ShippingController.cs | 5 ++ .../Extensions/StoreAreaConfiguration.cs | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs diff --git a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipping/Providers.cshtml b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipping/Providers.cshtml index bb28545a20..535b94297e 100644 --- a/src/Web/Grand.Web.Store/Areas/Store/Views/Shipping/Providers.cshtml +++ b/src/Web/Grand.Web.Store/Areas/Store/Views/Shipping/Providers.cshtml @@ -85,12 +85,12 @@ $(document).ready(function () { field: "FriendlyName", title: "@Loc["Admin.Configuration.Shipping.Providers.Fields.FriendlyName"]", width: 250, - template: '#=FriendlyName#', + template: '# if (ConfigurationUrl) { # #:FriendlyName# # } else { # #:FriendlyName# # } #', }, { field: "SystemName", title: "@Loc["Admin.Configuration.Shipping.Providers.Fields.SystemName"]", width: 250, - template: '#=SystemName#', + template: '# if (ConfigurationUrl) { # #:SystemName# # } else { # #:SystemName# # } #', }, { field: "DisplayOrder", title: "@Loc["Admin.Configuration.Shipping.Providers.Fields.DisplayOrder"]", diff --git a/src/Web/Grand.Web.Store/Controllers/ShippingController.cs b/src/Web/Grand.Web.Store/Controllers/ShippingController.cs index 017c2e5a6a..65491b5dc1 100644 --- a/src/Web/Grand.Web.Store/Controllers/ShippingController.cs +++ b/src/Web/Grand.Web.Store/Controllers/ShippingController.cs @@ -17,6 +17,7 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Models; using Grand.Web.Common.Security.Authorization; +using Grand.Web.Store.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; @@ -50,6 +51,10 @@ public async Task Providers(DataSourceRequest command) .Select(p => { var m = p.ToModel(); m.IsActive = p.IsShippingRateMethodActive(shippingProviderSettings); + //a provider whose plugin is not multi-store has no Store-area configuration screen - + //its relative url would resolve under /Store/ and 404, so offer no link at all + if (!StoreAreaConfiguration.Exists(p)) + m.ConfigurationUrl = null; return m; }) .ToList(); diff --git a/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs b/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs new file mode 100644 index 0000000000..01e564e938 --- /dev/null +++ b/src/Web/Grand.Web.Store/Extensions/StoreAreaConfiguration.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Mvc; +using System.Collections.Concurrent; +using System.Reflection; + +namespace Grand.Web.Store.Extensions; + +/// +/// Tells whether the plugin a provider comes from ships a configuration screen for the Store area. +/// +/// Provider configuration urls are relative (e.g. "../ShippingByWeight/Configure"), so the very +/// same value resolves to /Admin/... under the Admin area and to /Store/... under this one. A +/// plugin whose data is not store-scoped only has an [Area("Admin")] controller - linking a store +/// owner there produces a 404. The Store grids therefore ask this first and render plain text +/// instead of a dead link when the answer is no. +/// +/// +public static class StoreAreaConfiguration +{ + //plugin assemblies never change within a process lifetime, so the reflection cost is paid once + private static readonly ConcurrentDictionary Cache = new(); + + /// + /// Whether the provider's plugin exposes at least one controller in the Store area. + /// + public static bool Exists(object provider) + { + return provider != null && Cache.GetOrAdd(provider.GetType().Assembly, HasStoreAreaController); + } + + private static bool HasStoreAreaController(Assembly assembly) + { + return GetLoadableTypes(assembly).Any(type => + typeof(ControllerBase).IsAssignableFrom(type) && + string.Equals(type.GetCustomAttribute()?.RouteValue, Constants.AreaStore, + StringComparison.OrdinalIgnoreCase)); + } + + /// + /// A plugin assembly can reference types that fail to load; those types are simply not controllers + /// we could route to, so they are skipped rather than failing the whole provider list. + /// + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(type => type != null); + } + } +}