diff --git a/.gitattributes b/.gitattributes
index 1ff0c4230..cf9ac3f2d 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,6 +2,7 @@
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
+*.verified.txt text eol=lf working-tree-encoding=UTF-8
###############################################################################
# Set default behavior for command prompt diff.
diff --git a/.sonarcloud.properties b/.sonarcloud.properties
index 225fc6f34..65eb9825f 100644
--- a/.sonarcloud.properties
+++ b/.sonarcloud.properties
@@ -1,4 +1,4 @@
sonar.coverage.exclusions="**" `
sonar.cpd.exclusions="**"
sonar.language=cs
-sonar.exclusions=**/*.js,**/*.css,**/*.html,**/*.ts
\ No newline at end of file
+sonar.exclusions=**/*.js,**/*.css,**/*.chtml,**/*.ts
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a9f87a784..ad4c653a6 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,46 +3,47 @@
true
-
+
-
+
-
-
-
+
+
+
+
-
-
-
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
+
+
+
+
+
+
-
+
-
-
-
+
+
+
-
-
-
+
+
+
@@ -52,32 +53,32 @@
-
+
-
-
+
+
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index ffc05e555..dfdaf8e3e 100644
--- a/README.md
+++ b/README.md
@@ -34,12 +34,10 @@ Achieve superior performance, unlimited scalability, and comprehensive customiza

-[](https://dev.azure.com/grandnode/grandnode2/_build/latest?definitionId=8&branchName=main)
+[](https://dev.azure.com/grandnode/grandnode2/_build/latest?definitionId=8&branchName=develop)
[](https://github.com/grandnode/grandnode2/actions/workflows/docker-image.yml)

[](https://github.com/grandnode/grandnode2/actions/workflows/codeql.yml)
-[](https://sonarcloud.io/summary/new_code?id=grandnode_grandnode2)
-
@@ -51,6 +49,8 @@ Achieve superior performance, unlimited scalability, and comprehensive customiza
* [Getting Started](#getting-started)
* [Prerequisites](#prerequisites)
* [Installation](#installation)
+ * [Building from source](#building-from-source)
+ * [Running locally](#running-locally)
* [Online demo](#online-demo)
* [Roadmap](#roadmap)
* [Contributing](#contributing)
@@ -94,8 +94,9 @@ GrandNode was designed to solve the most important business challenges from the
GrandNode 2 leverages the latest technologies to deliver a high-performance e-commerce solution:
-- **ASP.NET Core 9.0** - Modern, cross-platform framework
+- **ASP.NET Core 10.0** - Modern, cross-platform framework
- **MongoDB 4.0+** - NoSQL database for unlimited scalability
+- **Vue 3 + Bootstrap 5** - Storefront UI, bundled with Vite
- **Docker Support** - Easy deployment and containerization
- **REST API** - Comprehensive API for integrations
- **Cloud-Ready** - Optimized for cloud hosting environments
@@ -106,9 +107,17 @@ GrandNode 2 leverages the latest technologies to deliver a high-performance e-co
To get a local copy up and running follow these simple steps.
-### Prerequisites (develop version)
+### Prerequisites
-GrandNode requires .NET Core 9.0, MongoDB 4.0+, and OS-specific dependency tools.
+| Tool | Version | Needed for |
+| --- | --- | --- |
+| [.NET SDK](https://dotnet.microsoft.com/download) | **10.0.100** or newer | building and running everything. The version is pinned in `global.json` with `rollForward: latestFeature`, so any 10.0.x SDK works |
+| [MongoDB](https://www.mongodb.com/try/download/community) | **4.0+** | the database. A local server, a Docker container or a MongoDB Atlas cluster all work |
+| [Node.js](https://nodejs.org/) + npm | **20 LTS** or newer | only when you change the storefront frontend sources. The build output is committed, so you can run the shop without Node |
+| IDE | any with .NET 10 support - Visual Studio, JetBrains Rider, VS Code | optional |
+
+Only the SDK and MongoDB are required to get the shop running - see
+[Building from source](#building-from-source) for when Node.js comes into play.
### Installation
@@ -124,13 +133,18 @@ If you want to download the latest stable version of GrandNode please use the fo
docker pull grandnode/grandnode2:x.xx
```
-* Open locally with VS2022+ (v17.12.0) or above
+* Open locally in an IDE
-Run the project in the Visual Studio 2022+, extract the source code package downloaded from Releases tab to a folder. Enter the extracted folder and double-click the GrandNode.sln solution file. Select the Plugins project, rebuild it, then select the GrandNode.Web project.
+Extract the source code package downloaded from the Releases tab to a folder (or
+clone the repository), and open `GrandNode.sln`. Build the whole solution - that
+compiles the modules and plugins into the web project's output as well - then set
+`Grand.Web` as the startup project and run it. See
+[Building from source](#building-from-source) for the command line equivalent and
+for the frontend build.
* Host on Linux server
-Before you start - please install, configure the nginx server, .NET Core 9.0+ and MongoDB 4.0+
+Before you start - please install, configure the nginx server, the .NET 10 SDK and MongoDB 4.0+
```bash
mkdir ~/source
cd ~/source
@@ -140,9 +154,13 @@ git clone - b x.xx https://github.com/grandnode/grandnode2.git
cd ~/source/grandnode
dotnet restore GrandNode.sln
```
-Now it's time to rebuild all of our plugins and publish application (command is pretty long because we've combined all commands in a single line, to ease up your work):
+Now it's time to rebuild all modules and plugins and publish the application. Each
+module and plugin copies itself into the web project's output, so they have to be
+built *before* the publish step:
```bash
-sudo dotnet build src/Plugins/Authentication.Facebook && sudo dotnet build src/Plugins/Authentication.Google && sudo dotnet build src/Plugins/DiscountRules.Standard && sudo dotnet build src/Plugins/ExchangeRate.McExchange && sudo dotnet build src/Plugins/Payments.BrainTree && sudo dotnet build src/Plugins/Payments.CashOnDelivery && sudo dotnet build src/Plugins/Payments.StripeCheckout && sudo dotnet build src/Plugins/Shipping.ByWeight && sudo dotnet build src/Plugins/Shipping.FixedRateShipping && sudo dotnet build src/Plugins/Shipping.ShippingPoint && sudo dotnet build src/Plugins/Tax.CountryStateZip && sudo dotnet build src/Plugins/Tax.FixedRate && sudo dotnet build src/Plugins/Widgets.FacebookPixel && sudo dotnet build src/Plugins/Widgets.GoogleAnalytics && sudo dotnet build src/Plugins/Widgets.Slider && sudo dotnet build src/Plugins/Theme.Modern && sudo dotnet publish src/Web/Grand.Web -c Release -o /var/webapps/grandnode
+for module in src/Modules/*; do dotnet build "$module" -c Release; done
+for plugin in src/Plugins/*; do dotnet build "$plugin" -c Release; done
+dotnet publish src/Web/Grand.Web -c Release -o /var/webapps/grandnode
```
Optional: Create the service file, to automatically restart your application.
```bash
@@ -172,6 +190,90 @@ sudo systemctl start grandnode.service
```
Feel free to visit our [detailed guide about GrandNode installation.](https://grandnode.com/how-to-install-grandnode-on-linux-ubuntu-1604/?utm_source=github&utm_medium=link&utm_campaign=readme)
+### Building from source
+
+#### Backend
+
+```bash
+dotnet restore GrandNode.sln
+dotnet build GrandNode.sln
+```
+
+`GrandNode.sln` contains the whole application: the core libraries, the web
+project, the modules under `src/Modules` (installer, migrations, REST API,
+scheduled tasks) and the plugins under `src/Plugins`. Building the solution
+builds them all and copies each module and plugin into
+`src/Web/Grand.Web/Modules` / `Plugins`, so a plain `dotnet build` is enough.
+
+Two things worth knowing:
+
+* **Plugins that ship views compile those views into the plugin DLL.** After
+ editing a `.cshtml` file in, for example, `src/Plugins/Theme.Modern`, rebuild
+ that plugin (`dotnet build src/Plugins/Theme.Modern`) - the running site will
+ not pick the change up otherwise. Razor runtime compilation covers only
+ `Grand.Web`'s own views. Stop the site before rebuilding a plugin, or the
+ build fails on a locked DLL.
+* Building a plugin on its own is fine and is what the Docker image does; you
+ only need the full solution build after changing shared code.
+
+#### Frontend
+
+The storefront UI (Vue 3, Bootstrap 5) lives in `src/Web/Grand.Web/vueapp` and is
+the only npm project in the repository:
+
+```bash
+cd src/Web/Grand.Web/vueapp
+npm install
+npm run build
+```
+
+That writes into `src/Web/Grand.Web/wwwroot/bundles`:
+
+| output | contents |
+| --- | --- |
+| `app.runtime.bundle.js` | Vue 3, the compatibility layer, the per-page view-models and the shared DOM behaviours |
+| `libs.css` | Bootstrap, Bootstrap Icons, animate.css, Pikaday |
+| `style.min.css`, `style.rtl.min.css` | the theme stylesheets from `wwwroot/theme/css`, concatenated in cascade order and minified |
+
+**This output is committed to the repository**, which is why neither the CI
+workflows nor the Dockerfile install Node - they build the .NET solution against
+the bundles already in the tree. The flip side is that when you change anything
+under `vueapp/src` or `wwwroot/theme/css` you have to run `npm run build` and
+commit the regenerated bundles together with the source change, otherwise your
+change simply will not be on the page.
+
+Other scripts: `npm run dev` (watch build), `npm run lint` (ESLint over
+`vueapp/src`), `npm run audit:prod`. More detail in
+[`vueapp/README.md`](src/Web/Grand.Web/vueapp/README.md).
+
+### Running locally
+
+```bash
+dotnet run --project src/Web/Grand.Web
+```
+
+`Grand.Web` does not reference the plugins - they install themselves into its
+output directory when *they* are built. So build the solution once
+(`dotnet build GrandNode.sln`) before the first run; after that you can start the
+web project alone.
+
+The Kestrel profile listens on and
+; the Visual Studio IIS Express profile uses
+.
+
+> **Set `ASPNETCORE_ENVIRONMENT=Development`.** Both launch profiles already do.
+> If you start the application without it, the static web assets manifest is not
+> consulted, every file served from `_content/...` returns 404 and the admin
+> panel loads with no CSS and no JavaScript at all. It looks like a broken
+> install; it is only the missing environment variable.
+
+On the first run the application redirects to `/install`, where you enter the
+MongoDB connection string (for example `mongodb://localhost/grandnode`) and the
+administrator account, and choose whether to load the sample data. The installer
+writes the connection string to `src/Web/Grand.Web/App_Data/Settings.cfg`, which
+is not tracked by git - delete that file to run the installer again against a
+fresh database.
+
### Online demo
#### Frontend ####
[https://demo.grandnode.com/](https://demo.grandnode.com/?utm_source=github&utm_medium=link&utm_campaign=readme)
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 09d6c0757..3f961fa9c 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -1,3 +1,13 @@
+trigger:
+ branches:
+ include:
+ - develop
+ - feature/*
+pr:
+ branches:
+ include:
+ - develop
+
resources:
containers:
- container: mongo
@@ -13,8 +23,31 @@ pool:
variables:
buildConfiguration: 'Release'
-
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: '1'
+ NUGET_PACKAGES: '$(Agent.TempDirectory)/.nuget/packages'
+ NUGET_HTTP_CACHE_PATH: '$(Agent.TempDirectory)/.nuget/http-cache'
+ NUGET_PLUGINS_CACHE_PATH: '$(Agent.TempDirectory)/.nuget/plugins-cache'
+ DOTNET_CLI_HOME: '$(Agent.TempDirectory)'
+ DOTNET_NOLOGO: '1'
+ DOTNET_CLI_TELEMETRY_OPTOUT: '1'
+ VSTEST_CONNECTION_TIMEOUT: '120'
+ VSTEST_RESULTS_DIRECTORY: '$(Agent.TempDirectory)/TestResults'
+
steps:
+- checkout: self
+ clean: true
+ fetchDepth: 1
+
+- bash: |
+ sudo rm -rf /usr/local/lib/android
+ sudo rm -rf /usr/share/swift
+ sudo rm -rf /opt/hostedtoolcache/CodeQL
+ sudo rm -rf /usr/local/share/powershell
+ sudo apt-get clean
+
+ df -h
+ displayName: Free disk space
+
- task: UseDotNet@2
displayName: 'Install .NET Core SDK 10'
inputs:
@@ -39,14 +72,14 @@ steps:
inputs:
command: 'build'
projects: '**/Grand.Web.csproj'
- arguments: '--configuration $(buildConfiguration)'
+ arguments: '--configuration $(buildConfiguration) --no-restore'
displayName: 'dotnet build $(buildConfiguration)'
- task: DotNetCoreCLI@2
displayName: 'dotnet test'
inputs:
command: 'test'
- arguments: '--configuration $(buildConfiguration) --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura'
+ arguments: '--configuration $(buildConfiguration) --no-restore --collect:"XPlat Code Coverage" -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura --results-directory "$(VSTEST_RESULTS_DIRECTORY)"'
publishTestResults: true
projects: '**/Tests/**/*.csproj'
@@ -54,13 +87,13 @@ steps:
displayName: 'Publish code coverage report'
inputs:
codeCoverageTool: 'Cobertura'
- summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml'
+ summaryFileLocation: '$(VSTEST_RESULTS_DIRECTORY)/**/coverage.cobertura.xml'
- task: DotNetCoreCLI@2
inputs:
command: publish
publishWebProjects: True
- arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'
+ arguments: '--configuration $(buildConfiguration) --no-restore --output $(Build.ArtifactStagingDirectory)'
zipAfterPublish: True
- task: PublishPipelineArtifact@1
diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/AddCustomerReservationCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/AddCustomerReservationCommandHandler.cs
index 6d0eb9e4b..512be8213 100644
--- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/AddCustomerReservationCommandHandler.cs
+++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/AddCustomerReservationCommandHandler.cs
@@ -1,4 +1,5 @@
using Grand.Business.Core.Commands.Checkout.Orders;
+using Grand.Business.Core.Extensions;
using Grand.Business.Core.Interfaces.Catalog.Products;
using Grand.Domain.Catalog;
using MediatR;
@@ -27,55 +28,17 @@ public async Task Handle(AddCustomerReservationCommand request, Cancellati
if (match != null) reservations.Remove(match);
}
- IGrouping groupToBook = null;
-
- var grouped = reservations.GroupBy(x => x.Resource);
- foreach (var group in grouped)
- {
- var groupCanBeBooked = true;
- if (request.Product.IncBothDate && request.Product.IntervalUnitId == IntervalUnit.Day)
- for (var iterator = request.RentalStartDate.Value;
- iterator <= request.RentalEndDate.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
- else
- for (var iterator = request.RentalStartDate.Value;
- iterator < request.RentalEndDate.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
-
- if (!groupCanBeBooked) continue;
- groupToBook = group;
- break;
- }
+ var groupToBook = reservations.FindGroupToBook(request.Product, request.RentalStartDate.Value,
+ request.RentalEndDate.Value);
if (groupToBook != null)
- {
- if (request.Product.IncBothDate && request.Product.IntervalUnitId == IntervalUnit.Day)
- foreach (var item in groupToBook.Where(x =>
- x.Date >= request.RentalStartDate && x.Date <= request.RentalEndDate))
- await _productReservationService.InsertCustomerReservationsHelper(new CustomerReservationsHelper {
- CustomerId = request.Customer.Id,
- ReservationId = item.Id,
- ShoppingCartItemId = request.ShoppingCartItem.Id
- });
- else
- foreach (var item in groupToBook.Where(x =>
- x.Date >= request.RentalStartDate && x.Date < request.RentalEndDate))
- await _productReservationService.InsertCustomerReservationsHelper(new CustomerReservationsHelper {
- CustomerId = request.Customer.Id,
- ReservationId = item.Id,
- ShoppingCartItemId = request.ShoppingCartItem.Id
- });
- }
+ foreach (var item in groupToBook.InRentalPeriod(request.Product, request.RentalStartDate.Value,
+ request.RentalEndDate.Value))
+ await _productReservationService.InsertCustomerReservationsHelper(new CustomerReservationsHelper {
+ CustomerId = request.Customer.Id,
+ ReservationId = item.Id,
+ ShoppingCartItemId = request.ShoppingCartItem.Id
+ });
}
if (!string.IsNullOrEmpty(request.ReservationId))
diff --git a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs
index dad11a8ab..55c8236a6 100644
--- a/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs
+++ b/src/Business/Grand.Business.Checkout/Commands/Handlers/Orders/PlaceOrderCommandHandler.cs
@@ -758,43 +758,13 @@ protected virtual async Task UpdateProductReservation(Order order, PlaceOrderCon
if (!sc.RentalStartDateUtc.HasValue || !sc.RentalEndDateUtc.HasValue) continue;
var reservations =
await _productReservationService.GetProductReservationsByProductId(product.Id, true, null);
- var grouped = reservations.GroupBy(x => x.Resource);
-
- IGrouping groupToBook = null;
- foreach (var group in grouped)
- {
- var groupCanBeBooked = true;
- if (product.IncBothDate && product.IntervalUnitId == IntervalUnit.Day)
- for (var iterator = sc.RentalStartDateUtc.Value;
- iterator <= sc.RentalEndDateUtc.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
- else
- for (var iterator = sc.RentalStartDateUtc.Value;
- iterator < sc.RentalEndDateUtc.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
-
- if (!groupCanBeBooked) continue;
- groupToBook = group;
- break;
- }
+ var groupToBook = reservations.FindGroupToBook(product, sc.RentalStartDateUtc.Value,
+ sc.RentalEndDateUtc.Value);
if (groupToBook == null) throw new Exception("ShoppingCart.Reservation.NoFreeReservationsInThisPeriod");
- var temp = groupToBook.AsQueryable();
- if (product.IncBothDate && product.IntervalUnitId == IntervalUnit.Day)
- temp = temp.Where(x => x.Date >= sc.RentalStartDateUtc && x.Date <= sc.RentalEndDateUtc);
- else
- temp = temp.Where(x => x.Date >= sc.RentalStartDateUtc && x.Date < sc.RentalEndDateUtc);
+ var temp = groupToBook.InRentalPeriod(product, sc.RentalStartDateUtc.Value, sc.RentalEndDateUtc.Value)
+ .ToList();
foreach (var item in temp)
{
diff --git a/src/Business/Grand.Business.Checkout/Validators/ShoppingCartReservationProductValidator.cs b/src/Business/Grand.Business.Checkout/Validators/ShoppingCartReservationProductValidator.cs
index 9a2e92d27..4169b68dd 100644
--- a/src/Business/Grand.Business.Checkout/Validators/ShoppingCartReservationProductValidator.cs
+++ b/src/Business/Grand.Business.Checkout/Validators/ShoppingCartReservationProductValidator.cs
@@ -1,4 +1,5 @@
using FluentValidation;
+using Grand.Business.Core.Extensions;
using Grand.Business.Core.Interfaces.Catalog.Products;
using Grand.Business.Core.Interfaces.Common.Localization;
using Grand.Domain.Catalog;
@@ -29,7 +30,6 @@ public ShoppingCartReservationProductValidator(ITranslationService translationSe
if (value.ShoppingCartItem.RentalStartDateUtc.HasValue && value.ShoppingCartItem.RentalEndDateUtc.HasValue)
{
- var canBeBook = false;
var reservations =
await productReservationService.GetProductReservationsByProductId(value.Product.Id, true, null);
var reserved = await productReservationService.GetCustomerReservationsHelpers(value.Customer.Id);
@@ -39,33 +39,9 @@ public ShoppingCartReservationProductValidator(ITranslationService translationSe
if (match != null) reservations.Remove(match);
}
- var grouped = reservations.GroupBy(x => x.Resource);
- foreach (var group in grouped)
- {
- var groupCanBeBooked = true;
- if (value.Product.IncBothDate && value.Product.IntervalUnitId == IntervalUnit.Day)
- for (var iterator = value.ShoppingCartItem.RentalStartDateUtc.Value;
- iterator <= value.ShoppingCartItem.RentalEndDateUtc.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
- else
- for (var iterator = value.ShoppingCartItem.RentalStartDateUtc.Value;
- iterator < value.ShoppingCartItem.RentalEndDateUtc.Value;
- iterator += new TimeSpan(24, 0, 0))
- {
- if (group.Select(x => x.Date).Contains(iterator)) continue;
- groupCanBeBooked = false;
- break;
- }
-
- if (!groupCanBeBooked) continue;
- canBeBook = true;
- break;
- }
+ var canBeBook = reservations.FindGroupToBook(value.Product,
+ value.ShoppingCartItem.RentalStartDateUtc.Value,
+ value.ShoppingCartItem.RentalEndDateUtc.Value) != null;
if (!canBeBook)
{
diff --git a/src/Business/Grand.Business.Core/Extensions/ProductReservationExtensions.cs b/src/Business/Grand.Business.Core/Extensions/ProductReservationExtensions.cs
new file mode 100644
index 000000000..cfe1f13c5
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Extensions/ProductReservationExtensions.cs
@@ -0,0 +1,47 @@
+using Grand.Domain.Catalog;
+
+namespace Grand.Business.Core.Extensions;
+
+public static class ProductReservationExtensions
+{
+ ///
+ /// Finds the first resource whose reservations cover the whole rental period, or null when none is free
+ ///
+ public static IGrouping FindGroupToBook(
+ this IEnumerable reservations, Product product, DateTime rentalStartDate,
+ DateTime rentalEndDate)
+ {
+ foreach (var group in reservations.GroupBy(x => x.Resource))
+ {
+ var freeDays = group.Select(x => x.Date.Date).ToHashSet();
+ if (BookedDays(product, rentalStartDate, rentalEndDate).All(day => freeDays.Contains(day)))
+ return group;
+ }
+
+ return null;
+ }
+
+ ///
+ /// Gets the reservations of a resource that fall within the rental period
+ ///
+ public static IEnumerable InRentalPeriod(this IEnumerable reservations,
+ Product product, DateTime rentalStartDate, DateTime rentalEndDate)
+ {
+ var bookedDays = BookedDays(product, rentalStartDate, rentalEndDate).ToHashSet();
+ return reservations.Where(x => bookedDays.Contains(x.Date.Date));
+ }
+
+ ///
+ /// Gets the days that a rental period covers. Reservations of day products are generated at the store's local
+ /// midnight and read back as UTC, so the time of day of a stored reservation is an arbitrary offset, while the
+ /// dates posted by the datepicker are always midnight - only the date part may be compared.
+ ///
+ private static IEnumerable BookedDays(Product product, DateTime rentalStartDate, DateTime rentalEndDate)
+ {
+ var includeEndDate = product.IncBothDate && product.IntervalUnitId == IntervalUnit.Day;
+ for (var iterator = rentalStartDate.Date;
+ includeEndDate ? iterator <= rentalEndDate.Date : iterator < rentalEndDate.Date;
+ iterator = iterator.AddDays(1))
+ yield return iterator;
+ }
+}
diff --git a/src/Modules/Grand.Module.Api/Grand.Module.Api.csproj b/src/Modules/Grand.Module.Api/Grand.Module.Api.csproj
index 765ced4aa..5bc94f45a 100644
--- a/src/Modules/Grand.Module.Api/Grand.Module.Api.csproj
+++ b/src/Modules/Grand.Module.Api/Grand.Module.Api.csproj
@@ -43,6 +43,7 @@
+
diff --git a/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpdateResourceString.cs b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpdateResourceString.cs
new file mode 100644
index 000000000..2813d0371
--- /dev/null
+++ b/src/Modules/Grand.Module.Migration/Migrations/2.4/MigrationUpdateResourceString.cs
@@ -0,0 +1,21 @@
+using Grand.Infrastructure.Migrations;
+
+namespace Grand.Module.Migration.Migrations._2._4;
+
+public class MigrationUpdateResourceString : IMigration
+{
+ public int Priority => 0;
+ public DbVersion Version => new(2, 4);
+ public Guid Identity => new("6C0F2E4B-9A17-4D65-B3C8-51E0A9D77B42");
+ public string Name => "Update resource string for english language 2.4";
+
+ ///
+ /// Upgrade process
+ ///
+ ///
+ ///
+ public bool UpgradeProcess(IServiceProvider serviceProvider)
+ {
+ return serviceProvider.ImportLanguageResourcesFromXml("App_Data/Resources/Upgrade/en_240.xml");
+ }
+}
diff --git a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj
index 3089521e3..d58e2f3ec 100644
--- a/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj
+++ b/src/Plugins/Authentication.Facebook/Authentication.Facebook.csproj
@@ -21,7 +21,7 @@
-
+
diff --git a/src/Plugins/Authentication.Google/Authentication.Google.csproj b/src/Plugins/Authentication.Google/Authentication.Google.csproj
index 07847958b..cf6d6ec07 100644
--- a/src/Plugins/Authentication.Google/Authentication.Google.csproj
+++ b/src/Plugins/Authentication.Google/Authentication.Google.csproj
@@ -20,7 +20,7 @@
-
+
diff --git a/src/Plugins/Theme.Modern/Content/css/animate-subset.css b/src/Plugins/Theme.Modern/Content/css/animate-subset.css
new file mode 100644
index 000000000..51214add7
--- /dev/null
+++ b/src/Plugins/Theme.Modern/Content/css/animate-subset.css
@@ -0,0 +1,58 @@
+/*
+ The three animate.css classes this theme actually uses.
+
+ animate.css itself is 95 kB of keyframes for ~90 animations and used to be
+ imported into the shared storefront bundle, so every page of both themes paid
+ for it while only Theme.Modern used it - and only here:
+
+ - Partials/ProductBoxTemplate marks the spec table animate__animated and names
+ the animation in data-animation / data-delay;
+ - Content/script/npslider-home.js reads those attributes and adds the classes
+ as a slide becomes active.
+
+ Because the class names come from data attributes, a theme that wants a
+ different animation has to add its keyframes here as well.
+*/
+
+:root {
+ --animate-duration: 1s;
+ --animate-delay: 1s;
+}
+
+.animate__animated {
+ -webkit-animation-duration: var(--animate-duration);
+ animation-duration: var(--animate-duration);
+ -webkit-animation-fill-mode: both;
+ animation-fill-mode: both;
+}
+
+.animate__delay-1s {
+ -webkit-animation-delay: var(--animate-delay);
+ animation-delay: var(--animate-delay);
+}
+
+@-webkit-keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.animate__fadeIn {
+ -webkit-animation-name: fadeIn;
+ animation-name: fadeIn;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .animate__animated {
+ -webkit-animation-duration: 1ms !important;
+ animation-duration: 1ms !important;
+ -webkit-animation-delay: 1ms !important;
+ animation-delay: 1ms !important;
+ -webkit-animation-iteration-count: 1 !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/src/Plugins/Theme.Modern/Content/css/blog-news/blog-news.css b/src/Plugins/Theme.Modern/Content/css/blog-news/blog-news.css
index 0d5a58075..5ab8144db 100644
--- a/src/Plugins/Theme.Modern/Content/css/blog-news/blog-news.css
+++ b/src/Plugins/Theme.Modern/Content/css/blog-news/blog-news.css
@@ -76,11 +76,11 @@
padding: 10px;
border-left: 1px solid #d6d6d6;
border-bottom: 1px solid #d6d6d6;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
line-height: 1;
}
.card.post-news .post-date .day {
- color: #000;
+ color: var(--bs-body-color);
font-size: 24px;
font-weight: 700;
}
diff --git a/src/Plugins/Theme.Modern/Content/css/cart/cart.css b/src/Plugins/Theme.Modern/Content/css/cart/cart.css
index 24747946d..91e5132af 100644
--- a/src/Plugins/Theme.Modern/Content/css/cart/cart.css
+++ b/src/Plugins/Theme.Modern/Content/css/cart/cart.css
@@ -8,10 +8,10 @@
.shopping-cart {
margin: 0 0 15px 0;
- color: #1c1b1b;
+ color: var(--bs-body-color);
}
.shopping-cart.table thead th {
- color: #1c1b1b;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
text-transform: uppercase;
@@ -23,7 +23,7 @@
border: 1px solid rgba(0,0,0,.1);
}
.shopping-cart.table td img {
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.shopping-cart.table .product-name {
font-size: 14px;
@@ -36,7 +36,7 @@
.shopping-cart .prod-total {
font-size: 18px;
font-weight: 700;
- color: #1c1b1b;
+ color: var(--bs-body-color);
}
.shopping-cart .prod-total {
color: #25b232;
@@ -61,7 +61,7 @@
border-radius: 0;
}
.totals-inner table, .totals-inner .table-hover tbody tr:hover {
- color: #1c1b1b;
+ color: var(--bs-body-color);
}
.checkout-buttons {
display: inline-flex;
@@ -88,7 +88,7 @@
-webkit-transition: all .2s ease-in-out;
transition: all .2s ease-in-out;
}
-.coupon-button.not-collapsed::after {
+.coupon-button:not(.collapsed)::after {
-webkit-transform: rotate(180deg);
transform: rotate(180deg);
}
@@ -120,7 +120,7 @@
[dark-theme="true"] .opc-header .card-header .btn-info {
color: #fff;
}
-.opc-header .card-header .btn-info.not-collapsed {
+.opc-header .card-header .btn-info:not(.collapsed) {
color:#25b232;
font-size: 16px;
font-weight: 700;
@@ -144,10 +144,10 @@
[dark-theme="true"] .opc-header .card-header .btn-info::after {
background: #fff;
}
-[dark-theme="true"] .opc-header .card-header .btn-info.not-collapsed::before,
-[dark-theme="true"] .opc-header .card-header .btn-info.not-collapsed::after,
-.opc-header .card-header .btn-info.not-collapsed::before,
-.opc-header .card-header .btn-info.not-collapsed::after {
+[dark-theme="true"] .opc-header .card-header .btn-info:not(.collapsed)::before,
+[dark-theme="true"] .opc-header .card-header .btn-info:not(.collapsed)::after,
+.opc-header .card-header .btn-info:not(.collapsed)::before,
+.opc-header .card-header .btn-info:not(.collapsed)::after {
background:#25b232;
}
.opc-header .card-header:last-child .btn-info::before,
diff --git a/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css b/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css
index e91306c6a..baff5a6ec 100644
--- a/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css
+++ b/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css
@@ -3,6 +3,7 @@
.breadcrumb {
margin: 0;
padding: 15px 0 0 0;
+ align-items: center;
}
/* category list */
@@ -28,7 +29,7 @@
.category-item .title {
font-size: 22px;
font-weight: 400;
- color: #000;
+ color: var(--bs-body-color);
font-family: 'Poppins', sans-serif;
margin: 5px 0 0 0;
white-space: pre-wrap;
@@ -89,7 +90,7 @@
overflow-x: hidden;
overflow-y: auto;
max-height: calc(100% - 54px);
- background-color: #fff;
+ background-color: var(--bs-body-bg);
margin: 15px 0 0 0;
border: 1px solid rgba(0,0,0,.1);
padding: 0 15px 15px 15px;
@@ -148,7 +149,7 @@
margin: 0 0 15px 0;
padding: 10px;
border: 0;
- background: #eaf0fa;
+ background: var(--bs-secondary-bg);
border-radius: .25rem;
}
.generalLeftSide .available-items ul {
@@ -160,10 +161,10 @@
text-transform: uppercase;
}
.generalLeftSide .available-items ul .btn {
- color: #1c1b1b;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 700;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.generalLeftSide .available-items ul .btn.color-container {
background-color: transparent;
@@ -191,7 +192,7 @@
}
.generalLeftSide li a {
position: relative;
- color: #1c1b1b;
+ color: var(--bs-body-color);
text-decoration: none;
font-size: 14px;
font-weight: 400;
@@ -320,7 +321,7 @@ a.left-side-toggler:hover {
align-items: center;
justify-content: space-between;
padding: 5%;
- background: #eaf0fa;
+ background: var(--bs-secondary-bg);
overflow: hidden;
}
.parallax-banner .content {
@@ -470,7 +471,7 @@ a.left-side-toggler:hover {
text-transform: uppercase;
font-weight: 700;
font-family: Poppins, sans-serif;
- color: #000;
+ color: var(--bs-body-color);
background-color: transparent;
border: 1px solid rgba(0,0,0,.1);
border-bottom: 0;
@@ -529,11 +530,12 @@ a.left-side-toggler:hover {
}
.sort-container .col-form-label {
position: absolute;
- top: -10px;
+ top: -20px;
left: 4px;
padding: 0 4px;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
font-size: 12px;
+ line-height: 18px;
font-weight: 400;
white-space: nowrap;
}
@@ -585,7 +587,7 @@ a.left-side-toggler:hover {
/* selectors */
.catalog-selectors {
- background: #eaf0fa;
+ background: var(--bs-secondary-bg);
padding: 10px;
}
.grid-icon {
@@ -687,7 +689,7 @@ a.left-side-toggler:hover {
position: relative;
height: 100%;
overflow: hidden;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.block-recently-viewed-products .card a.picture::before {
content: "";
@@ -702,7 +704,7 @@ a.left-side-toggler:hover {
left: 0;
max-width: 100%;
margin: auto;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
/* new cols */
diff --git a/src/Plugins/Theme.Modern/Content/css/common/common.css b/src/Plugins/Theme.Modern/Content/css/common/common.css
index fac160317..36530014b 100644
--- a/src/Plugins/Theme.Modern/Content/css/common/common.css
+++ b/src/Plugins/Theme.Modern/Content/css/common/common.css
@@ -1,3 +1,17 @@
+/* Bootstrap 5 renamed .sr-only to .visually-hidden; this theme's views
+ * (carried over from Bootstrap 4) still use the old class name. */
+.sr-only {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ padding: 0 !important;
+ margin: -1px !important;
+ overflow: hidden !important;
+ clip: rect(0, 0, 0, 0) !important;
+ white-space: nowrap !important;
+ border: 0 !important;
+}
+
.store-selector-container {
position: sticky;
position: -webkit-sticky;
@@ -28,11 +42,11 @@
.store-selector-container .inside-container .btn-secondary.dropdown-toggle,
.store-selector-container .inside-container .btn-secondary.dropdown-toggle:hover,
.store-selector-container .inside-container .btn-secondary.dropdown-toggle:focus {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
padding: 1rem 2rem;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border-color: #fff;
border-radius: .25rem;
}
@@ -62,7 +76,7 @@
left: 0;
width: 28px;
height: 2px;
- background: #fff;
+ background: var(--bs-body-bg);
}
.store-selector-container .inside-container .close-add .icon::before {
transform: rotate(45deg);
@@ -289,12 +303,12 @@
flex-direction: column;
margin: 15px 0 0 0;
padding: 15px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border-radius: 4px;
box-shadow: 0 14px 28px rgba(0, 0, 0, .25), 0 10px 10px rgba(0, 0, 0, .22);
}
.c-picker .content input {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 500;
margin: 0 0 15px 0;
@@ -431,7 +445,7 @@
/* commons */
a {
- color: #000;
+ color: var(--bs-body-color);
}
a:hover {
color: #25b232;
@@ -454,7 +468,7 @@ a:hover {
padding: 0 4px;
font-size: 12px;
font-weight: 400;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
z-index: 1;
}
.field-validation-error {
@@ -480,9 +494,7 @@ a:hover {
}
.navbar-brand img {
width: auto;
- height: auto;
- max-width: 150px;
- max-height: 50px;
+ height: auto;
backface-visibility: hidden;
}
.cookie-bar-notification {
@@ -516,7 +528,13 @@ body:not(.modal-open) {
.modal.fade + .modal-backdrop {
opacity: .5;
}
-[v-cloak] .page-loader-container::before {
+/*
+ Keyed off .axios-request, which app.js toggles around XHR, and no longer off
+ [v-cloak] on #app: that hid every server-rendered page behind an opaque sheet
+ until the deferred bundle had run. Markup only Vue can fill carries v-cloak
+ itself and is hidden by the scoped rule below.
+*/
+.page-loader-container.axios-request::before {
content: '';
position: fixed;
top: 0;
@@ -524,15 +542,16 @@ body:not(.modal-open) {
width: 100%;
height: 100%;
background: #fff;
+ opacity: 0.5;
overflow: hidden;
z-index: 1041;
}
-[v-cloak] .page-loader,
-[v-cloak] .page-loader:before,
-[v-cloak] .page-loader:after {
+.axios-request .page-loader,
+.axios-request .page-loader:before,
+.axios-request .page-loader:after {
border-radius: 50%;
}
-[v-cloak] .page-loader {
+.axios-request .page-loader {
color: #25b232;
margin: 22.5px auto;
position: fixed;
@@ -546,12 +565,12 @@ body:not(.modal-open) {
transform: translateZ(0);
z-index: 1041;
}
-[v-cloak] .page-loader:before,
-[v-cloak] .page-loader:after {
+.axios-request .page-loader:before,
+.axios-request .page-loader:after {
position: absolute;
content: '';
}
-[v-cloak] .page-loader:before {
+.axios-request .page-loader:before {
width: 27.5px;
height: 55px;
background: #25b232;
@@ -564,7 +583,7 @@ body:not(.modal-open) {
-webkit-animation: load2 2s infinite ease 1.5s;
animation: load2 2s infinite ease 1.5s;
}
-[v-cloak] .page-loader:after {
+.axios-request .page-loader:after {
width: 27.5px;
height: 55px;
background: #25b232;
@@ -577,6 +596,14 @@ body:not(.modal-open) {
-webkit-animation: load2 2s infinite ease;
animation: load2 2s infinite ease;
}
+
+[v-cloak] {
+ visibility: hidden;
+}
+
+validation-provider .field-validation-error {
+ visibility: hidden;
+}
@-webkit-keyframes load2 {
0% {
-webkit-transform: rotate(0deg);
@@ -659,6 +686,18 @@ body:not(.modal-open) {
padding-right: 0;
padding-left: 0;
}
+.form-row {
+ display: -ms-flexbox;
+ display: flex;
+ -ms-flex-wrap: wrap;
+ flex-wrap: wrap;
+ margin-right: -7.5px;
+ margin-left: -7.5px;
+}
+.form-row>.col, .form-row>[class*=col-] {
+ padding-right: 7.5px;
+ padding-left: 7.5px;
+}
/* checkbox & radio */
@@ -705,13 +744,13 @@ button.close:hover {
margin: 0 0 15px 0;
}
.quick-view-overview .actual-price {
- color: #000;
+ color: var(--bs-body-color);
font-size: 28px;
font-weight: 900;
line-height: 1;
}
.quick-view-overview .bid {
- color: #000;
+ color: var(--bs-body-color);
font-size: 22px;
}
.quick-view-overview .old-price {
@@ -751,7 +790,7 @@ button.close:hover {
.quick-view .spec-container tr td {
/*display: inline-flex;*/
/*border: 0;
- color: #000;*/
+ color: var(--bs-body-color);*/
}
.quick-view .spec-container .spec-name {
font-size: 16px;
@@ -800,15 +839,15 @@ button.close:hover {
font-size: 18px;
font-weight: 700;
border: 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 2px solid #25b232;
- color: #000;
+ color: var(--bs-body-color);
}
.quick-view-overview .accordion .btn.collapse-button .b-icon {
-webkit-transition: all .2s ease-in-out;
transition: all .2s ease-in-out;
}
-.quick-view-overview .accordion .btn.collapse-button.not-collapsed .b-icon {
+.quick-view-overview .accordion .btn.collapse-button:not(.collapsed) .b-icon {
-webkit-transform: rotate( 180deg );
transform: rotate( 180deg );
}
@@ -817,7 +856,7 @@ button.close:hover {
}
.quick-view .product-gallery img {
max-width: 100%;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
border: 1px solid #ddd;
}
.quick-view .product-gallery {
@@ -854,7 +893,7 @@ button.close:hover {
align-items: center;
justify-content: center;
height: 60px;
- background: #fff;
+ background: var(--bs-body-bg);
}
.quick-view .qty-controls div {
display: flex;
@@ -1074,21 +1113,21 @@ button.close:hover {
}
/* card */
.card {
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 1px solid rgba(0,0,0,.1);
}
.card-header {
font-size: 18px;
font-weight: 700;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
border-bottom: 1px solid rgba(0,0,0,.1);
}
/* forms */
.form-control {
- color: #1c1b1b;
- background-color: #fff;
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
border: 1px solid rgba(0,0,0,.2);
border-radius: 0;
}
@@ -1099,7 +1138,7 @@ button.close:hover {
}
.form-control:disabled, .form-control[readonly], .form-control:hover:disabled, .form-control[readonly]:hover {
color: #aaa;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border-color: #333;
}
.input-group-append .btn, .input-group-prepend .btn {
@@ -1109,11 +1148,22 @@ button.close:hover {
.custom-select {
font-size: 14px;
font-weight: 500;
- color: #1c1b1b;
+ color: var(--bs-body-color);
min-width: 75px;
min-height: 40px;
- background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23000' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;
- border: 2px solid #1c1b1b;
+ /* the background shorthand resets background-color, which left the control and its
+ option list transparent - the browser then painted the popup white and the
+ themed text became unreadable in dark mode */
+ background: var(--bs-body-bg) url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23000' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;
+ border: 2px solid var(--bs-border-color);
+}
+/* the arrow is baked into a data URI, so it cannot inherit currentColor */
+[data-bs-theme="dark"] .custom-select {
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23dee2e6' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e");
+}
+.custom-select option {
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
}
/* page */
@@ -1407,7 +1457,7 @@ button.close:hover {
height: 195px;
margin: 0 0 15px 0;
padding: 15px 0 0 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border-bottom: 1px solid rgba(0,0,0,.1);
direction: rtl;
z-index: 1034;
@@ -1476,7 +1526,7 @@ ul.Menu.-vertical {
padding: 0 15px 0 15px;
}
.Menu > li.category > a {
- color: #000;
+ color: var(--bs-body-color);
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
font-size: 14px;
font-weight: 700;
@@ -1573,7 +1623,7 @@ ul.Menu.-vertical {
visibility: visible;
}
.Menu li.-hasSubmenu > ul.gallery > li.category > a {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 900;
text-transform: uppercase;
@@ -1722,7 +1772,7 @@ ul.Menu.-vertical {
}
/* Background */
.Menu ul, .Menu.-floating {
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.Menu ul.opacity {
background-color: rgba(28, 27, 27, .95);
@@ -1763,7 +1813,7 @@ ul.Menu.-vertical {
display: none;
}
.Menu li.-hasSubmenu ul li a {
- color: #000;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 400;
display: flex;
@@ -1816,7 +1866,7 @@ ul.Menu.-vertical {
box-shadow: none;
}
.btn-outline-secondary {
- color: #1c1b1b;
+ color: var(--bs-body-color);
border-color: rgba(0,0,0,.2);
}
.btn-outline-secondary:hover {
@@ -1827,8 +1877,8 @@ ul.Menu.-vertical {
opacity: 0.85;
}
.btn-outline-info {
- color: #000;
- background-color: #fff;
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
border-color: #25b232;
}
.btn-outline-info:hover, .btn-outline-info .focus, .btn-outline-info :focus, .btn-outline-info :not(:disabled):not(.disabled).active, .btn-outline-info :not(:disabled):not(.disabled):active, .show > .btn-outline-info .dropdown-toggle {
@@ -1852,7 +1902,7 @@ ul.Menu.-vertical {
.generalTitle, .page-page .page-title > h1 {
position: relative;
font-family: 'Poppins', sans-serif;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
display: table;
font-size: 38px;
font-weight: 700;
@@ -1865,7 +1915,7 @@ ul.Menu.-vertical {
h5.generalTitle {
position: relative;
font-family: 'Poppins', sans-serif;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
display: table;
font-size: 22px;
font-weight: 700;
@@ -1876,7 +1926,7 @@ h5.generalTitle {
}
.generalTitle > span {
- background-color: #fff;
+ background-color: var(--bs-body-bg);
display: table;
font-size: 27px;
font-weight: 700;
@@ -1896,7 +1946,7 @@ h5.generalTitle {
}
.generalTitle div {
padding: 0 10px 0 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
display: table;
font-size: 38px;
font-weight: 700;
@@ -1972,7 +2022,7 @@ dl, ol, ul {
z-index: 1050;
}
#ModalAddToCart img {
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
#ModalAddToCart .card {
border: 1px solid #25b232;
@@ -2018,7 +2068,7 @@ input:checked ~ .color-container .color::before {
width: 45px;
height: 45px;
font-size: 22px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 1px solid rgba(0,0,0,.2);
border-radius: 100%;
box-shadow: 0px 12px 28px rgba(0,123,255,.25), 0 -4px 14px rgba(0,123,255,.25);
@@ -2095,7 +2145,7 @@ input:checked ~ .color-container .color::before {
#voiceSearchButton {
position: relative;
- color: #1c1b1b;
+ color: var(--bs-body-color);
margin: 0 3px;
border-radius: 0;
box-shadow: none;
@@ -2141,8 +2191,8 @@ input:checked ~ .color-container .color::before {
#small-searchterms {
margin: 0 -1px 0 0;
height: 50px;
- color: #000;
- background-color: #fff;
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
border: 2px solid #d7d7d7d7;
}
#small-searchterms:hover, #small-searchterms:hover + .input-group-append .btn, #small-searchterms + .input-group-append .btn:hover {
@@ -2157,18 +2207,18 @@ input:checked ~ .color-container .color::before {
padding: 0 20px;
font-size: 22px;
border: 2px solid #d7d7d7;
- color: #000;
- background-color: #fff;
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
}
.search-box-select {
max-width: 100px;
height: 50px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 2px solid #d7d7d7d7;
}
.search-box-select + .input-group-append {
margin-left: -2px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.search-button {
border: 2px solid #d7d7d7d7;
@@ -2182,18 +2232,18 @@ input:checked ~ .color-container .color::before {
overflow-x: hidden;
overflow-y: auto;
padding: 15px;
- background: #fff;
+ background: var(--bs-body-bg);
border: 1px solid rgba(0,0,0,.1);
border-top: 0;
z-index: 3;
}
.advanced-search-results .title {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
}
.advanced-search-results .list h6 a {
- color: #000;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 500;
}
@@ -2215,7 +2265,7 @@ input:checked ~ .color-container .color::before {
margin: 0 0 10px 0;
}
.advanced-search-results .products .list .media .media-aside {
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.advanced-search-results .products .list .media:not(:last-child) {
margin: 0 0 10px 0;
@@ -2231,7 +2281,7 @@ input:checked ~ .color-container .color::before {
}
.advanced-search-results .price {
width: 100%;
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
line-height: 1;
@@ -2264,7 +2314,7 @@ input:checked ~ .color-container .color::before {
padding: 7.5px;
border: 0;
text-align: left;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.product-box .attr-container {
position: absolute;
@@ -2286,7 +2336,7 @@ input:checked ~ .color-container .color::before {
width: auto;
height: auto;
padding: 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 0;
}
.attr-container .image-container {
@@ -2311,7 +2361,7 @@ input:checked ~ .color-container .color::before {
left: 0;
visibility: hidden;
opacity: 0;
- background: #fff;
+ background: var(--bs-body-bg);
border: 1px solid transparent;
border-bottom: 0;
border-radius: 0;
@@ -2599,7 +2649,7 @@ input:checked ~ .color-container .color::before {
.product-box .picture-container {
position: relative;
overflow: hidden;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.product-box .picture-container .img-container {
display: block;
@@ -2621,7 +2671,7 @@ input:checked ~ .color-container .color::before {
width: auto;
height: auto;
margin: auto;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
-webkit-transition: all .4s ease-in-out;
transition: all .4s ease-in-out;
}
@@ -2716,8 +2766,8 @@ input:checked ~ .color-container .color::before {
border: 1px solid #ccc;
border-radius: 100%;
padding: 0;
- color: #000;
- background-color: #fff;
+ color: var(--bs-body-color);
+ background-color: var(--bs-body-bg);
}
.product-box .other-buttons .btn.btn-outline-secondary .icons, .product-box .qvb-container .btn .icons {
font-size: 16px;
@@ -2735,7 +2785,7 @@ input:checked ~ .color-container .color::before {
display: flex;
justify-content: flex-start;
width: 100%;
- color: #1c1b1b;
+ color: var(--bs-body-color);
margin: 0;
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
font-size: 15px;
@@ -2795,7 +2845,7 @@ input:checked ~ .color-container .color::before {
}
.product-box .actual-price {
margin: 0 5px 0 0;
- color: #1c1b1b;
+ color: var(--bs-body-color);
font-size: 20px;
font-weight: 700;
line-height: 1;
@@ -2835,7 +2885,7 @@ input:checked ~ .color-container .color::before {
right: -1px;
bottom: 0;
left: 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 1px solid transparent;
border-top: 0;
border-radius: 0;
@@ -2847,7 +2897,7 @@ input:checked ~ .color-container .color::before {
border-color: rgba(0,0,0,.1);
}
.product-box .attr-name {
- color: #000;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 700;
padding: 5px;
@@ -3046,13 +3096,13 @@ input:checked ~ .color-container .color::before {
.rating .b-rating + a {
display: none;
- color: #000;
+ color: var(--bs-body-color);
position: relative;
font-size: 12px;
font-weight: 500;
}
.b-rating .b-rating-value {
- color: #000;
+ color: var(--bs-body-color);
font-size: 12px;
display: none;
}
@@ -3098,7 +3148,7 @@ input:checked ~ .color-container .color::before {
background-color: transparent;
}
.breadcrumb-item.active {
- color: #000;
+ color: var(--bs-body-color);
}
/* newsletter */
@@ -3408,7 +3458,7 @@ transform: rotate(
height: auto;
margin: 0;
padding: 15px 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
border: 1px solid rgba(0,0,0,.1);
}
.top-menu-container .menu-button.show + ul.Menu.-vertical {
@@ -3545,7 +3595,7 @@ transform: rotate(
padding: 0;
left: 0;
right: 0;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.generalTitle span {
padding: 0 15px 0 0;
@@ -3745,7 +3795,7 @@ transform: rotate(
left: 0;
margin: 1px 0 0 0;
padding: 15px 10px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
-webkit-transform: translateX(-100%);
transform: translateX(-100%);
-webkit-transition: transform .4s cubic-bezier(0.645, 0.045, 0.355, 1.000);
@@ -3777,7 +3827,7 @@ transform: rotate(
bottom: 125px;
}
.Menu .other-links li > a {
- color: #000;
+ color: var(--bs-body-color);
font-weight: 700;
}
}
diff --git a/src/Plugins/Theme.Modern/Content/css/customer/customer.css b/src/Plugins/Theme.Modern/Content/css/customer/customer.css
index 9b7974836..5d60ebcdb 100644
--- a/src/Plugins/Theme.Modern/Content/css/customer/customer.css
+++ b/src/Plugins/Theme.Modern/Content/css/customer/customer.css
@@ -11,7 +11,8 @@
color: #25b232;
border-color: #25b232;
}
-.block-account-navigation ul li svg {
+.block-account-navigation ul li svg,
+.block-account-navigation ul li .b-icon {
font-size: 28px;
margin: 0 10px 0 0;
}
@@ -127,21 +128,33 @@
/* status */
+/* Bootstrap's .badge sets a white foreground, which was unreadable on these pale
+ tints - white on #d4edda is about 1.2:1. The subtle/emphasis variable pairs carry
+ their own accessible foreground and flip with data-bs-theme, so the separate dark
+ override this used to need is gone.
+ The class comes from the order status *name*, which is editable in admin, so the
+ base rule has to stand on its own for a renamed or custom status. */
table .status.badge {
font-size: 16px;
padding: 6px 8px;
+ color: var(--bs-secondary-color);
+ background-color: var(--bs-secondary-bg);
}
table .status.pending {
- background-color: #d1ecf1;
+ color: var(--bs-info-text-emphasis);
+ background-color: var(--bs-info-bg-subtle);
}
table .status.processing {
- background-color: #fff3cd;
+ color: var(--bs-warning-text-emphasis);
+ background-color: var(--bs-warning-bg-subtle);
}
table .status.complete {
- background-color: #d4edda;
+ color: var(--bs-success-text-emphasis);
+ background-color: var(--bs-success-bg-subtle);
}
table .status.cancelled {
- background-color: #f8d7da;
+ color: var(--bs-danger-text-emphasis);
+ background-color: var(--bs-danger-bg-subtle);
}
/* pagination */
diff --git a/src/Plugins/Theme.Modern/Content/css/header/header.css b/src/Plugins/Theme.Modern/Content/css/header/header.css
index 3958dfd0c..5e04b4d75 100644
--- a/src/Plugins/Theme.Modern/Content/css/header/header.css
+++ b/src/Plugins/Theme.Modern/Content/css/header/header.css
@@ -4,7 +4,7 @@
.header-nav {
padding: 0 20px 0 0;
width: 100%;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
z-index: 1040;
}
.header-nav-container::before {
@@ -29,7 +29,7 @@
display: flex;
justify-content: center;
align-items: center;
- color: #000;
+ color: var(--bs-body-color);
cursor: pointer;
}
.navbar-nav.top-header-items .nav-link:hover {
@@ -40,7 +40,7 @@
display: flex;
justify-content: center;
align-items: center;
- color: #000;
+ color: var(--bs-body-color);
cursor: pointer;
height: 56px;
padding: 0 15px 0 0;
@@ -88,6 +88,9 @@
background: #1c1b1b;
color: #fff;
}
+.b-sidebar > .b-sidebar-header .btn-close {
+ filter: invert(1);
+}
.b-sidebar-header strong {
font-weight: 300;
}
@@ -137,7 +140,7 @@
width: 100%;
}
.swiper-info .top-header-info strong {
- color: #000;
+ color: var(--bs-body-color);
}
.swiper-info .top-header-info span {
color: #25b232;
@@ -162,8 +165,8 @@
.navbar-nav.selectors .btn.dropdown-toggle {
margin: 0 0 3px 0;
padding: 3px 6px;
- background-color: #fff;
- color: #000;
+ background-color: var(--bs-body-bg);
+ color: var(--bs-body-color);
border: 1px solid rgba(0,0,0,.2);
font-size: 12px;
font-weight: 400;
@@ -175,67 +178,102 @@
padding-left: 0;
}
-.sidebar-cart .item {
+#sidebar-cart .item,
+#sidebar-wishlist .item,
+#sidebar-compare .item {
position: relative;
width: 100%;
margin: 0 0 10px 0;
padding: 10px;
border: 1px solid rgba(0,0,0,.1);
- background-color: #fff;
-}
-.sidebar-cart .item img {
- background-color: #eaf0fa;
-}
-.sidebar-cart .item:last-child,
-.sidebar-cart .item:last-child:first-child {
+ background-color: var(--bs-body-bg);
+}
+#sidebar-cart .item img,
+#sidebar-wishlist .item img,
+#sidebar-compare .item img {
+ background-color: var(--bs-secondary-bg);
+ margin-right: 12px;
+}
+#sidebar-cart .item:last-child,
+#sidebar-wishlist .item:last-child,
+#sidebar-compare .item:last-child,
+#sidebar-cart .item:last-child:first-child,
+#sidebar-wishlist .item:last-child:first-child,
+#sidebar-compare .item:last-child:first-child {
margin: 0;
}
-.sidebar-cart .item .attributes {
+#sidebar-cart .item .attributes,
+#sidebar-wishlist .item .attributes,
+#sidebar-compare .item .attributes {
font-size: 12px;
- color: #000;
- background: #eaf0fa;
+ color: var(--bs-body-color);
+ background: var(--bs-secondary-bg);
display: table;
margin: 5px 0;
}
-.sidebar-cart .item .deletecartitem {
+#sidebar-cart .item .deletecartitem,
+#sidebar-wishlist .item .deletecartitem,
+#sidebar-compare .item .deletecartitem {
position: absolute;
top: 5px;
right: 5px;
cursor: pointer;
}
-.sidebar-cart .item .col a {
- color: #000;
+#sidebar-cart .item .col a,
+#sidebar-wishlist .item .col a,
+#sidebar-compare .item .col a {
+ color: var(--bs-body-color);
font-family: Poppins, sans-serif;
font-size: 14px;
font-weight: 400;
text-decoration: none;
}
-.sidebar-cart .item .col .price, .sidebar-cart .item .col .quantity {
+#sidebar-cart .item .col .price,
+#sidebar-wishlist .item .col .price,
+#sidebar-compare .item .col .price,
+#sidebar-cart .item .col .quantity,
+#sidebar-wishlist .item .col .quantity,
+#sidebar-compare .item .col .quantity {
font-size: 14px;
font-weight: 500;
}
-.sidebar-cart .item .col .price strong, .sidebar-cart .item .col .quantity strong {
+#sidebar-cart .item .col .price strong,
+#sidebar-wishlist .item .col .price strong,
+#sidebar-compare .item .col .price strong,
+#sidebar-cart .item .col .quantity strong,
+#sidebar-wishlist .item .col .quantity strong,
+#sidebar-compare .item .col .quantity strong {
margin: 0 0 0 5px;
font-size: 18px;
font-weight: 700;
}
-.sidebar-cart .b-sidebar-footer .h5 {
+#sidebar-cart .b-sidebar-footer .h5,
+#sidebar-wishlist .b-sidebar-footer .h5,
+#sidebar-compare .b-sidebar-footer .h5 {
font-weight: 300;
}
-.sidebar-cart .b-sidebar-footer .h4 {
+#sidebar-cart .b-sidebar-footer .h4,
+#sidebar-wishlist .b-sidebar-footer .h4,
+#sidebar-compare .b-sidebar-footer .h4 {
font-weight: 700;
}
-.sidebar-cart .sub-tot-top {
+#sidebar-cart .sub-tot-top,
+#sidebar-wishlist .sub-tot-top,
+#sidebar-compare .sub-tot-top {
font-weight: 300;
}
-.sidebar-cart .sub-tot-bottom {
+#sidebar-cart .sub-tot-bottom,
+#sidebar-wishlist .sub-tot-bottom,
+#sidebar-compare .sub-tot-bottom {
font-family: 'Poppins', sans-serif;
}
.header-nav .bi-cart-check {
width: 40px;
margin: 6px 0 0 0;
}
-.sidebar-cart .cart-button.btn-secondary {
+#sidebar-cart .cart-button.btn-secondary,
+#sidebar-wishlist .cart-button.btn-secondary,
+#sidebar-compare .cart-button.btn-secondary {
padding: 12px;
font-size: 16px;
font-weight: 700;
@@ -243,10 +281,14 @@
border-color: #111;
border-width: 2px;
}
-.sidebar-cart .cart-button.btn-secondary:hover {
+#sidebar-cart .cart-button.btn-secondary:hover,
+#sidebar-wishlist .cart-button.btn-secondary:hover,
+#sidebar-compare .cart-button.btn-secondary:hover {
background-color: #000;
}
-.sidebar-cart .no-items-icon {
+#sidebar-cart .no-items-icon,
+#sidebar-wishlist .no-items-icon,
+#sidebar-compare .no-items-icon {
font-size: 80px;
}
@@ -281,7 +323,7 @@
}
@media (min-width: 576px) {
.b-sidebar {
- width: 530px;
+ --bs-offcanvas-width: 530px;
}
}
@media (max-width: 1399px) {
@@ -312,54 +354,51 @@
border-bottom: 0;
}
.header-nav {
- position: fixed;
- top: unset;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- padding: 10px;
- display: inline-flex;
- justify-content: space-around;
- box-shadow: 0 -3px 6px rgba(0,0,0,.16), 0 -3px 6px rgba(0,0,0,.23);
- z-index: 1034;
- }
- .header-nav > .navbar {
- width: 100%;
- height: 58px;
+ display: none;
}
.navbar-brand img {
max-width: 85px;
max-height: 45px;
}
- .header-nav-container::before {
- margin-bottom: 70px;
- }
}
@media (max-width: 575px) {
.navbar-nav.top-header-items .nav-link {
font-size: 22px;
}
- .sidebar-cart .item img {
+#sidebar-cart .item img,
+#sidebar-wishlist .item img,
+#sidebar-compare .item img {
max-width: 75px;
}
- .sidebar-cart .item .col a {
+#sidebar-cart .item .col a,
+#sidebar-wishlist .item .col a,
+#sidebar-compare .item .col a {
display: table;
max-width: calc(100% - 15px);
font-size: 14px;
line-height: 1;
}
- .sidebar-cart .item .price,
- .sidebar-cart .item .quantity {
+#sidebar-cart .item .price,
+#sidebar-wishlist .item .price,
+#sidebar-compare .item .price,
+#sidebar-cart .item .quantity,
+#sidebar-wishlist .item .quantity,
+#sidebar-compare .item .quantity {
font-size: 14px;
}
- .sidebar-cart .item .attributes {
+#sidebar-cart .item .attributes,
+#sidebar-wishlist .item .attributes,
+#sidebar-compare .item .attributes {
font-size: 12px;
}
- .sidebar-cart .b-sidebar-footer .h4 {
+#sidebar-cart .b-sidebar-footer .h4,
+#sidebar-wishlist .b-sidebar-footer .h4,
+#sidebar-compare .b-sidebar-footer .h4 {
font-size: 20px;
}
- .sidebar-cart .b-sidebar-footer .h5 {
+#sidebar-cart .b-sidebar-footer .h5,
+#sidebar-wishlist .b-sidebar-footer .h5,
+#sidebar-compare .b-sidebar-footer .h5 {
font-size: 16px;
}
.shopping-item .subtotal {
diff --git a/src/Plugins/Theme.Modern/Content/css/home/home.css b/src/Plugins/Theme.Modern/Content/css/home/home.css
index 6cb9b4feb..71d5ba4ae 100644
--- a/src/Plugins/Theme.Modern/Content/css/home/home.css
+++ b/src/Plugins/Theme.Modern/Content/css/home/home.css
@@ -3,7 +3,7 @@
.generalTitle {
margin: 15px 0;
}
-[v-cloak] .products-tabs {
+.products-tabs[v-cloak] {
display: none;
}
.home-page .home-page-section {
@@ -16,7 +16,7 @@
position: relative;
height: calc(100vh - 195px);
min-height: 550px;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
-webkit-transition: all .2s ease-in-out;
transition: all .2s ease-in-out;
}
@@ -131,7 +131,7 @@
z-index: 3;
}
.product-box-slide .content .title a {
- color: #1c1b1b;
+ color: var(--bs-body-color);
font-size: 48px;
line-height: 1;
font-weight: 900;
@@ -336,7 +336,7 @@
margin: 0 -1px 1px 0;
padding: 16px 26px;
font-family: 'Poppins', sans-serif;
- color: #000;
+ color: var(--bs-body-color);
background-color: transparent;
font-size: 14px;
font-weight: 700;
@@ -385,7 +385,7 @@
.brands-container {
padding: 0 10%;
margin: 0 -15px;
- background-color: #fff;
+ background-color: var(--bs-body-bg);
}
.brands-container .swiper-wrapper {
align-items: center;
@@ -419,7 +419,7 @@
align-items: center;
justify-content: space-between;
padding: 10%;
- background: #eaf0fa;
+ background: var(--bs-secondary-bg);
overflow: hidden;
}
.parallax-banner .content {
@@ -508,7 +508,7 @@
position: relative;
top: -118px;
padding: 15px 7.5px;
- background: #fff;
+ background: var(--bs-body-bg);
z-index: 15;
}
.home-page-banner + * {
diff --git a/src/Plugins/Theme.Modern/Content/css/product/product.css b/src/Plugins/Theme.Modern/Content/css/product/product.css
index e94221e74..f6aca6522 100644
--- a/src/Plugins/Theme.Modern/Content/css/product/product.css
+++ b/src/Plugins/Theme.Modern/Content/css/product/product.css
@@ -25,7 +25,7 @@
.gallery-top {
height: 100%;
width: 75%;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.gallery-thumbs {
width: 25%;
@@ -37,7 +37,7 @@
width: 100%;
height: 25%;
opacity: 0.4;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
border: 1px solid rgba(0,0,0,.1);
box-shadow: none;
cursor: pointer;
@@ -66,7 +66,7 @@
z-index: 1101;
}
.medium-zoom-image {
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
}
.zoom-button {
position: absolute;
@@ -120,7 +120,7 @@
font-size: 14px;
}
.product-details-page .overview .label {
- color: #000;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 500;
}
@@ -134,12 +134,12 @@
/* spec */
.spec-container .spec-name {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
}
.spec-container .spec-value {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 500;
}
@@ -150,7 +150,7 @@
margin: 0 0 15px 0;
padding: 15px;
border: 0;
- background-color: #eaf0fa;
+ background-color: var(--bs-secondary-bg);
border-radius: .25rem;
}
.attributes table {
@@ -166,12 +166,12 @@
padding: 10px;
}
.attributes .text-prompt {
- color: #000;
+ color: var(--bs-body-color);
font-size: 16px;
font-weight: 700;
}
.attributes .custom-control-description {
- color: #000;
+ color: var(--bs-body-color);
font-size: 14px;
font-weight: 400;
}
@@ -233,7 +233,7 @@
align-items: center;
justify-content: center;
height: 60px;
- background: #eaf0fa;
+ background: var(--bs-secondary-bg);
}
.product-details-page .qty-controls div {
display: flex;
@@ -270,11 +270,11 @@
.product-add-to-cart .qty-input, .product-add-to-cart .qty-dropdown {
max-width: 65px;
height: 60px;
- color: #000;
+ color: var(--bs-body-color);
font-size: 18px;
font-weight: 700;
- background-color: #eaf0fa;
- border-color: rgba(0,0,0,.2);
+ background-color: var(--bs-secondary-bg);
+ border-color: var(--bs-border-color);
}
.product-details-page .add-to-cart-button.btn-info {
display: flex;
@@ -355,7 +355,7 @@
text-transform: uppercase;
font-weight: 700;
font-family: Poppins, sans-serif;
- color: #000;
+ color: var(--bs-body-color);
background-color: transparent;
border: 1px solid rgba(0,0,0,.1);
border-bottom: 0;
diff --git a/src/Plugins/Theme.Modern/Content/script/app.js b/src/Plugins/Theme.Modern/Content/script/app.js
index 82a12dae0..8acb383f9 100644
--- a/src/Plugins/Theme.Modern/Content/script/app.js
+++ b/src/Plugins/Theme.Modern/Content/script/app.js
@@ -1,14 +1,21 @@
"use strict";
-Vue.use(VueAwesomeSwiper)
-var vm = new Vue({
- el: '#app',
+/*
+ * The shell: the state and methods the page chrome shares. See the note in
+ * Grand.Web's theme/script/app.js - this used to be one Vue instance compiling
+ * the whole , and is now a reactive object shared by the `vue-island`
+ * elements Vue actually mounts on.
+ */
+var vm = Vue.shell({
data: function () {
return {
show: false,
fluid: false,
hover: false,
- darkMode: false,
+ // resolved in by Partials/ColorScheme (explicit choice, else the OS
+ // preference). Set here and not in mounted() so the watcher does not fire on
+ // the initial value - that would persist a choice the visitor never made.
+ darkMode: typeof window.grandColorSchemeIsDark === 'function' && window.grandColorSchemeIsDark(),
active: false,
NextDropdownVisible: false,
value: 5,
@@ -60,6 +67,29 @@ var vm = new Vue({
slideToClickedSlide: true,
},
},
+ // Product image gallery (see Pictures.cshtml, which sets
+ // window.productGallery before this vm is created). loop mode
+ // needs at least loopedSlides real slides to work; with fewer,
+ // Swiper's loop math breaks and navigation silently does nothing.
+ swiperOptionTop: {
+ loop: !!window.productGallery && window.productGallery.pictureCount > 4,
+ loopedSlides: 4,
+ spaceBetween: 10,
+ navigation: {
+ nextEl: '#ppslider .swiper-button-next',
+ prevEl: '#ppslider .swiper-button-prev'
+ },
+ },
+ swiperOptionThumbs: {
+ direction: 'vertical',
+ loop: !!window.productGallery && window.productGallery.pictureCount > 4,
+ loopedSlides: 4,
+ spaceBetween: 10,
+ centeredSlides: true,
+ slidesPerView: 'auto',
+ touchRatio: 0.2,
+ slideToClickedSlide: true,
+ },
}
},
props: {
@@ -75,9 +105,8 @@ var vm = new Vue({
if (localStorage.fluid == "true") this.fluid = "fluid";
if (localStorage.fluid == "fluid") this.fluid = "fluid";
if (localStorage.fluid == "") this.fluid = "false";
- if (localStorage.darkMode == "true") this.darkMode = true;
this.TopScroll();
- //this.wishindicator = parseInt(this.$refs.wishlistQty.innerText);
+ this.wishindicator = this.readWishlistQty();
this.updateCompareProductsQty();
this.backToTop();
if (991 < window.innerWidth) {
@@ -86,6 +115,15 @@ var vm = new Vue({
window.addEventListener('DOMContentLoaded', () => {
vm.$forceUpdate();
});
+ if (window.productGallery) {
+ setTimeout(function () {
+ if (!vm.$refs.swiperTop || !vm.$refs.swiperThumbs) return;
+ const swiperTop = vm.$refs.swiperTop.$swiper
+ const swiperThumbs = vm.$refs.swiperThumbs.$swiper
+ swiperTop.controller.control = swiperThumbs
+ swiperThumbs.controller.control = swiperTop
+ }, 1000)
+ }
},
created: function () {
if (location.pathname !== "/") {
@@ -97,13 +135,27 @@ var vm = new Vue({
localStorage.fluid = newName;
},
darkMode: function (newValue) {
- localStorage.darkMode = newValue;
+ // fires only when the visitor uses the switch, which is exactly when the
+ // choice should become explicit and stop following the OS
+ window.grandSetColorScheme(newValue);
},
PopupQuickViewVueModal: function () {
vm.getLinkedProductsQV(vm.PopupQuickViewVueModal.Id);
+ },
+ // .left-side-container is the element an island mounts on, and Vue never
+ // sees the attributes of its own mount point - so the class it used to
+ // carry as a v-bind is applied from here. The initial state is rendered
+ // by the layout, which keeps the sidebar from flickering open on load.
+ menuToggled: function (value) {
+ document.querySelectorAll('.left-side-container, #home-page').forEach(function (el) {
+ el.classList.toggle('toggled', !value);
+ });
}
},
methods: {
+ slideToThumb(index) {
+ this.$refs.swiperTop.$swiper.slideTo(index)
+ },
openMenu(el, mainMenu) {
var menu = document.getElementById(mainMenu);
if (menu.classList.contains('show')) {
@@ -298,37 +350,51 @@ var vm = new Vue({
alert(error);
});
},
+ /*
+ * Mounts server-rendered modal markup (privacy preferences, newsletter
+ * categories) as an app of its own.
+ *
+ * It used to render through `Vue.compile`, which the Vue 3 build does not
+ * expose - the markup silently never appeared. Passing the HTML as the
+ * `template` option compiles it the same way an island does.
+ */
displayPopup(html, el) {
- new Vue({
- el: '#' + el,
- data: {
- template: null,
- },
- render: function (createElement) {
- if (!this.template) {
- return createElement('b-overlay', {
- attrs: {
- show: 'true'
- }
- });
- } else {
- return this.template();
- }
- },
- methods: {
- showModal: function () {
- this.$refs[el].show()
+ var container = document.getElementById(el);
+ if (!container) {
+ container = document.createElement('div');
+ container.id = el;
+ document.body.appendChild(container);
+ }
+ if (!vm._popupApps) vm._popupApps = {};
+ if (vm._popupApps[el]) {
+ vm._popupApps[el].unmount();
+ delete vm._popupApps[el];
+ }
+ var popup = Vue.createApp({
+ template: html,
+ data: function () {
+ return {
+ darkMode: vm.darkMode
}
},
mounted: function () {
- var self = this;
- self.template = Vue.compile(html).render;
- this.darkMode = vm.darkMode;
- },
- updated: function () {
- this.showModal();
+ /*
+ * This used to be `this.$refs[el].show()` - a BootstrapVue
+ * `` reaching for the component instance. The
+ * partials it renders carry no `ref` at all and Bootstrap 5
+ * modals are plain elements, so the lookup was undefined and
+ * both popups mounted invisibly. Query the modal out of the
+ * container instead: its own id duplicates the container's,
+ * so getElementById would hand back the wrapper.
+ */
+ this.$nextTick(function () {
+ var modal = container.querySelector('.modal');
+ if (modal) bootstrap.Modal.getOrCreateInstance(modal).show();
+ });
}
});
+ popup.mount(container);
+ vm._popupApps[el] = popup;
},
displayBarNotification(message, url, messagetype, timeout) {
var variant;
@@ -361,6 +427,21 @@ var vm = new Vue({
});
return false;
},
+ /*
+ * Seeds the wishlist counter from the server-rendered header.
+ *
+ * The element only exists when the wishlist is enabled for the current
+ * customer, so reading the ref unguarded threw and aborted the rest of
+ * mounted() - which is why the call had been commented out. The number comes
+ * from data-qty rather than the element text, which is a localized template
+ * a store is free to decorate ("(0)", "0 items") and parseInt cannot read.
+ */
+ readWishlistQty: function () {
+ const el = this.$refs.wishlistQty;
+ if (!el) return undefined;
+ const qty = parseInt(el.dataset.qty, 10);
+ return isNaN(qty) ? undefined : qty;
+ },
updateCompareProductsQty: function () {
const cookie = AxiosCart.getCookie('Grand.CompareProduct');
if (cookie !== '') {
@@ -435,9 +516,6 @@ var vm = new Vue({
}
this.updateCompareProductsQty();
},
- showModalOutOfStock: function () {
- this.$refs['out-of-stock'].show()
- },
productImage: function (event) {
var Imagesrc = event.target.parentElement.getAttribute('data-href');
function collectionHas(a, b) {
@@ -526,18 +604,27 @@ var vm = new Vue({
if (response.data.stockAvailability) {
vm.PopupQuickViewVueModal.StockAvailability = response.data.stockAvailability;
}
- if (response.data.enabledattributemappingids) {
- for (var i = 0; i < response.data.enabledattributemappingids.length; i++) {
- document.querySelector('#product_attribute_label_' + response.data.enabledattributemappingids[i]).style.display = "table-cell";
- document.querySelector('#product_attribute_input_' + response.data.enabledattributemappingids[i]).style.display = "table-cell";
+ /*
+ * Conditional attributes: show the rows this choice unlocks, hide the
+ * ones it rules out.
+ *
+ * Scoped to the modal, and null-guarded. Both matter: the product page
+ * behind the quick view renders the *same* element ids, so a bare
+ * document.querySelector returned the page's row and left the modal's
+ * untouched; and when neither exists the unguarded `.style` threw,
+ * which aborted the rest of this handler.
+ */
+ var modal = document.getElementById('ModalQuickView');
+ var setRows = function (ids, display) {
+ for (var i = 0; i < (ids || []).length; i++) {
+ var label = modal && modal.querySelector('#product_attribute_label_' + ids[i]);
+ var input = modal && modal.querySelector('#product_attribute_input_' + ids[i]);
+ if (label) label.style.display = display;
+ if (input) input.style.display = display;
}
- }
- if (response.data.disabledattributemappingids) {
- for (var i = 0; i < response.data.disabledattributemappingids.length; i++) {
- document.querySelector('#product_attribute_label_' + response.data.disabledattributemappingids[i]).style.display = "none";
- document.querySelector('#product_attribute_input_' + response.data.disabledattributemappingids[i]).style.display = "none";
- }
- }
+ };
+ setRows(response.data.enabledattributemappingids, "table-cell");
+ setRows(response.data.disabledattributemappingids, "none");
/*if (response.data.notAvailableAttributeMappingids) {
document.querySelectorAll('[data-disable]').forEach((element) => element.disabled = false);
for (var i = 0; i < response.data.notAvailableAttributeMappingids.length; i++) {
@@ -547,17 +634,31 @@ var vm = new Vue({
}
}*/
if (response.data.pictureDefaultSizeUrl !== null) {
- if (vm.PopupQuickViewVueModal.PictureModels.length > 1) {
- const active_img_src = vm.$refs.QuickViewSlider.$swiper.slides[vm.$refs.QuickViewSlider.$swiper.activeIndex].querySelector("img").dataset.srcs;
- if (!(active_img_src == response.data.pictureDefaultSizeUrl)) {
- vm.$refs.QuickViewSlider.$swiper.slides.forEach(function (element, index) {
- const img_src = element.querySelector('img').dataset.srcs;
- if (img_src == response.data.pictureDefaultSizeUrl) {
- vm.$refs.QuickViewSlider.$swiper.slideTo(index, 1000, false)
+ /*
+ * Slide the gallery to the picture the chosen attribute maps to.
+ *
+ * The swiper is read defensively: inside the quick view it never
+ * finishes initialising (no .swiper-initialized, slides is empty),
+ * so indexing it threw "Cannot read properties of undefined
+ * (reading 'querySelector')" on *every* attribute change - which
+ * aborted this handler and left the picture on the old value. The
+ * single-picture path is the fallback: it swaps the image source
+ * directly, which is what the visitor actually needs to see.
+ */
+ var swiper = vm.$refs.QuickViewSlider && vm.$refs.QuickViewSlider.$swiper;
+ var slides = (swiper && swiper.slides) || [];
+ if (vm.PopupQuickViewVueModal.PictureModels.length > 1 && slides.length) {
+ var active = slides[swiper.activeIndex];
+ var activeImg = active && active.querySelector("img");
+ if (!activeImg || activeImg.dataset.srcs != response.data.pictureDefaultSizeUrl) {
+ Array.prototype.forEach.call(slides, function (element, index) {
+ var img = element.querySelector('img');
+ if (img && img.dataset.srcs == response.data.pictureDefaultSizeUrl) {
+ swiper.slideTo(index, 1000, false)
}
})
}
- } else {
+ } else if (vm.PopupQuickViewVueModal.DefaultPictureModel) {
vm.PopupQuickViewVueModal.DefaultPictureModel.ImageUrl = response.data.pictureDefaultSizeUrl;
}
}
@@ -626,8 +727,12 @@ var vm = new Vue({
});
},
warehouse_change_handler(id, url) {
+ //scoped to the modal: the product page behind it uses the same
+ //element id, and getElementById would return that one instead
+ var select = document.querySelector('#ModalQuickView #WarehouseId');
+ if (!select) return;
var data = new FormData();
- data.append('warehouseId', document.getElementById('WarehouseId').value);
+ data.append('warehouseId', select.value);
data.append('productId', id);
axios({
url: url,
@@ -637,6 +742,12 @@ var vm = new Vue({
if (response.data.stockAvailability) {
vm.PopupQuickViewVueModal.StockAvailability = response.data.stockAvailability;
}
+ //an attribute product prices per warehouse too, so let the
+ //attribute handler refresh price and availability together
+ if (vm.PopupQuickViewVueModal.ProductAttributes
+ && vm.PopupQuickViewVueModal.ProductAttributes.length > 0) {
+ vm.attrchange(id, true);
+ }
})
},
formatDate(date) {
diff --git a/src/Plugins/Theme.Modern/Content/script/catalog.js b/src/Plugins/Theme.Modern/Content/script/catalog.js
index fe05192b8..75ba70ee8 100644
--- a/src/Plugins/Theme.Modern/Content/script/catalog.js
+++ b/src/Plugins/Theme.Modern/Content/script/catalog.js
@@ -1,5 +1,4 @@
-Vue.use(VueAwesomeSwiper)
-var subcatslider = new Vue({
+var subcatslider = new Vue({
data() {
return {
swiperOptions: {
@@ -34,6 +33,14 @@ var subcatslider = new Vue({
}
},
});
+/*
+ * Published under the name the category page's island declares. This script is
+ * not part of the bundle, so views/ cannot register it - Vue.registerViewModel is
+ * the way in. It has to run before the islands mount, which it does: the theme's
+ * scripts are ordered ahead of app.js, and app.js is what calls Vue.shell().
+ */
+Vue.registerViewModel('subcatslider', subcatslider);
+
function sideToggle() {
var leftSide = document.querySelector(".generalLeftSide");
if (leftSide.classList.contains('show')) {
@@ -73,3 +80,19 @@ function closeLeftSide() {
}, 400);
}
+function updateFiltersToggleVisibility() {
+ var toggle = document.getElementById('mobile-filters-toggle');
+ var leftSide = document.querySelector('.generalLeftSide');
+ if (!toggle || !leftSide) return;
+ // block-category-navigation is d-lg-block/d-none, so it only counts as
+ // real content above the lg breakpoint - modal-close is always present
+ // and isn't filter content, so it's excluded from the check.
+ var hasContent = Array.prototype.some.call(leftSide.children, function (el) {
+ if (el.classList.contains('modal-close')) return false;
+ return getComputedStyle(el).display !== 'none';
+ });
+ toggle.style.display = hasContent ? '' : 'none';
+}
+document.addEventListener("DOMContentLoaded", updateFiltersToggleVisibility);
+window.addEventListener("resize", updateFiltersToggleVisibility);
+
diff --git a/src/Plugins/Theme.Modern/Content/script/home.js b/src/Plugins/Theme.Modern/Content/script/home.js
index 4075d31ef..aaecf1076 100644
--- a/src/Plugins/Theme.Modern/Content/script/home.js
+++ b/src/Plugins/Theme.Modern/Content/script/home.js
@@ -1,5 +1,4 @@
-Vue.use(VueAwesomeSwiper)
-var hpi = new Vue({
+var hpi = new Vue({
props: {
HomePageItems: {
type: Array,
diff --git a/src/Plugins/Theme.Modern/Content/script/npslider-home.js b/src/Plugins/Theme.Modern/Content/script/npslider-home.js
index 63f8dd5f9..db304b6b2 100644
--- a/src/Plugins/Theme.Modern/Content/script/npslider-home.js
+++ b/src/Plugins/Theme.Modern/Content/script/npslider-home.js
@@ -1,5 +1,4 @@
-Vue.use(VueAwesomeSwiper)
-var hpnslider = new Vue({
+var hpnslider = new Vue({
data() {
return {
Model: null,
diff --git a/src/Plugins/Theme.Modern/Content/script/swiper-vue3.js b/src/Plugins/Theme.Modern/Content/script/swiper-vue3.js
new file mode 100644
index 000000000..4d1c1cb80
--- /dev/null
+++ b/src/Plugins/Theme.Modern/Content/script/swiper-vue3.js
@@ -0,0 +1,64 @@
+"use strict";
+
+/* Vue 2 -> Vue 3: replaces vue-awesome-swiper (which registered and
+ * via Vue.use, an API the storefront's window.Vue does not have).
+ * / are registered here as thin wrappers around the
+ * (framework-agnostic) Swiper core library loaded just before this script. */
+Vue.component('swiper-slide', {
+ template: '
'
+})
+Vue.component('swiper', {
+ props: {
+ options: {
+ type: Object,
+ default: () => ({})
+ }
+ },
+ data: function () {
+ return { _swiper: null }
+ },
+ computed: {
+ $swiper: function () {
+ return this._swiper;
+ }
+ },
+ mounted: function () {
+ this.reparentControls();
+ this.$nextTick(() => {
+ /*
+ * `observer` is not an optimisation - without it the gallery is dead.
+ *
+ * The slides come from the parent's (a v-for over a model that
+ * arrives by ajax, e.g. the quick view), and Swiper reads the slide list
+ * once, at construction. It was constructing against an empty wrapper, so
+ * `swiper.slides` stayed empty for the life of the instance: slideTo() had
+ * nothing to move, and Theme.Modern's attrchange threw on slides[0] every
+ * time a product attribute changed. Observing the DOM lets Swiper pick the
+ * slides up whenever they land; the update() below covers the slides that
+ * were already there by the time this ran.
+ */
+ var options = Object.assign({ observer: true, observeParents: true }, this.options);
+ this._swiper = new Swiper(this.$refs.container, options);
+ this.$nextTick(() => { if (this._swiper) this._swiper.update(); });
+ });
+ },
+ updated: function () {
+ this.reparentControls();
+ if (this._swiper) this._swiper.update();
+ },
+ beforeUnmount: function () {
+ if (this._swiper) this._swiper.destroy(true, true);
+ },
+ methods: {
+ reparentControls: function () {
+ var container = this.$refs.container;
+ if (!container) return;
+ var wrapper = container.querySelector('.swiper-wrapper');
+ if (!wrapper) return;
+ wrapper.querySelectorAll(':scope > .swiper-button-next, :scope > .swiper-button-prev, :scope > .swiper-pagination, :scope > .swiper-scrollbar').forEach(function (el) {
+ container.appendChild(el);
+ });
+ }
+ },
+ template: '
'
+})
diff --git a/src/Plugins/Theme.Modern/Content/swiper/vue-awesome-swiper.min.js b/src/Plugins/Theme.Modern/Content/swiper/vue-awesome-swiper.min.js
deleted file mode 100644
index 194afdbda..000000000
--- a/src/Plugins/Theme.Modern/Content/swiper/vue-awesome-swiper.min.js
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * Minified by jsDelivr using Terser v3.14.1.
- * Original file: /npm/vue-awesome-swiper@4.1.1/dist/vue-awesome-swiper.js
- *
- * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
- */
-!function (e, t) { "object" == typeof exports && "undefined" != typeof module ? t(exports, require("swiper"), require("vue")) : "function" == typeof define && define.amd ? define(["exports", "swiper", "vue"], t) : t((e = e || self).VueAwesomeSwiper = {}, e.Swiper, e.Vue) }(this, function (e, t, n) { "use strict"; var i; t = t && Object.prototype.hasOwnProperty.call(t, "default") ? t.default : t, n = n && Object.prototype.hasOwnProperty.call(n, "default") ? n.default : n, function (e) { e.SwiperComponent = "Swiper", e.SwiperSlideComponent = "SwiperSlide", e.SwiperDirective = "swiper", e.SwiperInstance = "$swiper" }(i || (i = {})); var o, r, l = Object.freeze({ containerClass: "swiper-container", wrapperClass: "swiper-wrapper", slideClass: "swiper-slide" }); !function (e) { e.Ready = "ready", e.ClickSlide = "clickSlide" }(o || (o = {})), function (e) { e.AutoUpdate = "autoUpdate", e.AutoDestroy = "autoDestroy", e.DeleteInstanceOnDestroy = "deleteInstanceOnDestroy", e.CleanupStylesOnDestroy = "cleanupStylesOnDestroy" }(r || (r = {})); var a = ["init", "beforeDestroy", "slideChange", "slideChangeTransitionStart", "slideChangeTransitionEnd", "slideNextTransitionStart", "slideNextTransitionEnd", "slidePrevTransitionStart", "slidePrevTransitionEnd", "transitionStart", "transitionEnd", "touchStart", "touchMove", "touchMoveOpposite", "sliderMove", "touchEnd", "click", "tap", "doubleTap", "imagesReady", "progress", "reachBeginning", "reachEnd", "fromEdge", "setTranslate", "setTransition", "resize", "observerUpdate", "beforeLoopFix", "loopFix"]; function s() { for (var e = 0, t = 0, n = arguments.length; t < n; t++)e += arguments[t].length; var i = Array(e), o = 0; for (t = 0; t < n; t++)for (var r = arguments[t], l = 0, a = r.length; l < a; l++, o++)i[o] = r[l]; return i } var d, u = function (e) { return e.replace(/([a-z])([A-Z])/g, "$1-$2").replace(/\s+/g, "-").toLowerCase() }, p = function (e, t, n) { var i, r, l; if (e && !e.destroyed) { var a = (null === (i = t.composedPath) || void 0 === i ? void 0 : i.call(t)) || t.path; if ((null == t ? void 0 : t.target) && a) { var s = Array.from(e.slides), d = Array.from(a); if (s.includes(t.target) || d.some(function (e) { return s.includes(e) })) { var p = e.clickedIndex, c = Number(null === (l = null === (r = e.clickedSlide) || void 0 === r ? void 0 : r.dataset) || void 0 === l ? void 0 : l.swiperSlideIndex), v = Number.isInteger(c) ? c : null; n(o.ClickSlide, p, v), n(u(o.ClickSlide), p, v) } } } }, c = function (e, t) { a.forEach(function (n) { e.on(n, function () { for (var e = arguments, i = [], o = 0; o < arguments.length; o++)i[o] = e[o]; t.apply(void 0, s([n], i)); var r = u(n); r !== n && t.apply(void 0, s([r], i)) }) }) }, v = "instanceName"; function f(e, t) { var n = function (e, t) { var n, i, o, r, l = null === (i = null === (n = e.data) || void 0 === n ? void 0 : n.attrs) || void 0 === i ? void 0 : i[t]; return void 0 !== l ? l : null === (r = null === (o = e.data) || void 0 === o ? void 0 : o.attrs) || void 0 === r ? void 0 : r[u(t)] }, a = function (e, t, o) { return t.arg || n(o, v) || e.id || i.SwiperInstance }, s = function (e, t, n) { var i = a(e, t, n); return n.context[i] || null }, d = function (e) { return e.value || t }, f = function (e) { return [!0, void 0, null, ""].includes(e) }, h = function (e) { var t, n, i = (null === (t = e.data) || void 0 === t ? void 0 : t.on) || (null === (n = e.componentOptions) || void 0 === n ? void 0 : n.listeners); return function (e) { for (var t, n = arguments, o = [], r = 1; r < arguments.length; r++)o[r - 1] = n[r]; var l = null === (t = i) || void 0 === t ? void 0 : t[e]; l && l.fns.apply(l, o) } }; return { bind: function (e, t, n) { -1 === e.className.indexOf(l.containerClass) && (e.className += (e.className ? " " : "") + l.containerClass), e.addEventListener("click", function (i) { var o = h(n), r = s(e, t, n); p(r, i, o) }) }, inserted: function (t, n, i) { var r = i.context, l = d(n), s = a(t, n, i), u = h(i), p = r, v = null == p ? void 0 : p[s]; v && !v.destroyed || (v = new e(t, l), p[s] = v, c(v, u), u(o.Ready, v)) }, componentUpdated: function (e, t, i) { var o, l, a, u, p, c, v, h, w, S, y, m, C = n(i, r.AutoUpdate); if (f(C)) { var g = s(e, t, i); if (g) { var I = d(t).loop; I && (null === (l = null === (o = g) || void 0 === o ? void 0 : o.loopDestroy) || void 0 === l || l.call(o)), null === (a = null == g ? void 0 : g.update) || void 0 === a || a.call(g), null === (p = null === (u = g.navigation) || void 0 === u ? void 0 : u.update) || void 0 === p || p.call(u), null === (v = null === (c = g.pagination) || void 0 === c ? void 0 : c.render) || void 0 === v || v.call(c), null === (w = null === (h = g.pagination) || void 0 === h ? void 0 : h.update) || void 0 === w || w.call(h), I && (null === (y = null === (S = g) || void 0 === S ? void 0 : S.loopCreate) || void 0 === y || y.call(S), null === (m = null == g ? void 0 : g.update) || void 0 === m || m.call(g)) } } }, unbind: function (e, t, i) { var o, l = n(i, r.AutoDestroy); if (f(l)) { var a = s(e, t, i); a && a.initialized && (null === (o = null == a ? void 0 : a.destroy) || void 0 === o || o.call(a, f(n(i, r.DeleteInstanceOnDestroy)), f(n(i, r.CleanupStylesOnDestroy)))) } } } } function h(e) { var t; return n.extend({ name: i.SwiperComponent, props: (t = { defaultOptions: { type: Object, required: !1, default: function () { return {} } }, options: { type: Object, required: !1 } }, t[r.AutoUpdate] = { type: Boolean, default: !0 }, t[r.AutoDestroy] = { type: Boolean, default: !0 }, t[r.DeleteInstanceOnDestroy] = { type: Boolean, required: !1, default: !0 }, t[r.CleanupStylesOnDestroy] = { type: Boolean, required: !1, default: !0 }, t), data: function () { var e; return (e = {})[i.SwiperInstance] = null, e }, computed: { swiperInstance: { cache: !1, set: function (e) { this[i.SwiperInstance] = e }, get: function () { return this[i.SwiperInstance] } }, swiperOptions: function () { return this.options || this.defaultOptions }, wrapperClass: function () { return this.swiperOptions.wrapperClass || l.wrapperClass } }, methods: { handleSwiperClick: function (e) { p(this.swiperInstance, e, this.$emit.bind(this)) }, autoReLoopSwiper: function () { var e, t; if (this.swiperInstance && this.swiperOptions.loop) { var n = this.swiperInstance; null === (e = null == n ? void 0 : n.loopDestroy) || void 0 === e || e.call(n), null === (t = null == n ? void 0 : n.loopCreate) || void 0 === t || t.call(n) } }, updateSwiper: function () { var e, t, n, i, o, l, a, s; this[r.AutoUpdate] && this.swiperInstance && (this.autoReLoopSwiper(), null === (t = null === (e = this.swiperInstance) || void 0 === e ? void 0 : e.update) || void 0 === t || t.call(e), null === (i = null === (n = this.swiperInstance.navigation) || void 0 === n ? void 0 : n.update) || void 0 === i || i.call(n), null === (l = null === (o = this.swiperInstance.pagination) || void 0 === o ? void 0 : o.render) || void 0 === l || l.call(o), null === (s = null === (a = this.swiperInstance.pagination) || void 0 === a ? void 0 : a.update) || void 0 === s || s.call(a)) }, destroySwiper: function () { var e, t; this[r.AutoDestroy] && this.swiperInstance && this.swiperInstance.initialized && (null === (t = null === (e = this.swiperInstance) || void 0 === e ? void 0 : e.destroy) || void 0 === t || t.call(e, this[r.DeleteInstanceOnDestroy], this[r.CleanupStylesOnDestroy])) }, initSwiper: function () { this.swiperInstance = new e(this.$el, this.swiperOptions), c(this.swiperInstance, this.$emit.bind(this)), this.$emit(o.Ready, this.swiperInstance) } }, mounted: function () { this.swiperInstance || this.initSwiper() }, activated: function () { this.updateSwiper() }, updated: function () { this.updateSwiper() }, beforeDestroy: function () { this.$nextTick(this.destroySwiper) }, render: function (e) { return e("div", { staticClass: l.containerClass, on: { click: this.handleSwiperClick } }, [this.$slots[d.ParallaxBg], e("div", { class: this.wrapperClass }, this.$slots.default), this.$slots[d.Pagination], this.$slots[d.PrevButton], this.$slots[d.NextButton], this.$slots[d.Scrollbar]]) } }) } !function (e) { e.ParallaxBg = "parallax-bg", e.Pagination = "pagination", e.Scrollbar = "scrollbar", e.PrevButton = "button-prev", e.NextButton = "button-next" }(d || (d = {})); var w = n.extend({ name: i.SwiperSlideComponent, computed: { slideClass: function () { var e, t; return (null === (t = null === (e = this.$parent) || void 0 === e ? void 0 : e.swiperOptions) || void 0 === t ? void 0 : t.slideClass) || l.slideClass } }, methods: { update: function () { var e, t = this.$parent; t[r.AutoUpdate] && (null === (e = null == t ? void 0 : t.swiperInstance) || void 0 === e || e.update()) } }, mounted: function () { this.update() }, updated: function () { this.update() }, render: function (e) { return e("div", { class: this.slideClass }, this.$slots.default) } }), S = function (e) { var t = function (n, o) { if (!t.installed) { var r = h(e); o && (r.options.props.defaultOptions.default = function () { return o }), n.component(i.SwiperComponent, r), n.component(i.SwiperSlideComponent, w), n.directive(i.SwiperDirective, f(e, o)), t.installed = !0 } }; return t }; var y = function (e) { var t; return (t = { version: "4.1.1", install: S(e), directive: f(e) })[i.SwiperComponent] = h(e), t[i.SwiperSlideComponent] = w, t }(t), m = y.version, C = y.install, g = y.directive, I = y.Swiper, O = y.SwiperSlide; e.Swiper = I, e.SwiperSlide = O, e.default = y, e.directive = g, e.install = C, e.version = m, Object.defineProperty(e, "__esModule", { value: !0 }) });
-//# sourceMappingURL=/sm/20d7110b3463446defd51923206a17b7ff00b136457c620768cd77a8c5a24bfc.map
\ No newline at end of file
diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml
index e879c3049..12c15800c 100644
--- a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml
+++ b/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml
@@ -13,9 +13,11 @@
}
+@*a plain element to mount on: the view's outermost node is a Vue component*@
+