diff --git a/.ai/agents/admin-ui-specialist.md b/.ai/agents/admin-ui-specialist.md new file mode 100644 index 0000000000..18ce411e78 --- /dev/null +++ b/.ai/agents/admin-ui-specialist.md @@ -0,0 +1,15 @@ +--- +name: Admin UI Specialist +description: Make admin-facing changes affecting Main Admin, Store Owner, Vendor, shared admin models, permissions, navigation, validation, or scoped data access. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/admin-area-changes.md` for Main Admin, Store Owner, +Vendor, shared admin model, permission, navigation, validation, and scoped data access rules, and +`.ai/skills/template-creation.md` for Razor views, partials, and admin tag helper conventions. Run +`.ai/checklists/definition-of-done.md` before reporting the change complete. diff --git a/.ai/agents/architecture-reviewer.md b/.ai/agents/architecture-reviewer.md new file mode 100644 index 0000000000..1900dd3a63 --- /dev/null +++ b/.ai/agents/architecture-reviewer.md @@ -0,0 +1,15 @@ +--- +name: Architecture Reviewer +description: Review design, layering, dependency direction, module boundaries, public contracts, and maintainability. +tools: ["codebase", "search", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/architecture-review.md` for design, layering, +dependency, module boundary, and public contract review rules, and `.ai/knowledge/architecture.md` +for layering rules, DI lifetimes, mediator commands/queries, and domain events. Run +`.ai/checklists/code-review.md` before reporting the review complete. diff --git a/.ai/agents/bug-fixer.md b/.ai/agents/bug-fixer.md new file mode 100644 index 0000000000..7745cac041 --- /dev/null +++ b/.ai/agents/bug-fixer.md @@ -0,0 +1,16 @@ +--- +name: Bug Fixer +description: Diagnose and fix a broken behavior when the root cause is not yet known — reproduce, find the cause, choose a fix, assess risk, test, verify. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/workflows/fix-bug.md` step by step; it names the +skills and knowledge to load once the cause is located, and hands off to the matching prompt +for the fix itself. Run `.ai/checklists/definition-of-done.md`, and +`.ai/checklists/security.md` or `.ai/checklists/performance.md` when the bug touches those +areas, before reporting the fix complete. diff --git a/.ai/agents/dependency-upgrader.md b/.ai/agents/dependency-upgrader.md new file mode 100644 index 0000000000..74e3878d94 --- /dev/null +++ b/.ai/agents/dependency-upgrader.md @@ -0,0 +1,15 @@ +--- +name: Dependency Upgrader +description: Move a NuGet package, framework, or shared version, using central package management conventions. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/workflows/upgrade-dependency.md` step by step, using +`.ai/standards/dependencies.md` for central package management, shared MSBuild props, project +references, output paths, and SDK selection rules. Run `.ai/checklists/definition-of-done.md` +before reporting the upgrade complete. diff --git a/.ai/agents/dotnet-expert.md b/.ai/agents/dotnet-expert.md new file mode 100644 index 0000000000..c958d4c0e7 --- /dev/null +++ b/.ai/agents/dotnet-expert.md @@ -0,0 +1,16 @@ +--- +name: .NET Expert +description: Write or review idiomatic C#/.NET code — async/await, records, guard clauses, result objects, pattern matching, nullable types, and configuration binding. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/dotnet-review.md` for C#, .NET, ASP.NET Core, and +.NET test review rules, `.ai/knowledge/dotnet.md` for idioms, and `.ai/knowledge/async.md` for +async/await, `CancellationToken`, and `Task` vs `ValueTask` conventions. Run +`.ai/checklists/definition-of-done.md` before reporting the change complete. diff --git a/.ai/agents/migration-writer.md b/.ai/agents/migration-writer.md new file mode 100644 index 0000000000..4cf83d3814 --- /dev/null +++ b/.ai/agents/migration-writer.md @@ -0,0 +1,15 @@ +--- +name: Migration Writer +description: Add an upgrade migration that seeds or changes data for existing installations — settings, localization resources, permissions, admin sitemap entries, scheduled tasks, or document shape. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/prompts/add-migration.md` step by step, using +`.ai/skills/database-review.md` for migration, schema, and data integrity rules, and +`.ai/templates/migration.md` as the scaffold. Run `.ai/checklists/data-change.md` and +`.ai/checklists/definition-of-done.md` before reporting the migration complete. diff --git a/.ai/agents/mongodb-expert.md b/.ai/agents/mongodb-expert.md new file mode 100644 index 0000000000..52d1f7ab9d --- /dev/null +++ b/.ai/agents/mongodb-expert.md @@ -0,0 +1,16 @@ +--- +name: MongoDB Expert +description: Write or review MongoDB collections, filters, projections, indexes, repository queries, aggregations, updates, and data migrations. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/mongodb-review.md` for collections, filters, +projections, indexes, aggregations, updates, and data migration review rules, and +`.ai/knowledge/mongodb.md` for `IRepository` usage, query patterns, and partial update +conventions. Run `.ai/checklists/data-change.md` and `.ai/checklists/definition-of-done.md` +before reporting the change complete. diff --git a/.ai/agents/notification-specialist.md b/.ai/agents/notification-specialist.md new file mode 100644 index 0000000000..6d3bb52d80 --- /dev/null +++ b/.ai/agents/notification-specialist.md @@ -0,0 +1,16 @@ +--- +name: Notification Specialist +description: Work with message templates, DotLiquid tokens and drops, IMessageProviderService, queued email lifecycle, LiquidObjectBuilder, and domain event notification handlers. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/message-notification.md` for message templates, +DotLiquid tokens and drops, `IMessageProviderService`, queued email lifecycle, +`LiquidObjectBuilder`, the `MessageTokensAddedEvent` plugin extension point, and domain event +notification handler rules. Run `.ai/checklists/definition-of-done.md` before reporting the +change complete. diff --git a/.ai/agents/performance-investigator.md b/.ai/agents/performance-investigator.md new file mode 100644 index 0000000000..e5397910e2 --- /dev/null +++ b/.ai/agents/performance-investigator.md @@ -0,0 +1,16 @@ +--- +name: Performance Investigator +description: Diagnose slow behavior when the bottleneck is unknown — define, measure, explain, fix, re-measure. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/workflows/investigate-performance.md` step by step, using +`.ai/knowledge/performance.md` for `ICacheBase`, cache key constants, pagination, and partial +field write rules, and `.ai/knowledge/caching.md` for key composition and cross-family +invalidation. Run `.ai/checklists/performance.md` and `.ai/checklists/definition-of-done.md` +before reporting the fix complete. diff --git a/.ai/agents/permission-navigation-specialist.md b/.ai/agents/permission-navigation-specialist.md new file mode 100644 index 0000000000..50410de22e --- /dev/null +++ b/.ai/agents/permission-navigation-specialist.md @@ -0,0 +1,16 @@ +--- +name: Permission & Navigation Specialist +description: Add or change permissions, PermissionSystemName, PermissionActionName, StandardPermission, PermissionProvider, controller authorization attributes, AdminSiteMap entries, or permission/navigation migrations. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/permission-navigation.md` for permissions, +`PermissionSystemName`, `PermissionActionName`, `StandardPermission`, `PermissionProvider`, +controller authorization attributes, `AdminSiteMap` entries, and permission or navigation +migration rules. Run `.ai/checklists/data-change.md` and `.ai/checklists/definition-of-done.md` +before reporting the change complete. diff --git a/.ai/agents/plugin-creator.md b/.ai/agents/plugin-creator.md new file mode 100644 index 0000000000..8724cea823 --- /dev/null +++ b/.ai/agents/plugin-creator.md @@ -0,0 +1,17 @@ +--- +name: Plugin Creator +description: Scaffold a new installable GrandNode plugin (payment, shipping, tax, widget, external authentication, discount rule, exchange rate, or theme) end to end. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/prompts/create-plugin.md` step by step, using +`.ai/skills/plugin-module.md` (and the plugin-kind skill it points to, e.g. +`.ai/skills/plugin-payment.md`, `.ai/skills/plugin-shipping.md`, `.ai/skills/plugin-widget.md`, +`.ai/skills/plugin-discount-rules.md`) and `.ai/templates/plugin/base-plugin.md` as the scaffold. +Run `.ai/checklists/plugin-release.md` and `.ai/checklists/definition-of-done.md` before reporting +the plugin complete. diff --git a/.ai/agents/reviewer.md b/.ai/agents/reviewer.md new file mode 100644 index 0000000000..47e127975b --- /dev/null +++ b/.ai/agents/reviewer.md @@ -0,0 +1,16 @@ +--- +name: Reviewer +description: Review a pull request or diff against all applicable repository skills and checklists before it is accepted. +tools: ["codebase", "search", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/prompts/review-change.md` step by step: identify which +skills from `.ai/skills/` apply to the change, load relevant `.ai/knowledge/` context, and run +`.ai/checklists/code-review.md` plus any other checklist the change touches (`.ai/checklists/security.md`, +`.ai/checklists/performance.md`, `.ai/checklists/data-change.md`, `.ai/checklists/plugin-release.md`) +before reporting the review complete. diff --git a/.ai/agents/scheduled-task-writer.md b/.ai/agents/scheduled-task-writer.md new file mode 100644 index 0000000000..b198551e78 --- /dev/null +++ b/.ai/agents/scheduled-task-writer.md @@ -0,0 +1,15 @@ +--- +name: Scheduled Task Writer +description: Add scheduled task classes using IScheduleTask, AddKeyedScoped registration, the ScheduleTask seed, multi-instance distributed locking, error handling, and task migrations. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/scheduled-task.md` for `IScheduleTask`, +`AddKeyedScoped` registration, the `ScheduleTask` seed, multi-instance distributed locking, error +handling, and task migration rules. Run `.ai/checklists/data-change.md` and +`.ai/checklists/definition-of-done.md` before reporting the task complete. diff --git a/.ai/agents/security-reviewer.md b/.ai/agents/security-reviewer.md new file mode 100644 index 0000000000..9539258427 --- /dev/null +++ b/.ai/agents/security-reviewer.md @@ -0,0 +1,16 @@ +--- +name: Security Reviewer +description: Audit authentication, authorization, input handling, secrets, cryptography, sensitive data, and trust-boundary code. +tools: ["codebase", "search", "terminal"] +--- + + + +Read `AGENTS.md` first. Then read `.ai/skills/security-review.md` for authentication, +authorization, input handling, secrets, cryptography, and trust-boundary review rules, and +`.ai/knowledge/security.md` for authorization checks, FluentValidation patterns, guard clauses, +HTML encoding, and safe MongoDB queries. Run `.ai/checklists/security.md` before reporting the +review complete. diff --git a/.ai/agents/test-writer.md b/.ai/agents/test-writer.md new file mode 100644 index 0000000000..16b3bc43b4 --- /dev/null +++ b/.ai/agents/test-writer.md @@ -0,0 +1,15 @@ +--- +name: Test Writer +description: Add or extend unit tests for a change, using the MSTest + Moq conventions already in src/Tests/. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/prompts/write-tests.md` step by step, using +`.ai/knowledge/tests.md` for the MSTest + Moq patterns, test structure, validator testing, and +controller test setup. Run `.ai/checklists/definition-of-done.md` before reporting the tests +complete. diff --git a/.ai/agents/theme-builder.md b/.ai/agents/theme-builder.md new file mode 100644 index 0000000000..7d86f1b0af --- /dev/null +++ b/.ai/agents/theme-builder.md @@ -0,0 +1,16 @@ +--- +name: Theme Builder +description: Create a new storefront theme plugin, or override a subset of views in an existing theme, without forking the whole view tree. +tools: ["codebase", "search", "edit", "terminal"] +--- + + + +Read `AGENTS.md` first. Then follow `.ai/prompts/create-theme.md` step by step, using +`.ai/skills/theme-creation.md` for `IThemeView`, view-location fallback, and asset layout, and +`.ai/skills/frontend-bundle-workflow.md` for when to run the Vite build and how to commit bundle +output alongside source. Run `.ai/checklists/plugin-release.md` and +`.ai/checklists/definition-of-done.md` before reporting the theme complete. diff --git a/.ai/checklists/README.md b/.ai/checklists/README.md new file mode 100644 index 0000000000..8ff8b4085c --- /dev/null +++ b/.ai/checklists/README.md @@ -0,0 +1,19 @@ +# Checklists + +Cross-cutting gates. Skills carry their own domain checklists (a widget plugin's `Validation Checklist` belongs in the widget skill); these cover what no single skill owns. + +| Checklist | Run it | +|---|---| +| `definition-of-done.md` | Before calling any change complete | +| `code-review.md` | When reviewing someone else's diff, or your own before opening a PR | +| `security.md` | When the change touches auth, input, scoped data, secrets, or file handling | +| `performance.md` | When the change adds a query, a loop over entities, or a page render path | +| `data-change.md` | When the change touches entities, migrations, settings, or persisted identities | +| `plugin-release.md` | Before shipping a plugin or theme | + +## How to use + +1. Run `definition-of-done.md` on every change. Add the situational ones the change triggers. +2. A checklist item is a question to answer, not a box to tick by reflex. "Yes, because …" is the passing answer. +3. Report items that do not apply as N/A with a reason. Silence reads as an unchecked box. +4. An item you cannot verify is a risk to state in the PR, not an item to skip. diff --git a/.ai/checklists/code-review.md b/.ai/checklists/code-review.md new file mode 100644 index 0000000000..fce937e6b4 --- /dev/null +++ b/.ai/checklists/code-review.md @@ -0,0 +1,107 @@ +# Checklist: Code Review + +For reviewing a diff. Report only high-confidence findings, each with file, line, the rule violated, the failure it causes, and a minimal fix. + +Use with `.ai/prompts/review-change.md`, which handles skill selection. + +--- + +## Read the diff twice + +- [ ] First pass: does the change do what its description claims? +- [ ] Second pass: what did it change that the description does not mention? + +## Layering + +- [ ] No business logic in a controller. +- [ ] No view model in a business service. +- [ ] No MongoDB driver type outside `Grand.Data`. +- [ ] No HTTP or `IWorkContext` dependency in `Grand.Domain`. +- [ ] No core → plugin reference. +- [ ] Services registered in `IStartupApplication`, not `Program.cs`. + +## Scoping — the highest-yield section + +- [ ] Every new query filters by store. +- [ ] Vendor-reachable code filters by `VendorId` and re-checks ownership on write. +- [ ] `LimitedToStores` / `LimitedToGroups` honoured where the entity supports them. +- [ ] Every new cache key contains store id — and language id if the data is localized. +- [ ] Settings loaded and saved with the same store scope. +- [ ] No `IWorkContext` in a scheduled task, migration, or plugin install. + +## Duplication and reuse + +- [ ] The logic does not already exist in a service, extension, or helper. +- [ ] A near-copy of an existing method was not introduced instead of a parameter. +- [ ] A third repetition of the same block was extracted rather than added. +- [ ] Copy-pasted code had **every** identifier updated — stale names from the source are a classic defect. + +## Validation + +- [ ] Inputs crossing a trust boundary are validated. +- [ ] Validators cover the new fields, not just the old ones. +- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull`. +- [ ] Invalid model state does not partially save. + +## Exception handling + +- [ ] No empty `catch`, no `catch (Exception)` that continues silently. +- [ ] Expected business failures return results, not exceptions. +- [ ] Notification handlers cannot throw into the publisher. +- [ ] Migrations return `false` instead of throwing. +- [ ] Disposables are disposed. + +## Logging + +- [ ] Diagnosable failures are logged with store/entity/operation context. +- [ ] No secrets or personal data in log output. +- [ ] Log levels are proportionate. +- [ ] No logging in a hot loop. + +## Async + +- [ ] No `.Result`, `.Wait()`, `GetAwaiter().GetResult()`. +- [ ] No `async void`. +- [ ] No `Task.Run` wrapping synchronous work. +- [ ] `CancellationToken` forwarded where the surrounding signatures carry one. + +## Data lifecycle + +- [ ] Every write invalidates its cache prefix. +- [ ] Every write publishes its entity event. +- [ ] Cross-family caches that embed the entity are invalidated too. +- [ ] Migration `Identity` GUID is new. +- [ ] Migration is idempotent and destroys nothing operator-owned. +- [ ] No persisted identity renamed (plugin system name, permission name, template name, task name). + +## Localization + +- [ ] No hardcoded user-facing string. +- [ ] Resource keys follow the naming convention and are added **and** removed symmetrically in plugin install/uninstall. +- [ ] Localized entity properties read through the translation extension, not the raw property. + +## Frontend + +- [ ] Storefront data attributes preserved on touched views. +- [ ] Widget zones preserved. +- [ ] No `Html.Raw` on user content. +- [ ] Bundles rebuilt if source changed. + +## Tests + +- [ ] New behavior has tests, in the mirror project. +- [ ] Bug fixes have a regression test. +- [ ] No assertion weakened to make the suite pass. +- [ ] Tests mock at interface boundaries and hit no real database or network. + +## Backward compatibility + +- [ ] An existing installation upgrading in place still works. +- [ ] New settings default to previous behavior. +- [ ] No public interface, view model, or route changed without the PR saying so under "Breaking changes". + +## Before submitting the review + +- [ ] Every finding verified by opening the file — no speculative claims. +- [ ] Findings ranked by severity, not by file order. +- [ ] Style and preference comments omitted unless a mandatory rule is broken. diff --git a/.ai/checklists/data-change.md b/.ai/checklists/data-change.md new file mode 100644 index 0000000000..0bc937f9d7 --- /dev/null +++ b/.ai/checklists/data-change.md @@ -0,0 +1,80 @@ +# Checklist: Data Change + +Run when the change touches a domain entity, a migration, settings, localization resources, permissions, or any persisted identity. + +The question behind every item: **what happens to a store that already has data and is running the previous version?** + +--- + +## Entity changes + +- [ ] New entity derives from `BaseEntity` (top-level) or `SubBaseEntity` (embedded). +- [ ] The marker interfaces the feature needs are implemented: `IStoreLinkEntity`, `IGroupLinkEntity`, `ISlugEntity`, `ITranslationEntity`. +- [ ] A new field has a default that makes existing documents behave as they did before. +- [ ] A removed or renamed field has a migration, or the old data is knowingly abandoned and the PR says so. +- [ ] No UI concern, persistence detail, or infrastructure dependency added to `Grand.Domain`. +- [ ] Naming matches `.ai/glossary/` — the domain word, not the nopCommerce word. + +## Migration + +- [ ] `Identity` is a freshly generated GUID, unique across the repository. +- [ ] `Version` matches the folder and the shipping release. +- [ ] `Priority` orders it correctly against the other migrations in that version. +- [ ] `UpgradeProcess` cannot throw — it catches and returns `false`. +- [ ] Running it twice is a no-op. +- [ ] It does not overwrite or delete anything an operator may have customized. +- [ ] A new version folder has its `MigrationUpgradeDbVersion_{version}` class. +- [ ] It does not read `IWorkContext` — there is no ambient context. + +## Settings + +- [ ] New settings class implements `ISettings`. +- [ ] Defaults preserve pre-upgrade behavior. +- [ ] A migration seeds the setting for existing installations. +- [ ] Store scope handled: loaded and saved with the same scope. +- [ ] System-wide fields are preserved when saving a store-scoped copy. +- [ ] `ClearCache()` called after saving. + +## Localization + +- [ ] Every new user-facing string has a resource key. +- [ ] Key naming follows `.ai/standards/naming.md`. +- [ ] Core resources ship through `App_Data/Resources/Upgrade/en_{version}.xml` plus an import migration. +- [ ] Plugin resources are added in `Install()` and **every one of them** removed in `Uninstall()`. +- [ ] Admin fields have both a label and a `.Hint` key. + +## Permissions and navigation + +- [ ] New permission registered in the `PermissionProvider`. +- [ ] Migration adds it for existing installations. +- [ ] Controllers enforce it via `[PermissionAuthorize]`. +- [ ] Admin sitemap entry added, with a migration, if the feature needs navigation. + +## Persisted identities — never rename + +- [ ] Plugin `SystemName` unchanged. +- [ ] Provider `SystemName` unchanged. +- [ ] `PermissionSystemName` unchanged. +- [ ] Message template name unchanged. +- [ ] `ScheduleTaskName` unchanged (and still equal to its DI key). +- [ ] Setting key unchanged. + +Renaming any of these orphans data in every installation. If a rename is genuinely required, it needs a migration that moves the old records, and a "Breaking changes" entry. + +## Caching and events + +- [ ] Reads of the new data are cached with a key containing store id (and language id where localized). +- [ ] Every write invalidates the prefix. +- [ ] Every write publishes the entity event. +- [ ] Other cached families that embed this entity are invalidated. + +## Indexes and query shape + +- [ ] New query patterns are supported by an index, or the omission is deliberate and stated. +- [ ] No unbounded query over a collection that grows with orders or customers. + +## Verification + +- [ ] Tested against a database that already has data, not only a fresh install. +- [ ] The upgrade path was reasoned about explicitly, and the reasoning is in the PR. +- [ ] Rollback consequences stated: what an operator does if this change is wrong. diff --git a/.ai/checklists/definition-of-done.md b/.ai/checklists/definition-of-done.md new file mode 100644 index 0000000000..e977d585ed --- /dev/null +++ b/.ai/checklists/definition-of-done.md @@ -0,0 +1,77 @@ +# Checklist: Definition of Done + +Run on every change before reporting it complete. + +--- + +## Scope + +- [ ] The change does what was asked — no less. +- [ ] The change does **only** what was asked; unrelated refactors and formatting churn are out. +- [ ] Anything deliberately left out is stated explicitly, with the reason. + +## Correctness + +- [ ] The happy path was exercised, not just compiled. +- [ ] Boundary cases considered: empty collection, null, zero quantity, first page, last page. +- [ ] Store scope applied to every query and every cache key the change touches. +- [ ] Vendor scope applied where a vendor could reach the code. +- [ ] Behavior verified for the product types / order statuses / payment flows the change affects, or the untested ones named. + +## Code quality + +- [ ] No duplicated logic — the closest existing helper, service, or extension was reused. If similar code exists in three places now, that is a finding. +- [ ] Follows the closest existing file's structure, naming, and idiom. +- [ ] Naming matches `.ai/standards/naming.md` and the domain vocabulary in `.ai/glossary/`. +- [ ] No constraint from `.ai/constraints.md` violated. +- [ ] Dead code, commented-out code, and debug output removed. + +## Validation + +- [ ] Every input that crosses a trust boundary is validated — FluentValidation for models, guard clauses for service arguments. +- [ ] Invalid model state re-renders rather than partially saving. +- [ ] Server-side ownership re-checked for any id that arrived in a request. + +## Exception handling + +- [ ] Expected failures return result objects, not exceptions. +- [ ] No empty `catch`. +- [ ] Nothing catches broadly and continues as if it succeeded. +- [ ] Notification handlers and migrations cannot throw into their caller. +- [ ] Resources that need disposing are in `using` blocks. + +## Logging + +- [ ] Failures that an operator would need to diagnose are logged, with enough context to identify the store, entity, and operation. +- [ ] No secrets, tokens, passwords, card data, or full personal records in log messages. +- [ ] Log levels are honest: `Error` for something broken, `Warning` for something suspicious, not everything at `Information`. +- [ ] No logging inside a hot loop. + +## Data safety + +- [ ] Every write invalidates the affected cache prefixes. +- [ ] Every write publishes its entity event. +- [ ] New settings default to the pre-change behavior. +- [ ] New user-facing strings exist as localization resources. +- [ ] New permissions have a provider entry and a migration. +- [ ] An existing installation upgrading in place still works. + +## Tests + +- [ ] Tests added for new behavior, in the mirror test project. +- [ ] For a bug fix: a test that failed before the fix and passes after. +- [ ] No existing assertion weakened or deleted to make the suite pass. +- [ ] The affected test project runs green. + +## Build and delivery + +- [ ] The affected projects build. +- [ ] Frontend bundles rebuilt and committed if frontend source changed. +- [ ] Plugin/theme output path verified for Debug **and** Release, if applicable. +- [ ] PR follows `.ai/standards/git-and-pr.md`, with a truthful "Breaking changes" section. + +## Reporting + +- [ ] Commands actually run are listed, with results. +- [ ] Commands that could not be run are named. +- [ ] Remaining risk is stated plainly — not omitted because it is small. diff --git a/.ai/checklists/performance.md b/.ai/checklists/performance.md new file mode 100644 index 0000000000..a5fe735997 --- /dev/null +++ b/.ai/checklists/performance.md @@ -0,0 +1,65 @@ +# Checklist: Performance + +Run when the change adds a query, iterates over entities, touches a page-render path, or changes caching. + +Complementary to `.ai/knowledge/performance.md` and `.ai/knowledge/caching.md`. + +--- + +## Queries + +- [ ] No query inside a loop. Fetch the set once and join in memory, or push the filter into the query. +- [ ] Filtering, sorting, and paging happen in the query — not after `ToList()`. +- [ ] The query projects the fields it needs when the entity is large and only a few are used. +- [ ] List endpoints are paged. An unbounded list over a growing collection is a future outage. +- [ ] Existence checks use a count or an any-style query, not "load everything and check `Count`". +- [ ] New query shapes are supported by an index, or the absence of one is stated deliberately. + +## Writes + +- [ ] Updating a few fields uses a partial update rather than rewriting the whole document. +- [ ] Bulk operations are batched rather than issued one document at a time. +- [ ] No read-modify-write loop that could be a single update. + +## Caching + +- [ ] Data that is read far more than written, and is expensive to produce, is cached. +- [ ] The cache key contains every variable that changes the result — store, language, currency, customer group, vendor, page. +- [ ] Cached values are invalidated on **every** write path, including delete. +- [ ] Cross-family caches that embed this data are invalidated too. +- [ ] Nothing customer-specific is cached under a key that omits the customer. +- [ ] `Clear()` is not used to fix a stale entry — it evicts every store's cache. +- [ ] Caching sits in the business service, not in a controller or handler. + +## Render path + +- [ ] No repository or service call from a Razor view. +- [ ] A view component that loads data is not invoked inside a loop over products. +- [ ] View-model preparation happens once in the handler, not per item. +- [ ] Images carry `loading` and dimension attributes as the surrounding views do. +- [ ] No new blocking external HTTP call on a page-render path. If one is unavoidable, it has a timeout and a fallback. + +## Async and concurrency + +- [ ] Nothing blocks on a `Task`. +- [ ] Independent awaits that could run concurrently are not serialized in a loop when the underlying calls are safe to parallelize. +- [ ] Long work is moved to a scheduled task rather than run inside a request. +- [ ] Notification handlers are fast — they run inline in the write path. + +## Allocation + +- [ ] No repeated string concatenation in a loop where a builder is available. +- [ ] Large collections are not copied repeatedly between list types. +- [ ] Nothing large is held in a singleton or static field. + +## Scale assumptions + +- [ ] The change was reasoned about with a realistic catalog: tens of thousands of products, not ten. +- [ ] Behavior under multiple stores considered — per-store caching multiplies memory. +- [ ] Behavior under multiple application instances considered — cache invalidation must propagate. + +## Evidence + +- [ ] Any optimization that costs readability is justified by a measurement, not intuition. +- [ ] The measurement, or its absence, is stated in the PR. +- [ ] Nothing was optimized speculatively at the expense of clarity. diff --git a/.ai/checklists/plugin-release.md b/.ai/checklists/plugin-release.md new file mode 100644 index 0000000000..989e13a396 --- /dev/null +++ b/.ai/checklists/plugin-release.md @@ -0,0 +1,74 @@ +# Checklist: Plugin and Theme Release + +Run before shipping a new plugin or theme, or a version of one. + +Domain rules live in the per-kind skills; this is the packaging and lifecycle gate. + +--- + +## Identity + +- [ ] `SystemName` identical in `Manifest.cs`, `{Feature}Defaults`, the provider, and the output folder name. +- [ ] `SystemName` follows `{Group}.{Name}`, and `Group` is an existing group value. +- [ ] `SystemName` unchanged from the previously shipped version — it is the persisted identity. +- [ ] `Version` in the manifest bumped. +- [ ] `FriendlyName` in `{Feature}Defaults` is a **resource key**, and the provider resolves it through `ITranslationService`. + +## Project + +- [ ] Imports `..\..\Build\Grand.Common.props`. +- [ ] Correct SDK: `Microsoft.NET.Sdk.Razor` with `AddRazorSupportForMvc=true` and `StaticWebAssetsEnabled=false` if it has views; `Microsoft.NET.Sdk` otherwise. +- [ ] Output path set for **both** Debug and Release, to `Grand.Web/Plugins/{SystemName}/`. +- [ ] All GrandNode project references `false`, with `ExcludeAssets` matching the nearest comparable plugin. +- [ ] Package references carry no inline version. +- [ ] Added to `GrandNode.slnx`. +- [ ] `logo.jpg` present and copied to output. +- [ ] Themes only: `Content/theme.jpg` present, and `Content/**` copied with `PreserveNewest`. + +## Registration + +- [ ] `IStartupApplication` registers the provider(s) with the right lifetime. +- [ ] `Priority` matches the convention for comparable plugins. +- [ ] `Configure` is empty unless the plugin owns middleware or endpoints. +- [ ] Nothing was added to `Program.cs` or to a core project on the plugin's behalf. + +## Install and uninstall + +- [ ] `Install()` saves default settings and adds every resource key, then calls `base.Install()` **last**. +- [ ] `Uninstall()` deletes settings and removes **every** key `Install()` added, then calls `base.Uninstall()` last. +- [ ] The two lists were diffed against each other, key by key. +- [ ] Install and uninstall touch nothing outside the plugin's own settings and resources. +- [ ] Install → uninstall → install leaves the store in its original state. + +## Configuration screen + +- [ ] `ConfigurationUrl` matches the admin controller's actual route. +- [ ] `[AuthorizeAdmin]`, `[Area("Admin")]`, `[PermissionAuthorize(...)]` all present. +- [ ] Store scope from `IAdminStoreService.GetActiveStore()` used on both GET and POST. +- [ ] `LoadSetting` before mutation, so untouched fields are not reset. +- [ ] `ClearCache()` after save. +- [ ] Invalid model state re-renders instead of saving. +- [ ] `_ViewImports.cshtml` and `_ViewStart.cshtml` present under the plugin's view folders. + +## Behavior + +- [ ] `LimitedToStores` and `LimitedToGroups` behave as documented. +- [ ] `Priority` is driven by a `DisplayOrder` setting the operator controls. +- [ ] Themes only: `GetViewLocations()` ends with the two default fallbacks, and `ThemeName` matches the `Views/` folder. +- [ ] Widgets only: zone names match the strings actually used in the target views. +- [ ] Consent gating present where the plugin injects third-party scripts. + +## Verification in a running store + +- [ ] Build output lands in `Grand.Web/Plugins/{SystemName}/` for Release as well as Debug. +- [ ] Plugin appears in the admin plugin list with its logo and friendly name. +- [ ] Install succeeds on a store that has existing data. +- [ ] Configuration screen loads, saves, and the saved value takes effect on the storefront. +- [ ] The feature works on a second store with different settings. +- [ ] Uninstall succeeds and leaves no orphaned settings or resources. + +## Documentation + +- [ ] PR states what the plugin does, which providers it registers, and which settings it adds. +- [ ] Breaking changes stated truthfully — including any renamed system name or removed setting. +- [ ] Themes only: the list of copied views and the upstream revision they were copied at. diff --git a/.ai/checklists/security.md b/.ai/checklists/security.md new file mode 100644 index 0000000000..de65afc7fa --- /dev/null +++ b/.ai/checklists/security.md @@ -0,0 +1,77 @@ +# Checklist: Security + +Run when the change touches authentication, authorization, user input, scoped data, secrets, payments, or file handling. + +Complementary to `.ai/skills/security-review.md` (procedure) and `.ai/knowledge/security.md` (patterns). + +--- + +## Authorization + +- [ ] Every admin controller carries `[AuthorizeAdmin]`, `[Area("...")]`, and `[PermissionAuthorize(...)]`. +- [ ] The permission used is the right one — not a broader permission that happened to be at hand. +- [ ] New actions on an existing controller are covered by the class-level attribute, or carry their own. +- [ ] Authorization is enforced in the controller, not only by hiding a link in the view. +- [ ] A new permission has a `PermissionProvider` entry **and** a migration, so existing installations receive it. + +## Trust boundaries + +- [ ] Ids arriving in a request (`storeId`, `vendorId`, `customerId`, entity ids) are re-checked against server-side context before any write. +- [ ] A vendor cannot read or modify another vendor's records by changing an id. +- [ ] A customer cannot reach another customer's orders, addresses, downloads, or documents. +- [ ] A store owner cannot reach another store's data. +- [ ] Mass-assignment is bounded — the bound model does not expose fields the caller must not set. + +## Input handling + +- [ ] Every model crossing the boundary has a validator covering the new fields. +- [ ] Guard clauses on public service methods. +- [ ] No query built by concatenating user input. +- [ ] Uploaded files: extension and content type checked, size bounded, filename not used as a path. +- [ ] Redirect targets are validated — no open redirect from a returned URL parameter. + +## Output + +- [ ] User-supplied content is encoded by default. +- [ ] `Html.Raw` is used only on operator-authored or already-sanitized content. +- [ ] Error messages returned to the customer do not disclose internal paths, ids, or stack traces. +- [ ] Database-sourced HTML that may contain `{{ }}` is `v-pre`-guarded so it is not compiled as a Vue template. + +## Secrets and sensitive data + +- [ ] No credential, API key, connection string, or pepper committed. +- [ ] Secrets read from configuration, not constants. +- [ ] No secret, token, password, card data, or full personal record in a log message or an exception message. +- [ ] Personal data exposure respects the customer's consent settings where the feature is consent-gated. + +## Authentication + +- [ ] Password handling goes through the central verification path — no ad-hoc hashing or comparison. +- [ ] Failed login returns a result value and does not disclose whether the account exists. +- [ ] Session, cookie, and two-factor behavior unchanged unless that is the point of the change. +- [ ] External authentication tokens are not logged or persisted beyond what the flow requires. + +## Web hygiene + +- [ ] Forms and AJAX mutations carry antiforgery tokens. +- [ ] State-changing endpoints are POST, not GET. +- [ ] No third-party script added without a consent gate where one is required. +- [ ] No external CDN reference introduced in a storefront view. + +## Payments + +- [ ] No card data stored or logged. +- [ ] Amounts are re-derived server-side, never taken from the request. +- [ ] Provider callbacks verify their signature or shared secret before acting. +- [ ] A repeated callback cannot double-apply a payment. + +## Plugins + +- [ ] A plugin cannot escalate privilege through the services it registers. +- [ ] `Install()` / `Uninstall()` do not touch data outside the plugin's own settings and resources. +- [ ] Provider `LimitedToStores` / `LimitedToGroups` are honoured by consumers of the provider. + +## Verification + +- [ ] Each finding here was confirmed by reading the code, not inferred from a name. +- [ ] Anything that could not be verified is stated as unverified in the PR. diff --git a/.ai/constraints.md b/.ai/constraints.md new file mode 100644 index 0000000000..c699a5fa1d --- /dev/null +++ b/.ai/constraints.md @@ -0,0 +1,161 @@ +# Constraints + +Hard prohibitions. Unlike `.ai/principles.md`, these are not judgment calls — a violation is a defect, and a reviewer should reject it without debating trade-offs. + +Each entry states the prohibition, the reason, and what to do instead. When a constraint has a legitimate exception, the exception is listed; there are no unlisted exceptions. + +--- + +## Dependencies + +### Never add a NuGet package version in a `.csproj` +Central package management is on (`ManagePackageVersionsCentrally=true`). An inline version is a build error, not a style issue. +→ Add `` to `Directory.Packages.props`, reference without a version. + +### Never add a package for a capability the repository already has +`Grand.Mediator`, FluentValidation, `Grand.Mapping`, `MongoDB.Driver`, `StackExchange.Redis`, DotLiquid, Scryber, ImageSharp, MailKit, Scrutor are already present. Adding a second library for the same job splits the codebase. +→ See the table in `.ai/standards/dependencies.md`. + +### Never use Newtonsoft.Json +It is not referenced anywhere. `System.Text.Json` is the serializer. +→ `System.Text.Json`, or MessagePack where the existing code uses it. + +### Never use AutoMapper +The package is **not** referenced, despite `Grand.Mapping` exposing an AutoMapper-compatible `Profile` / `CreateMap` / `ForMember` API. Adding it would silently shadow the in-house mapper. +→ `Grand.Mapping`, via an `IAutoMapperProfile` implementation. + +### Never reference a plugin from core, business, or web projects +Dependencies point inward. A core project that knows a plugin exists cannot be built without it. +→ Define an interface in core; the plugin registers an implementation. + +--- + +## Async + +### Never block on a `Task` +No `.Result`, no `.Wait()`, no `.GetAwaiter().GetResult()` in request or service code. Under load this deadlocks or exhausts the thread pool. +→ `await` all the way down. The handful of existing occurrences are in startup paths and are not a precedent. + +### Never write `async void` +There are zero in the codebase. An exception in an `async void` method cannot be caught by the caller and takes down the process. +→ `async Task`. For event handlers, use `INotificationHandler` which is already `Task`-returning. + +### Never use `Task.Run` to make sync code look async +It moves work to another thread without removing the blocking, and loses the request context. +→ Make the underlying call async, or leave it synchronous. + +### Never swallow a `CancellationToken` +If the surrounding signatures carry one, forward it. + +--- + +## Data and persistence + +### Never inject `IMongoDatabase` or a Mongo collection into a business service +It ties the business layer to the driver and makes the service untestable. +→ `IRepository`. Mongo-specific behavior belongs in `Grand.Data`. + +### Never build a query from string concatenation of user input +→ Typed LINQ over `IRepository.Table`, or the repository's filter helpers. + +### Never filter scoped data in memory after materializing it +Loading every store's records and filtering in C# is both a performance defect and a leak waiting for the filter to be dropped. +→ Filter in the query. See `.ai/knowledge/scoping.md`. + +### Never write an entity without invalidating its cache and publishing its event +A cached read that survives its write serves stale — sometimes deleted — data. +→ `RemoveByPrefix(CacheKey.*_PATTERN_KEY)` then `_mediator.EntityInserted/Updated/Deleted`. See `.ai/examples/cached-store-scoped-service.md`. + +### Never omit a result-changing variable from a cache key +Store id, language id, customer group, currency, vendor id, page index. A missing store id is a cross-store data leak. + +### Never reuse a migration `Identity` GUID +The runner uses it to record what already ran; a duplicate means one migration silently never executes. +→ Generate a new GUID. + +### Never let a migration throw +It aborts the whole upgrade. +→ Catch and return `false`. + +--- + +## Time, culture, and formatting + +### Never use `DateTime.Now` +Stores span time zones; the server's local time is meaningless. The codebase uses `DateTime.UtcNow` almost everywhere (189 occurrences against 7). +→ `DateTime.UtcNow`, converted for display only. + +### Never format or parse machine-readable values with the current culture +Prices, ids, and stored strings must not depend on the request's culture. +→ `CultureInfo.InvariantCulture` for machine-readable values; the working culture only for display. + +### Never hardcode a user-facing string +→ A translation resource, added in `Install()` or an upgrade XML, read via `ITranslationService` / `@Loc[...]`. + +--- + +## Web layer + +### Never put business logic in a controller +→ A `Grand.Mediator` command or query handler. + +### Never trust an id from a request +A posted `vendorId`, `storeId`, or `customerId` is attacker-controlled. +→ Re-check ownership against `IWorkContext` / the resolved store, server-side, before writing. + +### Never omit the authorization attribute on an admin controller +`[AuthorizeAdmin]`, `[Area("...")]`, and `[PermissionAuthorize(...)]` together. A missing permission attribute leaves the screen open to any admin. + +### Never register services in `Program.cs` +→ `IStartupApplication.ConfigureServices` in the owning project. + +### Never read `IWorkContext` from a scheduled task, migration, or plugin `Install()` +There is no request, so there is no ambient context — it is null or stale. +→ Take store, customer, and language as explicit parameters. + +### Never `Html.Raw` user-supplied content +→ Encode by default; `Html.Raw` only for content already sanitized or authored by an operator. + +### Never remove a widget zone from a view +It silently disables every installed widget on that page and is a breaking change for third-party plugins. + +--- + +## Reflection and dynamic code + +### Never add ad-hoc reflection +Reflection in this repository is deliberately confined to the infrastructure that owns it: `Grand.Infrastructure.TypeSearch` (assembly scanning for `IStartupApplication`, providers, mapper profiles, validators), plugin loading, and `Grand.Infrastructure.Roslyn`. That is a platform mechanism, not a general licence. + +New feature code must not use `Activator.CreateInstance`, `GetType().GetProperty(...)`, or `Type.GetType(name)` to reach behavior that a DI registration or an interface could express. +→ Register an implementation and inject the interface. If discovery is genuinely needed, use `ITypeSearcher` rather than writing new scanning. + +### Never use `dynamic` +It defers every error to runtime and defeats every tool. + +--- + +## Structure and hygiene + +### Never introduce static mutable state +It breaks multi-store isolation and makes tests order-dependent. +→ A scoped service. + +### Never change a shipped plugin `SystemName`, permission system name, message template name, or schedule task name +They are persisted identities. Renaming one orphans existing data in every installation. + +### Never weaken or delete a failing assertion to make a suite pass +→ Find out why it now fails. + +### Never leave commented-out code, `#region`, or a `TODO` without an issue number + +### Never commit `obj/`, `bin/`, `TestResults/`, `.vs/`, or `.idea/` +Generated frontend bundles **are** committed, alongside the source that produced them. + +### Never edit a generated bundle by hand +→ Change the source and rebuild. See `.ai/skills/frontend-bundle-workflow.md`. + +--- + +## When a constraint blocks legitimate work + +Say so explicitly in the PR, explain why the alternative does not work, and get agreement before violating it. A documented, argued exception is fine. A silent one is not. diff --git a/.ai/examples/README.md b/.ai/examples/README.md new file mode 100644 index 0000000000..9a7dcb66f9 --- /dev/null +++ b/.ai/examples/README.md @@ -0,0 +1,13 @@ +# Examples + +Worked walkthroughs of code that already ships. Each one follows a single feature all the way through the layers, so the rules in `.ai/knowledge/` and `.ai/standards/` can be seen applied rather than only stated. + +| Example | Shows | +|---|---| +| `cached-store-scoped-service.md` | The canonical business-service shape: read-through cache, store scope, invalidation, entity events | +| `payment-plugin-walkthrough.md` | A complete plugin, file by file, from manifest to admin screen | +| `theme-override-walkthrough.md` | How `Theme.Modern` overrides a subset of views and what falls through | + +Every example names the real files it is drawn from. When an example and the shipped code disagree, the shipped code is correct — fix the example. + +These are illustrations, not templates. For copy-ready skeletons use `.ai/templates/`. diff --git a/.ai/examples/cached-store-scoped-service.md b/.ai/examples/cached-store-scoped-service.md new file mode 100644 index 0000000000..16dd579613 --- /dev/null +++ b/.ai/examples/cached-store-scoped-service.md @@ -0,0 +1,135 @@ +# Example: Cached, Store-Scoped Business Service + +Source: `src/Business/Grand.Business.Catalog/Services/Tax/TaxCategoryService.cs` + +This is the canonical shape of a GrandNode business service. Almost every service in `Grand.Business.*` is a variation on it. Read it alongside `.ai/knowledge/caching.md`, `.ai/knowledge/scoping.md`, and `.ai/knowledge/domain-events.md`. + +--- + +## Dependencies + +```csharp +public class TaxCategoryService : ITaxCategoryService +{ + private readonly IRepository _taxCategoryRepository; + private readonly IMediator _mediator; + private readonly ICacheBase _cacheBase; + + public TaxCategoryService(ICacheBase cacheBase, + IRepository taxCategoryRepository, + IMediator mediator) + { + _cacheBase = cacheBase; + _taxCategoryRepository = taxCategoryRepository; + _mediator = mediator; + } +} +``` + +Three dependencies, and they are the three a service of this kind almost always has: **repository abstraction**, **cache**, **mediator**. No `IMongoDatabase`, no `IServiceProvider`, no `IWorkContext` — the store id arrives as a parameter. + +The class is registered `AddScoped` in the owning project's `IStartupApplication`. + +## Read: cache wraps the query, store id is in the key + +```csharp +public virtual async Task> GetAllTaxCategories(string storeId = "") +{ + var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); + return await _cacheBase.GetAsync(key, async () => + { + var query = _taxCategoryRepository.Table.AsQueryable(); + if (!string.IsNullOrEmpty(storeId)) + query = query.Where(tc => tc.StoreId == storeId || string.IsNullOrEmpty(tc.StoreId)); + return await Task.FromResult(query.OrderBy(tc => tc.DisplayOrder).ToList()); + }); +} +``` + +Four things at once: + +1. **The key constant carries the parameter.** `TAXCATEGORIES_ALL_KEY` is `"Grand.taxcategory.all-{0}"`, documented in `CommonCacheKey.cs` with `{0} : store ID (empty = all stores)`. +2. **The store id is part of the key.** Omit it and store A serves store B's tax categories. This is a data leak, not a cache miss. +3. **`GetAsync` is read-through** — the delegate only runs on a miss. There is no separate `SetAsync`. +4. **Scoping is applied in the query**, not after materialization: records with an empty `StoreId` are global and visible to every store; the rest match the current store. + +The by-id read has the same shape with a different parameter: + +```csharp +public virtual Task GetTaxCategoryById(string taxCategoryId) +{ + var key = string.Format(CacheKey.TAXCATEGORIES_BY_ID_KEY, taxCategoryId); + return _cacheBase.GetAsync(key, () => _taxCategoryRepository.GetByIdAsync(taxCategoryId)); +} +``` + +Note it returns `Task` directly without `async`/`await` — a pure pass-through, per `.ai/standards/csharp-style.md`. + +## Write: guard, write, invalidate, publish — in that order + +```csharp +public virtual async Task InsertTaxCategory(TaxCategory taxCategory) +{ + ArgumentNullException.ThrowIfNull(taxCategory); + + await _taxCategoryRepository.InsertAsync(taxCategory); + + await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + + //event notification + await _mediator.EntityInserted(taxCategory); +} +``` + +The order is the contract: + +1. `ArgumentNullException.ThrowIfNull` — the built-in helper, not a hand-written null check. +2. Repository write. +3. `RemoveByPrefix` with the `*_PATTERN_KEY` constant, which clears every key in the family regardless of which store ids happen to be cached. +4. `EntityInserted` — published **after** the write succeeded, through the `IMediator` extension rather than by constructing the notification. + +`UpdateTaxCategory` and `DeleteTaxCategory` are identical in structure with `EntityUpdated` / `EntityDeleted`. All three invalidate. A service that caches on read but forgets to invalidate on delete serves deleted records until the entry expires. + +## Cross-family invalidation + +Delete does one extra thing: + +```csharp +await _taxCategoryRepository.DeleteAsync(taxCategory); + +//clear tax categories cache +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + +//clear product cache +await _cacheBase.RemoveByPrefix(CacheKey.PRODUCTS_PATTERN_KEY); + +//event notification +await _mediator.EntityDeleted(taxCategory); +``` + +Products embed a tax category, so cached product projections are stale the moment a tax category disappears. **When you add a cached family, ask what else embeds this data in a cached projection, and clear those prefixes too.** This is the step most often missed in review. + +## `virtual` methods + +Every public method is `virtual`. That is deliberate — plugins replace core services by registering their own implementation, and derived implementations override individual methods. Keep new service methods `virtual` for consistency with the surrounding code. + +## What is deliberately absent + +| Not here | Where it belongs | +|---|---| +| View models | `Grand.Web/Models` + a mediator handler | +| `IWorkContext` | the caller — the service takes `storeId` explicitly | +| MongoDB filters and collections | inside `IRepository` | +| Authorization checks | the controller's `[PermissionAuthorize]` attribute | +| Try/catch around the write | nothing here is an expected failure | + +## Checklist when writing a service like this + +- [ ] Constructor takes `IRepository`, `ICacheBase`, `IMediator` — nothing infrastructural. +- [ ] Every cached read uses a `CacheKey` constant with every result-changing parameter formatted in. +- [ ] Store scope is applied inside the query. +- [ ] Every write invalidates with `RemoveByPrefix` and the `*_PATTERN_KEY` constant. +- [ ] Every write publishes the matching entity event, after the write. +- [ ] Cross-family caches that embed this entity are invalidated too. +- [ ] Guard clauses use `ArgumentNullException.ThrowIfNull`. +- [ ] Methods are `virtual`. diff --git a/.ai/examples/payment-plugin-walkthrough.md b/.ai/examples/payment-plugin-walkthrough.md new file mode 100644 index 0000000000..9016154559 --- /dev/null +++ b/.ai/examples/payment-plugin-walkthrough.md @@ -0,0 +1,211 @@ +# Example: Payment Plugin, File by File + +Source: `src/Plugins/Payments.CashOnDelivery/` + +The smallest complete GrandNode plugin that still has every part: manifest, defaults, settings, provider, plugin lifecycle, DI registration, storefront controller, and an admin configuration screen. Use it as the reference when scaffolding any plugin, not just payment ones. + +Read `.ai/skills/plugin-payment.md` for the payment-specific contract, and `.ai/templates/plugin/` for the copy-ready skeleton. + +--- + +## The tree + +``` +Payments.CashOnDelivery/ + Payments.CashOnDelivery.csproj + Manifest.cs + CashOnDeliveryPaymentDefaults.cs + CashOnDeliveryPaymentSettings.cs + CashOnDeliveryPaymentProvider.cs + CashOnDeliveryPaymentPlugin.cs + StartupApplication.cs + EndpointProvider.cs + logo.jpg + Controllers/PaymentCashOnDeliveryController.cs ← storefront + Models/ConfigurationModel.cs + Models/PaymentInfoModel.cs + Views/ + Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs + Areas/Admin/Views/_ViewImports.cshtml + Areas/Admin/Views/_ViewStart.cshtml + Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml +``` + +Note the two controllers with the same name in different namespaces — one storefront, one admin. That is the convention, not an accident. + +## 1. Identity lives in one place + +```csharp +public static class CashOnDeliveryPaymentDefaults +{ + public const string ProviderSystemName = "Payments.CashOnDelivery"; + public const string FriendlyName = "Payments.CashOnDelivery.FriendlyName"; + public const string ConfigurationUrl = "/Admin/PaymentCashOnDelivery/Configure"; +} +``` + +Three constants, referenced everywhere else: + +- `ProviderSystemName` → the manifest's `SystemName`, the provider's `SystemName`, and the output folder. All three must agree; the system name is the plugin's persisted identity and cannot change after release. +- `FriendlyName` is a **resource key**, not a display string. The provider resolves it through `ITranslationService`. +- `ConfigurationUrl` must match the admin controller's actual route, or the admin's *Configure* link 404s. + +The manifest points back at the constant: + +```csharp +[assembly: PluginInfo( + FriendlyName = "Cash On Delivery (COD)", + Group = "Payment methods", + SystemName = CashOnDeliveryPaymentDefaults.ProviderSystemName, + Author = "grandnode team", + Version = "1.0.0")] +``` + +## 2. The provider: `IProvider` members first + +```csharp +public class CashOnDeliveryPaymentProvider : IPaymentProvider +{ + public CashOnDeliveryPaymentProvider( + ITranslationService translationService, + IHttpContextAccessor httpContextAccessor, + CashOnDeliveryPaymentSettings cashOnDeliveryPaymentSettings) + { … } + + public string ConfigurationUrl => CashOnDeliveryPaymentDefaults.ConfigurationUrl; + public string SystemName => CashOnDeliveryPaymentDefaults.ProviderSystemName; + public string FriendlyName => _translationService.GetResource(CashOnDeliveryPaymentDefaults.FriendlyName); + public int Priority => _cashOnDeliveryPaymentSettings.DisplayOrder; + public IList LimitedToStores => new List(); + public IList LimitedToGroups => new List(); + … +} +``` + +Points worth copying: + +- **The settings class is injected directly.** GrandNode registers `ISettings` implementations in DI — do not resolve them through `ISettingService` inside a provider. +- **`FriendlyName` goes through `ITranslationService`.** A literal here cannot be translated and cannot be changed by an operator. +- **`Priority` comes from `DisplayOrder`**, so operators control ordering from the admin screen. +- **`LimitedToStores` / `LimitedToGroups`** are the scoping hooks from `.ai/knowledge/scoping.md`. Returning empty lists means "available everywhere". + +The payment-specific members follow (`ProcessPayment`, `PostProcessPayment`, `PostRedirectPayment`, …). COD is the degenerate case — it returns `TransactionStatus.Pending` and does nothing else, which makes the surrounding structure easy to see. + +## 3. The plugin: install is a contract with uninstall + +```csharp +public class CashOnDeliveryPaymentPlugin( + ISettingService settingService, + IPluginTranslateResource pluginTranslateResource) + : BasePlugin, IPlugin +{ + public override string ConfigurationUrl() => CashOnDeliveryPaymentDefaults.ConfigurationUrl; + + public override async Task Install() + { + var settings = new CashOnDeliveryPaymentSettings { DescriptionText = "…" }; + await settingService.SaveSetting(settings); + + await pluginTranslateResource.AddOrUpdatePluginTranslateResource( + "Payments.CashOnDelivery.FriendlyName", "Cash on delivery"); + await pluginTranslateResource.AddOrUpdatePluginTranslateResource( + "Plugins.Payment.CashOnDelivery.DescriptionText", "Description"); + // … one Add per resource key, plus a .Hint for each admin field + await base.Install(); + } + + public override async Task Uninstall() + { + await settingService.DeleteSetting(); + await pluginTranslateResource.DeletePluginTranslationResource( + "Plugins.Payment.CashOnDelivery.DescriptionText"); + // … one Delete per key Install added + await base.Uninstall(); + } +} +``` + +- Primary constructor syntax — the codebase uses it for plugin classes. +- `base.Install()` / `base.Uninstall()` mark the plugin installed/uninstalled and are called **last**. +- **Every key added in `Install` should be removed in `Uninstall`.** In the shipped file `Uninstall` misses `DisplayOrder` and `FriendlyName` — an example of the drift this rule exists to prevent. Do not copy that gap. +- Admin fields get two keys each: the label and a `.Hint`. + +## 4. Registration + +```csharp +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } + + public int Priority => 10; + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) { } + public bool BeforeConfigure => false; +} +``` + +Discovered by assembly scanning — nothing in `Program.cs` or `Grand.Web` mentions this plugin. `Priority => 10` is the plugin convention. `Configure` stays empty because the plugin owns no middleware; routes come from `EndpointProvider.cs` instead. + +## 5. Admin configuration: store scope is the whole point + +```csharp +[AuthorizeAdmin] +[Area("Admin")] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentCashOnDeliveryController : BasePaymentController +{ + public async Task Configure() + { + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + var model = new ConfigurationModel { … , ActiveStore = storeScope }; + return View(model); + } + + [HttpPost] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var storeScope = await _adminStoreService.GetActiveStore(); + var settings = await _settingService.LoadSetting(storeScope); + settings.DescriptionText = model.DescriptionText; + // … + await _settingService.SaveSetting(settings, storeScope); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + return await Configure(); + } +} +``` + +Five things that are each a bug when omitted: + +1. All three attributes. Without `[PermissionAuthorize]` any admin can reconfigure payments. +2. `GetActiveStore()` on **both** GET and POST. Saving without the scope overwrites the global value for every store. +3. `LoadSetting` before mutating, so untouched properties are not reset to type defaults. +4. `ClearCache()` after saving, or the storefront keeps the old settings. +5. Invalid model state re-renders the form rather than saving partial data. + +Views under `Areas/Admin/Views/` need their own `_ViewImports.cshtml` (tag helpers + `@inject LocService Loc`) and a `_ViewStart.cshtml` with `Layout = ""` — the admin supplies the shell. + +## 6. Project file + +`Microsoft.NET.Sdk.Razor` with `AddRazorSupportForMvc=true` and `StaticWebAssetsEnabled=false`; output path set for **both** Debug and Release to `..\..\Web\Grand.Web\Plugins\Payments.CashOnDelivery\`; every GrandNode reference `false`, with `all` on `Grand.Web.Common`; `logo.jpg` copied to output. + +A Release output path missing means the plugin silently disappears from release builds. + +## Trace: what happens at runtime + +1. Host starts → assembly scan finds `StartupApplication` → `IPaymentProvider` registered. +2. Plugin marked installed → `Install()` seeded settings and resource keys. +3. Checkout asks for available payment providers → this one appears, ordered by `Priority`, filtered by `LimitedToStores` / `LimitedToGroups`. +4. Its name renders through `FriendlyName` → `ITranslationService` → the resource key installed in step 2. +5. Operator clicks *Configure* → `ConfigurationUrl` → admin controller → settings loaded for the active store. +6. Customer selects it → `ProcessPayment` → `TransactionStatus.Pending`. diff --git a/.ai/examples/theme-override-walkthrough.md b/.ai/examples/theme-override-walkthrough.md new file mode 100644 index 0000000000..0653a0d954 --- /dev/null +++ b/.ai/examples/theme-override-walkthrough.md @@ -0,0 +1,139 @@ +# Example: Theme Override + +Source: `src/Plugins/Theme.Modern/` + +How a GrandNode theme replaces part of the storefront without forking it. Read `.ai/skills/theme-creation.md` for the contract and `.ai/templates/theme/theme-plugin.md` for the skeleton. + +--- + +## Five files and a folder + +``` +Theme.Modern/ + Theme.Modern.csproj + Manifest.cs ← Group = "Themes" + ModernThemePlugin.cs ← one line + ModernThemeView.cs ← the whole mechanism + StartupApplication.cs ← one registration + logo.jpg + Content/{css,script,images,swiper}, theme.jpg + Views/Modern/… ← the overrides +``` + +The plugin class is a declaration and nothing more: + +```csharp +public class MinimalThemePlugin : BasePlugin, IPlugin; +``` + +A theme with no settings and no resource keys needs no `Install()` or `Uninstall()` — `BasePlugin` already marks it installed. (The class name here does not match the file name `ModernThemePlugin.cs`; the type name is not load-bearing, but new code should keep them aligned per `.ai/standards/naming.md`.) + +Registration is equally small: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddScoped(); +} +``` + +## The mechanism: `GetViewLocations()` + +```csharp +public class ModernThemeView : IThemeView +{ + public string AreaName => ""; + public string ThemeName => "Modern"; + + public ThemeInfo ThemeInfo => new("Modern theme (beta)", + "~/Plugins/Theme.Modern/Content/theme.jpg", "Minimal theme (beta)", false); + + public IEnumerable GetViewLocations() + { + return new List { + "/Views/Modern/{1}/{0}.cshtml", + "/Views/Modern/Shared/{0}.cshtml", + "/Views/{1}/{0}.cshtml", + "/Views/Shared/{0}.cshtml" + }; + } +} +``` + +`{0}` is the view name, `{1}` the controller name. Resolution walks the list in order and takes the first hit: + +| Request | Resolves to | +|---|---| +| `Product/ProductTemplate.Simple` (theme has it) | `/Views/Modern/Product/ProductTemplate.Simple.cshtml` | +| `Vendor/List` (theme does not) | `/Views/Vendor/List.cshtml` — the default in `Grand.Web` | + +The last two entries are the fallback. **Remove them and every page the theme has not copied fails with "view not found."** That single detail is what makes a theme an override set rather than a fork. + +`ThemeName` is `"Modern"` and the folder is `Views/Modern/` — those must match, because the format strings hardcode the folder name. + +## What "a subset" means in practice + +`Theme.Modern` ships 110 `.cshtml` files against 235 in `Grand.Web/Views`: + +| Folder | Views | +|---|---| +| `Shared/` | 47 | +| `Account/` | 18 | +| `Product/` | 16 | +| `Catalog/` | 11 | +| `Blog/` | 5 | +| `Checkout/`, `News/`, `Order/` | 2 each | +| `Common/`, `Home/`, `MerchandiseReturn/`, `ShoppingCart/`, `Wishlist/` | 1 each | + +The weight is in `Shared/` — layouts, partials, and components, the markup that defines the theme's look. Everything not listed (vendor pages, courses, knowledgebase, most of checkout) falls through to the default views and stays correct for free. + +**That distribution is the lesson.** Start from layouts and shared partials. Copy a page-level view only when its structure genuinely differs; if only styling differs, a rule in `Content/css/` is cheaper and does not need re-reconciling on upgrade. + +## The `_ViewImports.cshtml` trap + +The theme's view folder does **not** inherit `Grand.Web`'s imports. `Views/Modern/_ViewImports.cshtml` re-declares everything — the tag helper registrations, ~30 `@using` lines for `Grand.Web.Models.*` / `Grand.Domain.*` / `Grand.Web.Common.*`, and `@inject LocService Loc`. + +It also carries this, which is easy to lose when writing a new theme by hand: + +```cshtml +@*we remove the default InputTagHelper to prevent the checkbox duplicating*@ +@removeTagHelper Microsoft.AspNetCore.Mvc.TagHelpers.InputTagHelper, Microsoft.AspNetCore.Mvc.TagHelpers +``` + +Without it, every checkbox in the theme renders twice. Copy the file wholesale rather than assembling the `@using` list from scratch — a missing namespace only surfaces when some copied view fails to compile. + +## Assets + +`Content/` is grouped by area — `common/`, `header/`, `home/`, `catalog/`, `product/`, `cart/`, `customer/`, `blog-news/`, `fonts/` — plus `script/` and a vendored `swiper/`. The whole tree is copied with `PreserveNewest` and served from `~/Plugins/Theme.Modern/Content/...`. + +`Content/theme.jpg` is what `ThemeInfo.PreviewImageUrl` points at — the image the admin theme picker shows. `logo.jpg` is the separate plugin-list logo. Both are required. + +## Project file, the theme-specific parts + +```xml + + + true + false + + … + + false + all + +``` + +A theme is the one plugin kind that references `Grand.Web` — it compiles against the storefront view models. `ExcludeAssets=all` keeps the assembly out of the plugin's output; the host already has it. + +Output path is set for both Debug and Release to `..\..\Web\Grand.Web\Plugins\Theme.Modern\`. + +## The upgrade cost + +Every copied view is a file that must be reconciled against upstream on each GrandNode upgrade. A view that drifted silently — a widget zone dropped, a `data-cart-action` attribute lost, a model property renamed — is the standard theme failure mode. + +Mitigations, in order of value: + +1. Copy fewer views. Prefer CSS. +2. Record which views were copied, at which upstream revision, in the PR. +3. On upgrade, diff each copied view against its `Grand.Web` counterpart before assuming it still works. +4. Never change a view model or route from inside a theme — that turns an override into a fork. diff --git a/.ai/glossary/README.md b/.ai/glossary/README.md new file mode 100644 index 0000000000..b63463cf8e --- /dev/null +++ b/.ai/glossary/README.md @@ -0,0 +1,21 @@ +# Glossary + +The vocabulary of the GrandNode domain, mapped to the types that implement it. + +Read this before naming anything, before writing a model, and before assuming a term means what it means in another e-commerce platform. GrandNode descends from nopCommerce and renamed a large part of the vocabulary — using the old word in a new type name produces code that reads as if it belongs to a different system. + +| File | Covers | +|---|---| +| `entity-model.md` | Base types, marker interfaces, and the mechanics every entity shares | +| `catalog.md` | Products, grouping, attributes, pricing, inventory | +| `sales.md` | Cart, orders, payment, shipping, returns, discounts | +| `customers.md` | Customers, groups, vendors, sales employees | +| `platform.md` | Stores, localization, settings, permissions, CMS, media, messaging | +| `renamed-terms.md` | Terms that differ from nopCommerce and other platforms — read this first | + +## Rules + +1. Use the domain term the codebase uses. A "return request" is a **merchandise return**; a "customer role" is a **customer group**. +2. The domain term is also the type name. `Grand.Domain.Orders.MerchandiseReturn` — no `Entity`, `Model`, or `Dto` suffix in the domain layer. +3. When a glossary entry and the shipped entity disagree, the entity is correct — fix the glossary. +4. New domain concepts get a glossary entry in the same change that introduces them. diff --git a/.ai/glossary/catalog.md b/.ai/glossary/catalog.md new file mode 100644 index 0000000000..bee1096b94 --- /dev/null +++ b/.ai/glossary/catalog.md @@ -0,0 +1,96 @@ +# Glossary: Catalog + +Source: `src/Core/Grand.Domain/Catalog/` + +--- + +## Product + +`Product` is the sellable unit. Its kind is `ProductTypeId` (`ProductType`): + +| Value | Meaning | +|---|---| +| `SimpleProduct` (0) | One sellable item — the default | +| `GroupedProduct` (10) | A parent listing whose children are the sellable products | +| `Reservation` (20) | Booked by date/time slot — rooms, appointments (`ProductReservation`) | +| `BundledProduct` (30) | Sold as a set of other products (`BundleProduct`) | +| `Auction` (40) | Sold by bidding (`Bid`) | + +Product type is not cosmetic — cart, pricing, and inventory branch on it. A change that touches purchasing must state which product types it was verified against. + +## Grouping + +Three independent ways to group products. They are not synonyms and they are not hierarchical variants of each other. + +| Term | Meaning | Link entity | +|---|---|---| +| **Category** | The navigational tree. Hierarchical, has a parent. | `ProductCategory` | +| **Brand** | The maker. Flat. (Called *Manufacturer* in other platforms.) | on the product | +| **Collection** | A curated cross-cutting set — "Summer 2026", "Staff picks". Flat. | `ProductCollection` | + +A product belongs to many categories and collections, and to at most one brand. + +## Attributes + +Two different mechanisms, constantly confused: + +| Term | Purpose | Types | +|---|---|---| +| **Product attribute** | Customer-selectable options that change what is bought — size, colour. Can change price, weight, SKU, and stock. | `ProductAttribute`, `ProductAttributeMapping`, `ProductAttributeValue`, `ProductAttributeCombination` | +| **Specification attribute** | Descriptive facts used for filtering and comparison — screen size, material. Never changes price or stock. | `SpecificationAttribute`, its options | + +- `ProductAttributeMapping` attaches an attribute to one product, with its control type (`AttributeControlType`). +- `ProductAttributeCombination` is a concrete combination (Red + Large) with its own SKU, stock, and price. +- `PredefinedProductAttributeValue` seeds values reused across products. + +If a change affects what the customer can buy, it is a product attribute. If it affects how they find or compare it, it is a specification attribute. + +## Pricing + +| Term | Meaning | +|---|---| +| **Tier price** | Quantity-break price on a product (`TierPrice`); per-combination variant `ProductCombinationTierPrices` | +| **Customer product price** | A price negotiated for one customer (`Grand.Domain.Customers.CustomerProductPrice`) | +| **Product price** | `ProductPrice` — per-currency price entries | +| **Catalog price rules** | Discounts of type catalog, applied through `Discount` — see `sales.md` | + +Prices are stored in the store's primary currency and converted for display. Never persist a converted value. + +## Inventory + +| Term | Meaning | +|---|---| +| **Manage inventory method** | `ManageInventoryMethod` — don't track / track by product / track by attribute combination | +| **Warehouse** | `Grand.Domain.Shipping.Warehouse` — stock location; per-combination stock in `ProductCombinationWarehouseInventory` | +| **Backorder mode** | `BackorderMode` — what happens at zero stock | +| **Low stock activity** | `LowStockActivity` — automatic reaction at the minimum threshold | +| **Inventory journal** | `InventoryJournal` — the movement log | +| **Out of stock subscription** | `OutOfStockSubscription` — customer notification request | + +Stock lives at the product, the combination, or the warehouse level depending on the inventory method. Code that adjusts stock must handle all three. + +## Layout + +`ProductLayout`, `CategoryLayout`, `BrandLayout`, `CollectionLayout` select which view renders the entity. **Layout**, never "template" — in this codebase a template is a message template or a Razor file. See `renamed-terms.md`. + +## Reviews and relations + +| Term | Meaning | +|---|---| +| **Product review** | `ProductReview` — customer rating and text, with approval | +| **Cross-sell product** | `CrossSellProduct` — "customers also bought", shown in the cart | +| **Related product** | Curated "you may also like", on the product page | +| **Also purchased** | `ProductAlsoPurchased` — computed from order history | +| **Product deleted** | `ProductDeleted` — tombstone kept so historical orders still resolve | + +`ProductDeleted` exists because orders reference products that operators remove. Never assume a product id in an order still resolves to a live `Product`. + +## Other + +| Term | Meaning | +|---|---| +| **Gift voucher** | `Grand.Domain.Orders.GiftVoucher` — prepaid value (called *gift card* elsewhere) | +| **Reservation** | `ProductReservation` — a bookable slot for a reservation product | +| **Bid** | `Bid` — an auction offer | +| **Customer group product** | `CustomerGroupProduct` — product visibility or ordering per customer group | +| **Customer tag product** | `CustomerTagProduct` — the same, driven by customer tags | diff --git a/.ai/glossary/customers.md b/.ai/glossary/customers.md new file mode 100644 index 0000000000..a3b41a78ec --- /dev/null +++ b/.ai/glossary/customers.md @@ -0,0 +1,71 @@ +# Glossary: Customers and Vendors + +Source: `src/Core/Grand.Domain/Customers/`, `Vendors/`, `Affiliates/` + +--- + +## Customer + +`Customer` is the account — and also the guest. A guest with items in a cart is a `Customer` record; registration converts it rather than creating a new one. Code that assumes "customer means registered" is wrong. + +| Term | Meaning | +|---|---| +| **Customer group** | `CustomerGroup` — a set of customers driving pricing, visibility, and permissions (called *customer role* elsewhere) | +| **System customer group names** | `SystemCustomerGroupNames` — the built-in groups (administrators, registered, guests, vendors). Match on these constants, never on the display name | +| **Customer tag** | `CustomerTag` — operator-assigned label for segmentation and targeting | +| **Customer attribute** | `CustomerAttribute` / `CustomerAttributeValue` — operator-defined registration fields | +| **Customer note** | `CustomerNote` — operator annotation | +| **Customer product** | `CustomerProduct` — the customer's relationship to a product (recently viewed, personal listing) | +| **Customer product price** | `CustomerProductPrice` — a negotiated price for one customer | +| **User field** | `UserField` on `BaseEntity` — sparse extension data (called *generic attribute* elsewhere) | +| **System customer field names** | `SystemCustomerFieldNames` — the well-known `UserField` keys. Never hardcode the string | + +Membership in a group is the authorization primitive. `CustomerGroup` drives `LimitedToGroups` on entities and providers — see `.ai/knowledge/scoping.md`. + +## Identity and authentication + +| Term | Meaning | +|---|---| +| **Password format** | `PasswordFormat` / `HashedPasswordFormat` — how the stored hash was produced; self-describing so old hashes can be upgraded on login | +| **Customer history password** | `CustomerHistoryPassword` — previous hashes, enforcing no-reuse policy | +| **External authentication** | `ExternalAuthentication` — a linked provider identity (Google, Facebook) | +| **Two factor authentication type** | `TwoFactorAuthenticationType` — app, email, or provider | +| **User API** | `UserApi` — API credentials for a customer, used by the API module | +| **Customer login results** | `CustomerLoginResults` — the enum a login attempt returns; not an exception | +| **User registration type** | `UserRegistrationType` — disabled, standard, email validation, admin approval | + +Login failure is an expected outcome and returns a result value. Do not convert it into an exception — see `.ai/principles.md`. + +## Vendor + +`Vendor` is a seller operating inside a store — a marketplace participant, not an operator. + +| Term | Meaning | +|---|---| +| **Vendor** | `Vendor` — the selling party; products carry its id | +| **Vendor note** | `VendorNote` — operator annotation | +| **Vendor review** | `VendorReview`, `VendorReviewHelpfulness` — customer feedback on the vendor | +| **Vendor settings** | `VendorSettings` — marketplace-wide vendor behavior | + +A vendor manager sees only their own records. Every vendor-facing query filters on `VendorId`, and every vendor-facing write re-checks ownership server-side. A posted id is attacker-controlled. See `.ai/knowledge/scoping.md`. + +`IWorkContext.CurrentVendor` is the logged-in vendor manager, and is null for ordinary customers and for background code. + +## Sales employee + +`SalesEmployee` is an internal staff member a customer can be assigned to — for commission and reporting. Distinct from a vendor (external seller) and from an administrator (an operator in the administrators customer group). + +## Affiliate + +`Grand.Domain.Affiliates.Affiliate` is a referrer credited for bringing in an order, tracked by URL parameter and stored on the order. Distinct from a vendor: an affiliate refers, a vendor sells. + +## Four parties, four boundaries + +| Party | Is | Sees | +|---|---|---| +| Customer | a buyer, possibly a guest | own orders, own data, one store | +| Vendor | an external seller | own products and orders, in stores they sell in | +| Sales employee | internal staff | assigned customers | +| Administrator | operator (a customer group) | everything their permissions allow | + +Each has its own admin area — see `.ai/knowledge/admin-areas.md`. Reusing a shared model across areas does not reuse the scope filter; each area applies its own. diff --git a/.ai/glossary/entity-model.md b/.ai/glossary/entity-model.md new file mode 100644 index 0000000000..a824cb2c38 --- /dev/null +++ b/.ai/glossary/entity-model.md @@ -0,0 +1,79 @@ +# Glossary: Entity Model + +The mechanics every GrandNode entity shares. Source: `src/Core/Grand.Domain/`. + +--- + +## Base types + +| Type | Meaning | +|---|---| +| `ParentEntity` | The root of every persisted type. Owns `Id`, mapped to Mongo's `_id`. | +| `BaseEntity` | `ParentEntity` + `UserFields` + audit fields. **The default base for a top-level entity.** | +| `SubBaseEntity` | `ParentEntity` with nothing added — for documents embedded in another entity. | + +```csharp +public abstract class ParentEntity +{ + [DBFieldName("_id")] + public string Id { get; set; } // a new UniqueIdentifier when unset +} + +public abstract class BaseEntity : ParentEntity, IAuditableEntity +{ + public IList UserFields { get; set; } = new List(); + public DateTime CreatedOnUtc { get; set; } + public string CreatedBy { get; set; } + public DateTime? UpdatedOnUtc { get; set; } + public string UpdatedBy { get; set; } +} +``` + +**Id** is a `string`, not an `ObjectId` or an `int`. It is assigned at construction, so an entity has a valid id before it is saved. Never generate ids yourself, and never treat an empty id as "new" without checking. + +**Audit fields** (`IAuditableEntity`) are filled by the data layer's audit provider, not by service code. Do not set `CreatedOnUtc` or `UpdatedBy` by hand. + +## Marker interfaces + +These are the contract for cross-cutting behavior. Implementing one opts the entity into the corresponding machinery; forgetting one is why a feature silently does not work. + +| Interface | Members | Effect | +|---|---|---| +| `IStoreLinkEntity` | `LimitedToStores`, `Stores` | Entity can be restricted to a subset of stores | +| `IGroupLinkEntity` | `LimitedToGroups`, `CustomerGroups` | Entity can be restricted to customer groups | +| `ISlugEntity` | `SeName` | Entity has a URL slug, tracked in `EntityUrl` | +| `ITranslationEntity` | `Locales` (`IList`) | Entity has per-language property translations | +| `IAuditableEntity` | created/updated by/on | Audit stamping (already on `BaseEntity`) | + +Scoping semantics for the first two are in `.ai/knowledge/scoping.md`. Never filter on them in the view — filter in the query. + +## User fields + +`UserField` is the open extension point: a name/value pair with an optional store id, attached to any `BaseEntity`. It is how plugins attach data to core entities without changing the core schema. + +Use it for genuinely optional, sparse, per-installation data. Do **not** use it for a field the domain always has — that belongs on the entity — and do not query heavily on it. + +## Localized properties + +An entity implementing `ITranslationEntity` carries a `Locales` collection. The raw property holds the default-language value; per-language values live in `Locales`. + +Read through the translation extension with `IWorkContext.WorkingLanguage.Id` — never render the raw property directly, and never cache a localized projection without the language id in the key. + +Do not confuse this with `TranslationResource`, which holds UI strings (labels, messages) rather than entity content. + +## Slugs + +An `ISlugEntity` exposes `SeName`. The authoritative slug records live in `EntityUrl` (`Grand.Domain.Seo`), keyed by entity type and language. Changing a slug means writing an `EntityUrl` record — not just assigning `SeName`. + +## Persistence + +Entities are persisted through `IRepository` (`Grand.Data`). The business layer never sees a Mongo collection, filter, or driver type. `[DBFieldName]` (from `Grand.SharedKernel.Attributes`) maps a property to a different stored field name. + +## Rules + +1. A top-level entity derives from `BaseEntity`; an embedded document derives from `SubBaseEntity`. +2. `Id` is a `string` and is already populated — do not overwrite it on insert. +3. Implement the marker interfaces the feature needs; they are not optional decoration. +4. No UI concerns, no persistence details, and no dependencies on business or infrastructure in `Grand.Domain`. +5. Entities carry data and invariants, not repository or service calls. +6. Adding a field to an entity that already ships needs a default that preserves existing behavior, and usually a migration — see `.ai/templates/migration.md`. diff --git a/.ai/glossary/platform.md b/.ai/glossary/platform.md new file mode 100644 index 0000000000..a434b502c3 --- /dev/null +++ b/.ai/glossary/platform.md @@ -0,0 +1,119 @@ +# Glossary: Platform + +Stores, localization, settings, permissions, content, media, and messaging. + +Source: `src/Core/Grand.Domain/Stores/`, `Localization/`, `Configuration/`, `Permissions/`, `Seo/`, `Pages/`, `Media/`, `Messages/` + +--- + +## Store + +| Term | Meaning | +|---|---| +| **Store** | `Store` — a storefront with its own hosts, currency, language, and settings | +| **Domain host** | `DomainHost` — a hostname routed to a store; how a request resolves its store | +| **Bank account** | `BankAccount` — store payment details shown on invoices | +| **Store link entity** | `IStoreLinkEntity` — the `LimitedToStores` / `Stores` marker | + +Multi-store is the default assumption, not a feature flag. The store is resolved **before** the customer, at the start of every request — see `.ai/knowledge/request-lifecycle.md`. + +## Localization + +| Term | Meaning | +|---|---| +| **Language** | `Language` — an installed language with culture and RTL flag | +| **Translation resource** | `TranslationResource` — one UI string, keyed (called *locale string resource* elsewhere) | +| **Translation resource area** | `TranslationResourceArea` — which surface a resource belongs to | +| **Translation entity** | `TranslationEntity` — a per-language value of an *entity property* | +| **Translation entity marker** | `ITranslationEntity` — the `Locales` collection | + +Two distinct things: + +- **Resources** are UI strings — labels, messages, validation text. Read through `ITranslationService` in services and `LocService` (`@Loc["..."]`) in views. Seeded from `App_Data/Resources/` and shipped through upgrade XML. +- **Translations on entities** are content — a product's name in German. Read through the translation extension with the working language id. + +A hardcoded user-facing string is a defect in both cases. + +## Settings + +| Term | Meaning | +|---|---| +| **Settings class** | any `ISettings` implementation — `CatalogSettings`, `OrderSettings`, … | +| **Setting service** | `ISettingService` — load/save, with per-store overrides | +| **Store scope** | the store a setting value applies to; empty means the global value | + +A setting has a global value and optional per-store overrides. Loading without a store id gives the global value, which is rarely what a storefront request wants. Some fields are deliberately system-wide and must be preserved when saving a store-scoped copy. See `.ai/skills/settings-and-localization.md`. + +Always `ClearCache()` after saving settings. + +## Permissions and navigation + +| Term | Meaning | +|---|---| +| **Permission** | a named capability granted to customer groups | +| **Permission system name** | `PermissionSystemName` — the stable identifier used in `[PermissionAuthorize]` | +| **Permission action name** | `PermissionActionName` — finer-grained action within a permission | +| **Standard permission** | `StandardPermission` — the built-in set | +| **Permission provider** | `PermissionProvider` — registers permissions at install | +| **Admin site map** | `AdminSiteMap` — the admin navigation tree | +| **Group link entity** | `IGroupLinkEntity` — `LimitedToGroups` / `CustomerGroups` | + +A new permission needs a provider entry **and** a migration, or existing installations never receive it. See `.ai/skills/permission-navigation.md`. + +## SEO + +| Term | Meaning | +|---|---| +| **Entity URL** | `EntityUrl` — the authoritative slug record, per entity and language | +| **SeName** | the slug property on `ISlugEntity` | +| **Entity types** | `EntityTypes` — which entity a slug belongs to | +| **Robots.txt** | `RobotsTxt` — operator-editable crawler rules | + +Changing a slug means writing an `EntityUrl` record, not just assigning `SeName`. + +## Content + +| Term | Meaning | +|---|---| +| **Page** | `Grand.Domain.Pages.Page` — an operator-authored content page (called *topic* elsewhere) | +| **Page layout** | `PageLayout` — which view renders it | +| **Blog** | `Grand.Domain.Blogs` — posts, categories, comments | +| **News** | `Grand.Domain.News` — news items and comments | +| **Knowledgebase** | `Grand.Domain.Knowledgebase` — articles and categories | +| **Course** | `Grand.Domain.Courses` — courses, lessons, subjects, tied to a product | +| **Document** | `Grand.Domain.Documents` — operator documents attached to customers or orders | + +Content authored in the admin is rendered with `Html.Raw` into a Vue-controlled page. Database content containing `{{ }}` is compiled as a Vue template unless wrapped in `v-pre` — see `.ai/standards/razor-frontend.md`. + +## Media + +| Term | Meaning | +|---|---| +| **Picture** | `Picture` — an image, stored in the database or on a configured provider | +| **Download** | `Download` / `DownloadType` — a downloadable file, e.g. for digital products | +| **Media settings** | `MediaSettings` — thumbnail sizes and image behavior | +| **Storage settings** | `StorageSettings` — where binaries live (DB, filesystem, S3, Azure Blob) | + +## Messaging + +| Term | Meaning | +|---|---| +| **Message template** | `MessageTemplate` — a DotLiquid email/notification body, keyed by name | +| **Message template names** | the constants message-sending code matches on | +| **Queued email** | the outbound message row; sending is asynchronous, via a scheduled task | +| **Token / drop** | the DotLiquid values a template may reference | +| **Message tokens added event** | the extension point for plugins to add tokens | + +A template may only use tokens the relevant drop exposes. See `.ai/skills/message-notification.md`. + +## Tasks and infrastructure + +| Term | Meaning | +|---|---| +| **Schedule task** | `Grand.Domain.Tasks.ScheduleTask` — the persisted definition; the DI key must equal `ScheduleTaskName` | +| **Migration** | `IMigration` — a versioned upgrade step, identified by a GUID | +| **DB version** | `MigrationDb` / `DbVersion` — the installed schema version | +| **GrandNode version** | `GrandNodeVersion` — the product version record | +| **History** | `Grand.Domain.History` — change tracking for auditable entities | + +Scheduled tasks and migrations run without a request, and therefore without `IWorkContext`. They take store and customer explicitly. diff --git a/.ai/glossary/renamed-terms.md b/.ai/glossary/renamed-terms.md new file mode 100644 index 0000000000..36305cd9fa --- /dev/null +++ b/.ai/glossary/renamed-terms.md @@ -0,0 +1,68 @@ +# Renamed Terms + +GrandNode descends from nopCommerce and renamed much of the vocabulary. Using the old term produces types that read as foreign to the codebase, and searches that find nothing. + +Read this before naming a type, a model property, a resource key, or a variable. + +| Elsewhere | In GrandNode | Type | +|---|---|---| +| Manufacturer | **Brand** | `Grand.Domain.Catalog.Brand` | +| — (new concept) | **Collection** | `Grand.Domain.Catalog.Collection` | +| Topic | **Page** | `Grand.Domain.Pages.Page` | +| Customer role | **Customer group** | `Grand.Domain.Customers.CustomerGroup` | +| Return request | **Merchandise return** | `Grand.Domain.Orders.MerchandiseReturn` | +| Reward points | **Loyalty points** | `Grand.Domain.Orders.LoyaltyPointsHistory` | +| Gift card | **Gift voucher** | `Grand.Domain.Orders.GiftVoucher` | +| Generic attribute | **User field** | `Grand.Domain.Common.UserField` | +| Locale string resource | **Translation resource** | `Grand.Domain.Localization.TranslationResource` | +| Localized property | **Translation entity** | `Grand.Domain.Localization.TranslationEntity` | +| URL record / slug record | **Entity URL** | `Grand.Domain.Seo.EntityUrl` | +| Product template | **Product layout** | `Grand.Domain.Catalog.ProductLayout` | +| Discount requirement | **Discount rule** | `Grand.Domain.Discounts.DiscountRule` | +| Specification attribute option | **Specification attribute option** | unchanged | +| Address attribute, checkout attribute | unchanged | `AddressAttribute`, `CheckoutAttribute` | + +## Layouts, not templates + +Every entity that has a selectable rendering has a `*Layout` type — `ProductLayout`, `CategoryLayout`, `BrandLayout`, `CollectionLayout`, `PageLayout`. "Template" in this codebase means a **message template** (`Grand.Domain.Messages.MessageTemplate`, DotLiquid) or a Razor view file, never a catalog rendering choice. + +## Two payment vocabularies + +`PaymentStatus` and `TransactionStatus` are different enums for different objects: + +- `Order.PaymentStatusId` → `Grand.Domain.Payments.PaymentStatus` — where the order stands commercially. +- `PaymentTransaction.TransactionStatus` → `Grand.Domain.Payments.TransactionStatus` — where one payment attempt stands with the provider. + +An order may have several payment transactions. Do not treat the two as interchangeable. + +## Groups, twice + +"Group" means two unrelated things depending on the namespace: + +- `CustomerGroup` — a set of customers, used for pricing, visibility, and permissions. +- `PluginInfo.Group` — the plugin category string in a manifest (`"Payment methods"`, `"Widgets"`, `"Themes"`). + +## Provider vs plugin + +- A **plugin** is the installable unit: an assembly, a manifest, an `IPlugin` implementation, an output folder. +- A **provider** is a capability the plugin registers: `IPaymentProvider`, `IShippingRateCalculationProvider`, `IWidgetProvider`, `IDiscountProvider`, `IThemeView`. + +One plugin may register several providers. `SystemName` on the provider and `SystemName` in the manifest must match — see `.ai/standards/naming.md`. + +## Store vs shop vs site + +The codebase says **store** (`Grand.Domain.Stores.Store`) — a storefront with its own domain hosts, currency, language, and settings. "Shop", "site", and "tenant" appear nowhere; do not introduce them. + +## Words to avoid entirely + +| Do not write | Because | +|---|---| +| `Manufacturer` | it is `Brand` | +| `Topic` | it is `Page` | +| `CustomerRole` | it is `CustomerGroup` | +| `ReturnRequest` | it is `MerchandiseReturn` | +| `RewardPoints` | it is `LoyaltyPoints` | +| `GiftCard` | it is `GiftVoucher` | +| `GenericAttribute` | it is `UserField` | +| `Tenant` | it is `Store` | +| `Repository` as a type-name suffix on a business service | the repository is `IRepository`; services are `*Service` | diff --git a/.ai/glossary/sales.md b/.ai/glossary/sales.md new file mode 100644 index 0000000000..6a5aa0caed --- /dev/null +++ b/.ai/glossary/sales.md @@ -0,0 +1,109 @@ +# Glossary: Sales + +Cart through order, payment, shipping, returns, and discounts. + +Source: `src/Core/Grand.Domain/Orders/`, `Payments/`, `Shipping/`, `Discounts/` + +--- + +## Cart + +| Term | Meaning | +|---|---| +| **Shopping cart item** | `ShoppingCartItem` — a line held against the customer, not a separate cart document | +| **Shopping cart type** | `ShoppingCartType` — cart, wishlist, and the other list kinds share one entity | +| **Checkout attribute** | `CheckoutAttribute` — order-level options collected at checkout (gift wrap, delivery note), distinct from product attributes | + +There is no `Cart` entity. A cart is the set of `ShoppingCartItem` rows on a `Customer` filtered by `ShoppingCartType` and store. Code that "loads the cart" is filtering that collection — apply the store filter. + +## Order + +| Term | Meaning | +|---|---| +| **Order** | `Order` — the placed order; immutable in its commercial essentials once placed | +| **Order item** | `OrderItem` — one purchased line, holding the price *as sold* | +| **Order note** | `OrderNote` — operator or system annotation, optionally customer-visible | +| **Order tag** | `OrderTag` — operator-defined label for filtering | +| **Order tax** | `OrderTax` — the tax breakdown as computed at placement | + +`OrderItem` stores its own prices. Never recompute an order's totals from current product prices — the sold price is the record. + +### Three statuses, three questions + +| Enum | Question | Values | +|---|---|---| +| `OrderStatusSystem` | Where is the order in its lifecycle? | `Pending` (10), `Processing` (20), `Complete` (30), `Cancelled` (40) | +| `PaymentStatus` | Has it been paid? | pending / authorized / paid / refunded / voided | +| `ShippingStatus` | Has it shipped? | not required / not yet shipped / partially / shipped / delivered | + +They move independently. A `Complete` order can be partially refunded; a `Processing` order can be fully shipped. Never derive one from another. + +`OrderStatus` (alongside `OrderStatusSystem`) allows operator-defined statuses — read the system enum for logic, the operator status for display. + +`OrderItemStatus` tracks per-line state, which is what makes partial shipment and partial return possible. + +## Payment + +| Term | Meaning | +|---|---| +| **Payment transaction** | `PaymentTransaction` — one attempt against a provider | +| **Transaction status** | `TransactionStatus` — where that attempt stands | +| **Payment status** | `PaymentStatus` — where the *order* stands commercially | +| **Payment provider** | `IPaymentProvider` — the plugin capability | +| **Payment restriction** | `PaymentRestrictedSettings` — which methods are hidden for which countries/groups | + +An order may have several payment transactions (retry, capture, refund). `PaymentTransaction` is the audit trail; `Order.PaymentStatusId` is the summary. See `renamed-terms.md` for why they must not be conflated. + +Flows: **Standard** (charged in-process) vs **Redirection** (customer leaves to the provider and returns). Which one a plugin implements changes everything about its lifecycle — see `.ai/skills/plugin-payment.md`. + +## Shipping + +| Term | Meaning | +|---|---| +| **Shipping method** | `ShippingMethod` — the operator-facing choice ("Courier", "Economy") | +| **Shipping option** | `ShippingOption` — a computed quote returned by a provider, with a rate | +| **Shipment** | `Shipment` — an actual dispatch; an order can have many | +| **Shipment item** | `ShipmentItem` — which order items, in which quantity, from which warehouse | +| **Warehouse** | `Warehouse` — stock location | +| **Pickup point** | `PickupPoints` — collect-in-person location, an alternative to delivery | +| **Delivery date** | `DeliveryDate` — the promised-window label shown on a product | +| **Shipment tracker** | `IShipmentTracker` — maps a tracking number to carrier events | + +A *shipping method* is configuration; a *shipping option* is a runtime quote. Providers return options, never methods. + +## Returns + +| Term | Meaning | +|---|---| +| **Merchandise return** | `MerchandiseReturn` — the customer's request (called *return request* elsewhere) | +| **Merchandise return item** | `MerchandiseReturnItem` — which order items are coming back | +| **Merchandise return reason** | `MerchandiseReturnReason` — operator-defined reason list | +| **Merchandise return action** | `MerchandiseReturnAction` — what the customer wants: repair, replace, refund | +| **Merchandise return status** | `MerchandiseReturnStatus` — where the request stands | +| **Merchandise return note** | `MerchandiseReturnNote` — annotation on the request | + +A merchandise return is a request, not a refund. It does not change `PaymentStatus` by itself. + +## Discounts + +| Term | Meaning | +|---|---| +| **Discount** | `Discount` — the definition: type, amount or percentage, validity window | +| **Discount type** | `DiscountType` — what it applies to: order total, order subtotal, shipping, per-product, category, brand, collection | +| **Discount coupon** | `DiscountCoupon` — a code that activates a discount | +| **Discount rule** | `DiscountRule` — a condition the cart must satisfy (called *discount requirement* elsewhere); implemented by `IDiscountRule` in a plugin | +| **Discount limitation** | `DiscountLimitationType` — usage caps: unlimited, N times, N times per customer | +| **Discount usage history** | `DiscountUsageHistory` — the redemption log enforcing those caps | + +Multiple discounts can apply to one order. Never assume a single winner unless the discount type guarantees it. + +## Loyalty and vouchers + +| Term | Meaning | +|---|---| +| **Loyalty points** | `LoyaltyPointsHistory` — earned/spent ledger (called *reward points* elsewhere) | +| **Loyalty points settings** | `LoyaltyPointsSettings` — earn and redeem rates; some fields are system-wide, not per store | +| **Gift voucher** | `GiftVoucher` — prepaid value, `GiftVoucherType` physical or virtual | +| **Gift voucher usage history** | `GiftVoucherUsageHistory` — where the balance went | + +Both are ledgers. Never store a computed balance as a field — derive it from the history. diff --git a/.ai/knowledge/admin-areas.md b/.ai/knowledge/admin-areas.md new file mode 100644 index 0000000000..88d8bf774e --- /dev/null +++ b/.ai/knowledge/admin-areas.md @@ -0,0 +1,146 @@ +# GrandNode Admin Areas + +## Panel Inventory +- Main Admin panel: `src/Web/Grand.Web.Admin` +- Store Owner panel: `src/Web/Grand.Web.Store` +- Vendor panel: `src/Web/Grand.Web.Vendor` +- Shared admin contracts: `src/Web/Grand.Web.AdminShared` + +## Controller Locations +Controllers are directly under each web project: +- Admin: `src/Web/Grand.Web.Admin/Controllers` +- Store Owner: `src/Web/Grand.Web.Store/Controllers` +- Vendor: `src/Web/Grand.Web.Vendor/Controllers` + +Use the matching base controller: +- Admin: `BaseAdminController` +- Store Owner: `BaseStoreController` +- Vendor: `BaseVendorController` + +## View Locations +Views are under area folders: +- Admin: `src/Web/Grand.Web.Admin/Areas/Admin/Views` +- Store Owner: `src/Web/Grand.Web.Store/Areas/Store/Views` +- Vendor: `src/Web/Grand.Web.Vendor/Areas/Vendor/Views` + +Use nearby view patterns for: +- `List.cshtml` +- `Create.cshtml` +- `Edit.cshtml` +- popup views +- `Partials/CreateOrUpdate.cshtml` +- `Partials/CreateOrUpdate.Tab*.cshtml` +- `Partials/*.cshtml` + +## Shared Admin Contracts +Shared models, validators, and mapper profiles live in: +- `src/Web/Grand.Web.AdminShared/Models` +- `src/Web/Grand.Web.AdminShared/Validators` +- `src/Web/Grand.Web.AdminShared/Mapper` + +Check `AdminShared` first when a model is used by more than one panel. + +Common shared areas include: +- catalog and product models +- order, shipment, payment transaction, and merchandise return models +- customer, customer group, and vendor models +- discount models +- message template models +- store and settings models +- validators for create, edit, delete, popup, and relation models +- mapper profiles between domain entities and admin models + +## Parallel Workflow Check +When changing an entity, search for matching names across all panels. + +Examples: +- Product: check Admin `ProductController`, Store `ProductController`, Vendor `ProductController`, product views, `ProductModel`, `ProductValidator`, and `ProductProfile`. +- Order: check Admin `OrderController`, Store `OrderController`, Vendor `OrderController`, order views, `OrderModel`, order validators, and order-related reports. +- Shipment: check Admin `ShipmentController`, Store `ShipmentController`, Vendor `ShipmentController`, shipment views, `ShipmentModel`, and shipment permissions. +- Vendor: check Admin `VendorController`, Vendor `VendorInfoController`, vendor settings, vendor review flows, `VendorModel`, `VendorProfile`, and `VendorValidator`. +- Message templates: check Admin and Store `MessageTemplateController`, views, `MessageTemplateModel`, validator, mapper, and DotLiquid message template behavior. + +## Permission Rules +Use existing permission patterns from nearby controllers. + +Check: +- `StandardPermission` usage +- `_permissionService.Authorize(...)` +- area-specific access checks +- menu permission names in `src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs` +- seeded permission names in installer utilities when adding new management areas + +Do not expose Admin-only permissions to Store Owner or Vendor panels. + +## Scope Rules +Admin can usually access global data when permission allows it. + +Store Owner workflows must enforce store scope. Check: +- selected store context +- store-limited entities +- store-specific settings +- ACL and store mapping helpers +- search filters that include store ID + +Vendor workflows must enforce vendor scope. Check: +- current vendor identity +- product vendor ID +- order item vendor ownership +- shipment and merchandise return ownership +- vendor editable settings +- vendor-specific reports + +Server-side scope is mandatory. UI hiding is not enough. + +## Navigation Rules +Main Admin menu entries are seeded in: +- `src/Modules/Grand.Module.Installer/Utilities/StandardAdminSiteMap.cs` + +When adding a new admin page: +- Add or update the site map entry only if the page should appear in navigation. +- Use an existing resource key naming pattern. +- Use the correct controller and action. +- Attach the correct permission names. +- Check whether Store Owner or Vendor navigation also requires a corresponding entry. + +## Form And Grid Rules +Follow existing area conventions for: +- admin tag helpers +- tab partials +- popup forms +- grids: `` for new and converted grids (`.ai/standards/razor-frontend.md`, Admin grids); views not converted yet still use Kendo grids in script +- AJAX data functions +- `addAntiForgeryToken(data)` +- validation summary and field validation spans +- localized labels through `GrandResourceDisplayName` + +When adding a field: +- Update model. +- Update mapper profile. +- Update validator. +- Update create/edit views. +- Update list/search models if the field is searchable or visible in grids. +- Update controller binding and save logic. +- Update localization resources. +- Update tests. + +## Settings Rules +When changing settings: +- Check settings model in `AdminShared`. +- Check mapper profile. +- Check Admin settings controller and view. +- Check store-scope override behavior. +- Check whether Store Owner can configure the setting. +- Check whether Vendor panel needs read-only behavior or visibility changes. +- Update resource keys and validation. + +## Test Targets +Prefer narrow tests: +- controller action authorization and scope tests +- validator tests +- mapper profile tests +- service tests for scoped queries +- UI model preparation tests + +Run a targeted project build when tests are not available. + diff --git a/.ai/knowledge/architecture.md b/.ai/knowledge/architecture.md new file mode 100644 index 0000000000..85378a58a7 --- /dev/null +++ b/.ai/knowledge/architecture.md @@ -0,0 +1,142 @@ +# Best Practice: Architecture + +Patterns from `Grand.Infrastructure`, `Grand.Business.*`, `Grand.Domain`. Complementary to `.ai/skills/architecture-review.md`. + +Deeper documents split out of this file: + +- `.ai/knowledge/request-lifecycle.md` — startup, `IStartupApplication` priorities, middleware order, controller → view path. +- `.ai/knowledge/scoping.md` — store, vendor, customer group, language, and currency boundaries. +- `.ai/knowledge/caching.md` — `ICacheBase`, cache key constants, invalidation. +- `.ai/knowledge/domain-events.md` — commands vs queries vs notifications, handler rules. + +--- + +## Layering + +``` +Grand.Web / Grand.Module.Api — HTTP, ViewModels, Controllers +Grand.Business.* — Use cases, Services, Validators +Grand.Domain — Entities, Value objects (no dependencies) +Grand.Data / Grand.Infrastructure — DB access, Caching, Infrastructure +Grand.SharedKernel — Interfaces and utilities shared across all layers +``` + +Rules: +- Domain has no dependencies on infrastructure or business layers. +- Business layer depends on Domain and Data abstractions (`IRepository`), not on concrete Mongo types. +- Controllers delegate to the mediator — never contain business logic. +- Infrastructure registrations go in `IStartupApplication`, not `Program.cs`. +- Dependencies point inward. Core, business, and web projects never reference a plugin. + +--- + +## Dependency Injection Registration + +Implement `IStartupApplication` in each project that owns services. Split registration into private static `Register*` methods: + +```csharp +public class StartupApplication : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + RegisterOrdersService(services); + RegisterPaymentsService(services); + } + + private static void RegisterOrdersService(IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + } + + private static void RegisterPaymentsService(IServiceCollection services) + { + services.AddScoped(); + } +} +``` + +### Lifetimes + +| Lifetime | When to use | +|---|---| +| `AddScoped` | Business services, repositories — default choice | +| `AddSingleton` | Stateless infrastructure: `IMongoDatabase`, cache providers, `IAuditInfoProvider` | +| `AddTransient` | Rare; lightweight per-resolve objects with no shared state | +| `AddKeyedScoped` | Scheduled tasks (key must equal `ScheduleTask.ScheduleTaskName`) | + +--- + +## Grand.Mediator — Commands and Queries + +All mutations (writes) go through commands; all reads go through queries. Controllers only call `_mediator.Send(...)`. + +### Command + +```csharp +// in Grand.Business.Core/Commands/ +public class MaxOrderNumberCommand : IRequest +{ + public int? OrderNumber { get; set; } +} + +// in Grand.Business.Checkout/Commands/Handlers/ +public class MaxOrderNumberCommandHandler : IRequestHandler +{ + public async Task Handle(MaxOrderNumberCommand request, CancellationToken cancellationToken) + { + // write logic + } +} +``` + +### Query + +```csharp +// in Grand.Business.Core/Queries/ +public class GetSuggestedProductsQuery : IRequest> +{ + public string[] CustomerTagIds { get; set; } + public int ProductsNumber { get; set; } +} +``` + +Command/query definitions belong in `Grand.Business.Core`; handlers belong in the relevant `Grand.Business.*` project. + +Web-layer view-model preparation is a separate set of requests: query handlers in `Grand.Web/Features/Handlers/`, command handlers in `Grand.Web/Commands/Handler/`. + +--- + +## Domain Events + +Publish domain events after every repository mutation so event handlers can react (cache clearing, side effects, integrations). + +```csharp +await _repository.InsertAsync(entity); +await _mediator.EntityInserted(entity); + +await _repository.UpdateAsync(entity); +await _mediator.EntityUpdated(entity); + +await _repository.DeleteAsync(entity); +await _mediator.EntityDeleted(entity); +``` + +Event handlers implement `INotificationHandler>` (or Updated/Deleted). Place them in `Grand.Business.*/Events/Handlers/`. + +Handler failure semantics, re-entrancy, and the missing-ambient-context trap are covered in `.ai/knowledge/domain-events.md`. + +--- + +## Anti-Patterns + +| Anti-pattern | Correct | +|---|---| +| Business logic in a controller | Move to a command handler, call via `_mediator.Send` | +| Registering services in `Program.cs` | Register in `IStartupApplication.ConfigureServices` | +| Injecting `IMongoDatabase` into a business service | Inject `IRepository` | +| Forgetting `_mediator.EntityUpdated` after `_repo.UpdateAsync` | Always publish after every mutation | +| Singleton service with `IRepository` dependency | `IRepository` is Scoped — its consumer must also be Scoped | +| Caching in a controller or mediator handler | Cache in the business service, around the repository call | +| Reading `IWorkContext` from a scheduled task or migration | Pass store/customer explicitly — there is no ambient context | +| A cache key that omits the store id for store-scoped data | Include every variable that changes the result | diff --git a/.ai/knowledge/async.md b/.ai/knowledge/async.md new file mode 100644 index 0000000000..dc03fdcf7f --- /dev/null +++ b/.ai/knowledge/async.md @@ -0,0 +1,157 @@ +# Best Practice: Async / Await + +Patterns derived from the GrandNode codebase. Every rule below has a real counterpart in production code. + +--- + +## Rules + +### Always use `async Task`, never `async void` + +`async void` cannot be awaited; exceptions escape to `UnobservedTaskException`. + +```csharp +// correct +public async Task HandleAsync(Command request, CancellationToken cancellationToken) { ... } + +// never +public async void HandleAsync() { ... } +``` + +### Accept and forward CancellationToken throughout the call chain + +Every mediator handler already accepts `CancellationToken`. Pass it to every async call you make. + +```csharp +public async Task Handle(MaxOrderNumberCommand request, CancellationToken cancellationToken) +{ + await _orderRepository.InsertAsync(new Order { ... }, cancellationToken); +} +``` + +If an API or repository method doesn't accept `CancellationToken`, you don't need to invent one — just don't create a new `CancellationToken.None` at the call site. Use the one provided. + +### Never use `.Result`, `.Wait()`, or `.GetAwaiter().GetResult()` + +These block the thread and risk deadlocks under ASP.NET Core. + +```csharp +// wrong — blocks thread +var product = _productService.GetProductByIdAsync(id).Result; + +// correct +var product = await _productService.GetProductByIdAsync(id); +``` + +### Do NOT add `ConfigureAwait(false)` + +ASP.NET Core has no synchronization context. `ConfigureAwait(false)` is noise here — the codebase does not use it and neither should you. + +### Don't wrap I/O in `Task.Run` + +`Task.Run` is for CPU-bound work. Database and network calls are already async at the driver level. + +```csharp +// wrong — wastes a thread pool thread +var result = await Task.Run(() => _repository.GetByIdAsync(id)); + +// correct +var result = await _repository.GetByIdAsync(id); +``` + +### Use `ValueTask` only when the hot path frequently returns synchronously + +`ValueTask` avoids allocation for code that often returns a cached result without awaiting. Do not retrofit existing `Task`-returning code unless profiling shows allocation is a problem. + +### Never execute a query built on `Table` synchronously + +`Table` is an `IQueryable` over the MongoDB driver. `ToList()`, `Count()`, `First()` and plain +enumeration on it each issue a **blocking** round trip and hold a thread pool thread for its +duration. Being MongoDB rather than EF does not make it synchronous-safe — it makes it a network +call with no async on that path unless you ask for one. + +Execute through the repository instead. `MongoRepository` runs the query on the driver's +asynchronous API. Hand it a sequence you already materialised and it throws — that is deliberate, +because quietly enumerating it would be the very pattern this rule exists to remove. + +```csharp +// wrong — blocks a thread for the whole round trip +var productIds = query.Take(request.ProductsNumber).ToList(); +var total = query.Count(); +return new PagedList(query, pageIndex, pageSize); + +// correct +var productIds = await _productRepository.ToListAsync(query.Take(request.ProductsNumber)); +var total = await _productRepository.CountAsync(query); +return await _productRepository.PagedAsync(query, pageIndex, pageSize); +``` + +The same applies inside a lambda passed to `ICacheBase.GetAsync` — make the lambda `async` and +await the repository, rather than returning `Task.FromResult(query.ToList())`. Blocking there is +worse than elsewhere, because the cache holds a lock while the acquire function runs. + +`ToList()` on a sequence that is already in memory is fine and needs no repository call. + +### Run independent operations concurrently with `Task.WhenAll` + +When two or more async operations are independent, start all of them before awaiting: + +```csharp +// GetProductOverviewHandler.cs — real codebase example +var tasks = new List>(); +foreach (var product in request.Products) + tasks.Add(GetProductOverviewModel(product, ...)); + +var result = await Task.WhenAll(tasks); +``` + +Don't `await` each item in a `foreach` loop if the iterations don't depend on each other — that forces sequential execution. + +### LINQ + async: force evaluation before `Task.WhenAll` + +LINQ uses deferred execution. If you build a task list with `Select(id => GetAsync(id))`, the tasks don't start until the sequence is iterated. Call `.ToArray()` or `.ToList()` immediately to start all tasks: + +```csharp +// correct — all tasks start immediately +var tasks = ids.Select(id => GetProductByIdAsync(id)).ToArray(); +var products = await Task.WhenAll(tasks); + +// wrong — tasks created lazily, may not all start +var tasks = ids.Select(id => GetProductByIdAsync(id)); // no ToArray() +``` + +### `await Task.Delay` instead of `Thread.Sleep` + +`Thread.Sleep` blocks the thread. `await Task.Delay` releases it back to the pool during the wait: + +```csharp +// wrong +Thread.Sleep(1000); + +// correct +await Task.Delay(1000); +``` + +--- + +## Blocking-to-Async Replacement Table + +| Blocking (avoid) | Async equivalent | +|---|---| +| `task.Wait()` | `await task` | +| `task.Result` | `await task` | +| `Task.WaitAll(t1, t2)` | `await Task.WhenAll(t1, t2)` | +| `Task.WaitAny(t1, t2)` | `await Task.WhenAny(t1, t2)` | +| `Thread.Sleep(ms)` | `await Task.Delay(ms)` | + +--- + +## Summary + +| Rule | Reason | +|------|--------| +| `async Task` everywhere | `async void` exceptions are unobservable | +| Pass `CancellationToken` | Enables cooperative cancellation | +| No `.Result` / `.Wait()` | Deadlock risk | +| No `ConfigureAwait(false)` | No sync context in ASP.NET Core | +| No `Task.Run` for I/O | Wastes threads; I/O is already async | diff --git a/.ai/knowledge/caching.md b/.ai/knowledge/caching.md new file mode 100644 index 0000000000..8e2f5b0c75 --- /dev/null +++ b/.ai/knowledge/caching.md @@ -0,0 +1,110 @@ +# Caching + +`ICacheBase` (`src/Core/Grand.Infrastructure/Caching/ICacheBase.cs`) is the only caching abstraction. Backed by in-memory cache, or Redis when configured — the interface is the same either way, and the Redis backing is why invalidation has a `publisher` flag. + +--- + +## The interface + +```csharp +public interface ICacheBase +{ + T Get(string key, Func acquire); + T Get(string key, Func acquire, int cacheTime); + Task GetAsync(string key, Func> acquire); + Task GetAsync(string key, Func> acquire, int cacheTime); + Task SetAsync(string key, Func> acquire); + Task SetAsync(string key, Func> acquire, int cacheTime); + Task RemoveAsync(string key, bool publisher = true); + Task RemoveByPrefix(string prefix, bool publisher = true); + Task Clear(bool publisher = true); +} +``` + +`GetAsync` is read-through: the `acquire` delegate runs only on a miss. Never call the repository and then `SetAsync` separately. + +`publisher: true` (the default) broadcasts the invalidation to other instances through the Redis message bus. Pass `false` **only** when handling an invalidation message that already arrived from another instance — otherwise you create an invalidation loop. + +## Cache keys + +Keys are `static string` members on the partial `CacheKey` class, split by area: + +``` +src/Core/Grand.Infrastructure/Caching/Constants/ + CommonCacheKey.cs, ProductCacheKey.cs, CategoryCacheKey.cs, + CustomerCacheKey.cs, OrdersCacheKeys.cs, VendorCacheKey.cs, … +``` + +Two constants per cached family: + +```csharp +/// {0} : store ID (empty = all stores) +public static string TAXCATEGORIES_ALL_KEY => "Grand.taxcategory.all-{0}"; + +/// Key pattern to clear cache +public static string TAXCATEGORIES_PATTERN_KEY => "Grand.taxcategory."; +``` + +Rules: + +1. Prefix every key with `Grand.` and the entity family, lowercase, dot-separated. +2. The `*_PATTERN_KEY` is the shared prefix of every key in the family, and is what `RemoveByPrefix` takes. +3. Format parameters are documented in an XML `` block, in order. Follow that convention — it is what makes the placeholders readable at the call site. +4. **Every variable that changes the result must be in the key.** Store id, language id, customer group set, currency, vendor id, page index. A missing store id in a key is a cross-store data leak, not a performance bug. +5. Never build a key inline as a string literal at the call site. Add a constant. + +## Usage + +Read path, in the business service: + +```csharp +public virtual async Task> GetAllTaxCategories(string storeId = "") +{ + var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); + return await _cacheBase.GetAsync(key, async () => + { + var query = _taxCategoryRepository.Table.AsQueryable(); + // … + }); +} +``` + +Write path, in the same service: + +```csharp +await _taxCategoryRepository.InsertAsync(taxCategory); + +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); + +//event notification +await _mediator.EntityInserted(taxCategory); +``` + +Invalidate on **every** write — insert, update, and delete. A service that caches on read but forgets to invalidate on delete serves deleted records until the entry expires. + +## Cross-family invalidation + +A write sometimes invalidates families it does not own. `TaxCategoryService.Delete` clears both its own prefix and `CacheKey.PRODUCTS_PATTERN_KEY`, because products carry a tax category. + +When adding a cached family, ask what *else* embeds this data in a cached projection, and clear those prefixes too. When adding a new relationship, revisit the invalidation of both sides. + +## Where caching belongs + +| Layer | Caches? | +|---|---| +| Controller | No | +| Mediator handler | No — a handler may compose cached services, but does not own a cache key | +| Business service | **Yes** — this is the only layer that calls `ICacheBase` | +| Repository | No | + +## Anti-patterns + +- A cache key without the store id for store-scoped data. +- A cache key without the language id for localized data. +- `RemoveByPrefix` with a hand-written string instead of the `*_PATTERN_KEY` constant. +- `Clear()` to fix a stale entry — it evicts everything for every store. +- Caching an entity graph that contains customer-specific data under a key that omits the customer. +- `publisher: false` outside an invalidation-message handler. +- Caching in a scheduled task without a key that includes the store it is processing. + +See also `.ai/knowledge/performance.md` for pagination and partial-write guidance, and `.ai/knowledge/domain-events.md` for the event that accompanies each invalidation. diff --git a/.ai/knowledge/domain-events.md b/.ai/knowledge/domain-events.md new file mode 100644 index 0000000000..902e0f68a3 --- /dev/null +++ b/.ai/knowledge/domain-events.md @@ -0,0 +1,97 @@ +# Domain Events and Notifications + +GrandNode uses `Grand.Mediator` (`src/Core/Grand.Mediator`, in-house) for three distinct things. Keeping them apart matters, because they have different failure semantics. + +| Kind | Interface | Handlers | Failure | +|---|---|---|---| +| Query | `IRequest` | exactly one | propagates to the caller | +| Command | `IRequest` | exactly one | propagates to the caller | +| Notification (event) | `INotification` | zero or many | a throwing handler breaks the publisher | + +--- + +## Entity events + +`src/Core/Grand.Infrastructure/Events/` defines the three lifecycle notifications: + +```csharp +public class EntityInserted : INotification where T : ParentEntity +{ + public EntityInserted(T entity) { Entity = entity; } + public T Entity { get; private set; } +} +``` + +with `EntityUpdated` and `EntityDeleted` in the same shape. + +Publish through the extensions in `Grand.Infrastructure/Extensions/EventPublisherExtensions.cs`: + +```csharp +await _mediator.EntityInserted(taxCategory); +await _mediator.EntityUpdated(product); +await _mediator.EntityDeleted(category); +``` + +Do not construct the notification and call `Publish` by hand — use the extension. + +### Where to publish + +In the business service, after the repository write and after cache invalidation: + +```csharp +await _taxCategoryRepository.InsertAsync(taxCategory); +await _cacheBase.RemoveByPrefix(CacheKey.TAXCATEGORIES_PATTERN_KEY); +await _mediator.EntityInserted(taxCategory); +``` + +Never publish an entity event from a controller, a handler, or a repository. + +## Subscribing + +Implement `INotificationHandler`: + +```csharp +public class ProductUpdatedHandler : INotificationHandler> +{ + public async Task Handle(EntityUpdated notification, CancellationToken cancellationToken) + { + // react + } +} +``` + +Handlers are discovered by assembly scanning, including in plugins. This is the primary extension point for plugins that must react to core writes without modifying core. + +### Handler rules + +1. **A handler must not throw.** `Publish` runs handlers sequentially and fails fast; an exception aborts the remaining handlers and surfaces in the caller's write path. Catch, log, and return. +2. A handler must be fast. It runs inline in the request. Long work belongs in a scheduled task — see `.ai/skills/scheduled-task.md`. +3. A handler must not assume ambient context. Entity events fire from scheduled tasks and migrations too, where `IWorkContext` is null. Read what you need off the entity, or take it as an explicit parameter. See `.ai/knowledge/scoping.md`. +4. A handler that writes the same entity type it subscribes to will re-enter itself. Guard it or restructure. +5. A handler is not a transaction participant — MongoDB writes are already committed when it runs. There is no rollback. + +## Cache events + +`Events/CacheEvent.cs` and `Events/EntityCacheEvent.cs` carry cache invalidation between application instances. Handlers for these pass `publisher: false` to `ICacheBase` so the invalidation is not rebroadcast. See `.ai/knowledge/caching.md`. + +## Message and notification events + +Plugins extend outbound messages by handling `MessageTokensAddedEvent` rather than editing the token provider. Message templates, DotLiquid drops, and the queued-email lifecycle are covered in `.ai/skills/message-notification.md`. + +## Choosing between the three + +| You want to | Use | +|---|---| +| read data for a view | query + handler in `Features/Handlers/` | +| perform a state change with a result the caller needs | command + handler in `Commands/Handler/` | +| let unknown code react to a state change | `INotification` published by the owning service | +| let a plugin extend core behavior without a fork | `INotificationHandler` in the plugin | +| do slow or retryable work | `IScheduleTask`, triggered by a flag the handler sets | + +## Anti-patterns + +- Business logic in a notification handler that the write path actually depends on. If the write is wrong without it, it is not an event — put it in the service. +- Publishing an event before the write succeeds. +- A handler that queries back the entity it was just handed. +- Multiple handlers for the same event mutating the same entity, with ordering assumptions between them. +- Swallowing an exception in a handler with no logging. diff --git a/.ai/knowledge/dotnet.md b/.ai/knowledge/dotnet.md new file mode 100644 index 0000000000..33fdbbe5c9 --- /dev/null +++ b/.ai/knowledge/dotnet.md @@ -0,0 +1,175 @@ +# Best Practice: C# / .NET Idioms + +Patterns found across the GrandNode codebase. Covers C# language features and .NET conventions used here. + +--- + +## Immutable Input Types — Records for Validators + +Use positional records as input to FluentValidation validators. Records are immutable and their equality is structural: + +```csharp +public record ShoppingCartStandardValidatorRecord( + Customer Customer, + Product Product, + ShoppingCartItem ShoppingCartItem); + +public class ShoppingCartStandardValidator : AbstractValidator +{ ... } +``` + +--- + +## Guard Clauses — ArgumentNullException + +Use the built-in static helpers at the top of service methods. Do not write `if (x == null) throw new ArgumentNullException(...)` manually: + +```csharp +public async Task SaveDeliveryDate(DeliveryDate deliveryDate) +{ + ArgumentNullException.ThrowIfNull(deliveryDate); + // ... +} + +public void Initialize(string keyId, string secretKey, string bucket) +{ + ArgumentNullException.ThrowIfNullOrEmpty(keyId); + ArgumentNullException.ThrowIfNullOrEmpty(secretKey); + ArgumentNullException.ThrowIfNullOrEmpty(bucket); +} +``` + +--- + +## Result Objects Instead of Exceptions for Expected Failures + +For operations where failure is a valid business outcome, use a result object rather than throwing exceptions: + +```csharp +public class PlaceOrderResult +{ + public bool Success => Errors.Count == 0; + public IList Errors { get; set; } = new List(); + public Order PlacedOrder { get; set; } + + public void AddError(string error) => Errors.Add(error); +} +``` + +Exceptions are for unexpected, unrecoverable conditions. Validation failures, business rule violations, and "entity not found" are expected — return a result object. + +--- + +## Pattern Matching + +Use `switch` with `when` guards rather than nested `if/else`: + +```csharp +switch (cartItem.ShoppingCartTypeId) +{ + case ShoppingCartType.ShoppingCart when product.DisableBuyButton: + context.AddFailure(translationService.GetResource("ShoppingCart.BuyingDisabled")); + break; + case ShoppingCartType.Wishlist when product.DisableWishlistButton: + context.AddFailure(translationService.GetResource("ShoppingCart.WishlistDisabled")); + break; +} +``` + +--- + +## Nullable Reference Types + +The project has nullable reference types enabled. Follow these conventions: + +- `string` — the property/parameter is never null; calling code must not pass null. +- `string?` — explicitly nullable; caller or callee handles null. +- Do not suppress warnings with `!` unless you are certain the value cannot be null at that point. + +--- + +## Configuration Binding + +GrandNode uses direct `Bind` rather than `IOptions`: + +```csharp +var dbConfig = new DatabaseConfig(); +configuration.GetSection("Database").Bind(dbConfig); +``` + +Do not introduce `IOptions`, `IOptionsMonitor`, or `IOptionsSnapshot` — they are not used in this codebase and would be inconsistent. + +--- + +## LINQ Expression Trees in Predicates + +Repository methods accept `Expression>`, not strings. Always use typed lambda predicates: + +```csharp +// correct — type-safe, translates to a MongoDB query +var entity = await _repository.GetOneAsync(x => x.ExternalId == externalId); + +// wrong — string-based, no compile-time safety +var entity = await collection.Find("{externalId: '" + externalId + "'}").FirstOrDefaultAsync(); +``` + +--- + +## Naming Conventions + +| Concept | Convention | Example | +|---|---|---| +| Private fields | `_camelCase` | `_orderRepository` | +| Constants | `UPPER_SNAKE_CASE` | `CacheKey.PRODUCTS_BY_ID` | +| Async methods | `Async` suffix | `GetByIdAsync` | +| Event handlers | `EntityDeletedEventHandler` | `ProductDeletedEventHandler` | +| Command | `*Command` | `PlaceOrderCommand` | +| Query | `*Query` | `GetSuggestedProductsQuery` | +| Validator input record | `*ValidatorRecord` | `ShoppingCartStandardValidatorRecord` | + +--- + +## `using` Declaration for Disposables + +Use the modern `using` declaration instead of a `try/finally` block with `Dispose`. The variable is disposed at the end of the enclosing scope: + +```csharp +// correct — disposed when method exits +await using var response = await httpClient.GetStreamAsync(url); + +// also correct for sync disposables +using var stream = File.OpenRead(path); + +// old style — still valid but more verbose +using (var stream = File.OpenRead(path)) +{ + // ... +} +``` + +The `await using` form is required for `IAsyncDisposable` (e.g., async streams). + +## `var` Usage Conventions + +Use `var` when the type is obvious from the right-hand side (constructor, cast, literal). Don't use it when the type is only readable from a method name: + +```csharp +// clear — type is obvious +var product = new Product(); +var products = await _productRepository.GetByIdAsync(id); + +// prefer explicit — type is not obvious from method name alone +IList products = _catalogService.GetFeaturedProducts(); +``` + +## Anti-Patterns + +| Anti-pattern | Correct | +|---|---| +| `if (x == null) throw new ArgumentNullException(nameof(x))` | `ArgumentNullException.ThrowIfNull(x)` | +| Throwing `Exception` for a business rule violation | Return a result object with `.AddError(...)` | +| `IOptions` for configuration | `configuration.GetSection(...).Bind(config)` | +| String-based MongoDB queries | LINQ expression predicates | +| `null!` suppression without a documented reason | Remove the null, or handle it | +| `try/finally { Dispose() }` | `using var x = ...` or `await using var x = ...` | +| `new HttpClient()` in a service | Inject `IHttpClientFactory`, call `CreateClient()` | diff --git a/.ai/knowledge/module-types.md b/.ai/knowledge/module-types.md new file mode 100644 index 0000000000..80bc81e9b0 --- /dev/null +++ b/.ai/knowledge/module-types.md @@ -0,0 +1,101 @@ +# GrandNode Module Types + +## Existing Module Inventory +- `Grand.Module.Api` +- `Grand.Module.Installer` +- `Grand.Module.Migration` +- `Grand.Module.ScheduledTasks` + +## Common Module Structure +Use modules for application-level features that are not installable provider plugins. + +Use this structure unless the closest existing module uses a narrower shape: +- `{ModuleName}.csproj` +- `Startup/StartupApplication.cs` or `Infrastructure/*Startup.cs` +- `Controllers`, `Commands`, `Queries`, `Handlers`, `DTOs`, `Validators`, or services as needed +- module-specific configuration, mapping, endpoints, and tests + +## Project Rules +Set Debug and Release output paths to: + +```xml +..\..\Web\Grand.Web\Modules\{ModuleName}\ +$(OutputPath) +``` + +Use `Microsoft.NET.Sdk` unless Razor views are required. + +Use central package versions. Add `` without a version when listed in `Directory.Packages.props`. + +Set shared GrandNode project references to `Private=False` and `ExcludeAssets=runtime` when following existing module patterns. + +## Startup Rules +Use `IStartupApplication` for module service registration and middleware setup. + +Register services in `ConfigureServices`. + +Keep `Configure` empty unless the module owns middleware, endpoints, OpenAPI setup, CORS, authentication, or other pipeline behavior. + +Set `Priority` deliberately: +- Use nearby module priority for similar behavior. +- Use a higher priority only when dependencies must be registered after core services. +- Use `BeforeConfigure` only when middleware must run before normal application configuration. + +## API Module +Use `Grand.Module.Api` for REST API controllers, DTOs, validators, mapping profiles, JWT, OpenAPI, query filters, and API-specific security. + +Check: +- controller authorization +- model validation +- DTO mapping +- command and query handler behavior +- OpenAPI metadata +- status codes +- store, vendor, customer, and permission scope + +Combine with `api-review`, `security-review`, and `mongodb-review` when relevant. + +## Installer Module +Use `Grand.Module.Installer` for installation flow, initial data, permission seeding, settings seeding, store setup, and first-run behavior. + +Check: +- idempotency +- default admin and store setup +- seeded permissions +- seeded settings +- sample data safety +- localization resources +- database connection handling + +## Migration Module +Use `Grand.Module.Migration` for versioned data and configuration migrations. + +Check: +- version ordering +- idempotency +- resumability +- mixed-version compatibility +- data loss risk +- rollback expectations +- batch behavior for large collections + +Combine with `mongodb-review` for data migrations. + +## Scheduled Tasks Module +Use `Grand.Module.ScheduledTasks` for recurring background tasks. + +Register tasks with keyed scoped registration: + +```csharp +serviceCollection.AddKeyedScoped("Task name"); +``` + +Check: +- task name matches installed or migrated schedule task data +- idempotency +- cancellation behavior +- retry and partial failure behavior +- concurrent execution risk +- store, language, currency, and customer context +- logging and observability + diff --git a/.ai/knowledge/mongodb.md b/.ai/knowledge/mongodb.md new file mode 100644 index 0000000000..033c1b626d --- /dev/null +++ b/.ai/knowledge/mongodb.md @@ -0,0 +1,128 @@ +# Best Practice: MongoDB / Repository + +Patterns derived from `Grand.Data` and `Grand.Business.*`. Never bypass the repository abstraction. + +--- + +## IRepository\ — the only data access surface + +Inject `IRepository` where `T : BaseEntity`. Never inject `IMongoCollection` or `IMongoDatabase` directly in business code. + +```csharp +public class MyService +{ + private readonly IRepository _productRepository; + + public MyService(IRepository productRepository) + { + _productRepository = productRepository; + } +} +``` + +--- + +## Query Patterns + +### Fetch by ID + +```csharp +var product = await _productRepository.GetByIdAsync(id); +``` + +### Fetch single by predicate + +```csharp +var product = await _productRepository.GetOneAsync(p => p.Sku == sku); +``` + +### Fetch list with LINQ on `Table` + +`Table` is `IQueryable` backed by MongoDB driver; the query is translated and executed on the database, not in memory. + +```csharp +var query = from cr in _customerTagProductRepository.Table + where request.CustomerTagIds.Contains(cr.CustomerTagId) + orderby cr.DisplayOrder + select cr.ProductId; + +var ids = query.Take(limit).ToList(); +``` + +### Paginated list + +```csharp +public async Task> GetAllPickupPoints( + string storeId = "", int pageIndex = 0, int pageSize = int.MaxValue) +{ + var query = _pickupPointsRepository.Table; + if (!string.IsNullOrEmpty(storeId)) + query = query.Where(pp => pp.StoreId == storeId); + query = query.OrderBy(pp => pp.DisplayOrder); + return await PagedList.Create(query, pageIndex, pageSize); +} +``` + +--- + +## Write Patterns + +### Insert + +```csharp +await _repository.InsertAsync(entity); +``` + +After insert, publish the domain event: + +```csharp +await _mediator.EntityInserted(entity); +``` + +### Full document replace (update all fields) + +```csharp +await _repository.UpdateAsync(entity); +await _mediator.EntityUpdated(entity); +``` + +### Partial field update — prefer over full replace + +Use `UpdateField` / `UpdateOneAsync` with `UpdateBuilder` when only one or a few fields change. This reduces write size and avoids overwriting concurrent changes. + +```csharp +// update a single field +await _repository.UpdateField(id, x => x.DisplayOrder, newOrder); + +// update multiple fields +await _repository.UpdateOneAsync( + x => x.Id == id, + new UpdateBuilder() + .Set(x => x.Published, true) + .Set(x => x.UpdatedOnUtc, DateTime.UtcNow)); +``` + +### Delete + +```csharp +await _repository.DeleteAsync(entity); +await _mediator.EntityDeleted(entity); +``` + +--- + +## Anti-Patterns + +| Anti-pattern | Correct alternative | +|---|---| +| `_repository.Table.ToList()` on large collections | Add `.Where(...)` before `.ToList()`, or use `PagedList` | +| Fetching full document to update one field | `UpdateField(id, x => x.Field, value)` | +| Fetching by ID then checking null inside a loop | Batch by IDs with `GetProductsByIds` style method | +| String-interpolated filter conditions | LINQ expression predicate — never string concatenation | +| Calling `_repository.Table.Count()` without filter | Can be expensive on large collections; filter first | + +--- + +## Index Awareness + +Queries on un-indexed fields cause collection scans. Before adding a query on a new field, check whether a MongoDB index exists in the migration files under `Grand.Module.Migration`. If not, add one. diff --git a/.ai/knowledge/performance.md b/.ai/knowledge/performance.md new file mode 100644 index 0000000000..cee115e832 --- /dev/null +++ b/.ai/knowledge/performance.md @@ -0,0 +1,147 @@ +# Best Practice: Performance + +Patterns from `Grand.Infrastructure.Caching`, `Grand.Business.*`, and `Grand.Domain`. + +--- + +## Caching + +### Rule: every read-only service method that hits the DB must be cached + +Wrap database calls with `ICacheBase.GetAsync`. Direct repository calls without a cache check are a performance bug. + +```csharp +public virtual Task GetPickupPointById(string pickupPointId) +{ + var key = string.Format(CacheKey.PICKUPPOINTS_BY_ID_KEY, pickupPointId); + return _cacheBase.GetAsync(key, () => _pickupPointsRepository.GetByIdAsync(pickupPointId)); +} +``` + +### Define cache keys as `static string` in the `CacheKey` partial class + +There are 21+ `CacheKey` partial classes under `Grand.Infrastructure/Caching/Constants/`. Add your keys there, not as inline strings. + +```csharp +// in CacheKey partial for your domain area +public static string PICKUPPOINTS_BY_ID_KEY => "Grand.pickuppoint.id-{0}"; +public static string PICKUPPOINTS_PATTERN_KEY => "Grand.pickuppoint."; +``` + +Use a dedicated pattern key for invalidation by prefix: + +```csharp +await _cacheBase.RemoveByPrefix(CacheKey.PICKUPPOINTS_PATTERN_KEY); +``` + +### Invalidate after every write + +Call `RemoveByPrefix` or `RemoveAsync` in Insert/Update/Delete service methods so the cache stays consistent. + +```csharp +await _pickupPointsRepository.UpdateAsync(pickupPoint); +await _cacheBase.RemoveByPrefix(CacheKey.PICKUPPOINTS_PATTERN_KEY); +await _mediator.EntityUpdated(pickupPoint); +``` + +### Cache key format + +``` +"Grand.{entity}.{discriminator}-{param}" +``` + +Use `string.Format(CacheKey.MY_KEY, id)` at the call site, not string interpolation, to keep the key format centralised. + +--- + +## Pagination + +Never load an entire collection into memory. Use `PagedList` with explicit page parameters. + +```csharp +// admin list — large page size is acceptable +var list = await _service.GetAll(pageIndex: 0, pageSize: int.MaxValue); + +// storefront — always constrain +var list = await _service.GetAll(pageIndex: pageIndex, pageSize: pageSize); +``` + +`PagedList.Create(query, pageIndex, pageSize)` issues a `Count()` and a `Skip/Take` on the database, not in memory. + +--- + +## Partial Writes + +Replacing a full document when only one field changes wastes bandwidth and triggers index updates for every field. Use `UpdateField` for targeted writes: + +```csharp +// instead of: entity.Published = true; await _repo.UpdateAsync(entity); +await _repository.UpdateField(id, x => x.Published, true); +``` + +--- + +## Projection + +Fetch only the fields you need. On `Table` (IQueryable) this translates to a MongoDB projection: + +```csharp +var productIds = _productRepository.Table + .Where(p => p.Published) + .Select(p => p.Id) // projection — only Id is fetched + .ToList(); +``` + +Avoid pulling full documents just to read one property. + +--- + +## HTTP Clients + +Never `new HttpClient()` directly — use `IHttpClientFactory`. The push notifications service and exchange rate plugin are the canonical examples: + +```csharp +// Startup registration +serviceCollection.AddHttpClient(); + +// Named client registration (plugin) +serviceCollection.AddHttpClient(Constant.DefaultHttpClientName); + +// Consumption via IHttpClientFactory +public NbpExchange(IHttpClientFactory httpClientFactory) +{ + _httpClientFactory = httpClientFactory; +} + +var client = _httpClientFactory.CreateClient(Constant.DefaultHttpClientName); +``` + +Instantiating `HttpClient` directly leaves sockets in `TIME_WAIT` and exhausts available connections under load. + +## Exceptions Are Not Flow Control + +Throwing an exception is expensive (stack walk, allocation). Use result objects for expected outcomes: + +```csharp +// correct — expected failure as a result object +var result = new PlaceOrderResult(); +result.AddError("Product is out of stock"); +return result; + +// avoid — throwing for an expected condition +throw new Exception("Product is out of stock"); +``` + +This is enforced throughout the codebase by `PlaceOrderResult`, `CapturePaymentResult`, etc. + +## Anti-Patterns + +| Anti-pattern | Problem | Fix | +|---|---|---| +| Repository call without cache | DB hit on every request | Wrap in `ICacheBase.GetAsync` | +| `Table.ToList()` without filter | Full collection scan | Add `.Where(...)` first | +| Cache key as inline `$"key-{id}"` | Bypasses central key registry | Use `CacheKey.MY_KEY` constant | +| `int.MaxValue` page size on storefront | Returns entire collection to browser | Constrain to display page size | +| Full replace to update one field | Over-writes concurrent partial updates | Use `UpdateField` | +| `new HttpClient()` directly | Socket exhaustion under load | Use `IHttpClientFactory` | +| `throw new Exception(...)` for a business rule | Expensive; hard to handle | Return a result object | diff --git a/.ai/knowledge/plugin-types.md b/.ai/knowledge/plugin-types.md new file mode 100644 index 0000000000..89ced4b7ed --- /dev/null +++ b/.ai/knowledge/plugin-types.md @@ -0,0 +1,286 @@ +# GrandNode Plugin Types + +## Existing Plugin Inventory +- `Authentication.Facebook` +- `Authentication.Google` +- `DiscountRules.Standard` +- `ExchangeRate.McExchange` +- `Payments.BrainTree` +- `Payments.CashOnDelivery` +- `Payments.StripeCheckout` +- `Shipping.ByWeight` +- `Shipping.FixedRateShipping` +- `Shipping.ShippingPoint` +- `Tax.CountryStateZip` +- `Tax.FixedRate` +- `Theme.Modern` +- `Widgets.FacebookPixel` +- `Widgets.GoogleAnalytics` +- `Widgets.Slider` + +## Common Plugin Structure +Use this structure unless the closest existing plugin uses a narrower shape: + +- `{SystemName}.csproj` +- `Manifest.cs` +- `{Feature}Defaults.cs` +- `{Feature}Plugin.cs` +- `{Feature}Provider.cs` +- `{Feature}Settings.cs`, when settings are persisted +- `StartupApplication.cs` or `Infrastructure/StartupApplication.cs` +- `Areas/Admin/Controllers`, when admin configuration is required +- `Areas/Admin/Views`, when admin configuration uses Razor +- `Controllers`, `Views`, `Components`, or `EndpointProvider.cs`, when public UI or routes are required +- `logo.jpg`, when the plugin appears in plugin lists + +## Manifest Rules +Create `Manifest.cs` with an assembly-level `PluginInfo` attribute: + +```csharp +[assembly: PluginInfo( + FriendlyName = "Friendly name", + Group = "Plugin group", + SystemName = Defaults.ProviderSystemName, + Author = "grandnode team", + Version = "1.0.0" +)] +``` + +Use existing group names when possible: +- `Payment methods` +- `Shipping rate` +- `Tax providers` +- `Widgets` +- `External authentication` +- `Discount rules` +- `Exchange rate` +- `Themes` + +## Project Rules +Use `Microsoft.NET.Sdk.Razor` when the plugin contains Razor views. + +Set Debug and Release output paths to: + +```xml +..\..\Web\Grand.Web\Plugins\{SystemName}\ +$(OutputPath) +``` + +Use central package versions. Add `` without a version when the package is listed in `Directory.Packages.props`. + +Set shared GrandNode project references to `Private=false` or the local equivalent used by the nearest plugin. + +Copy plugin-owned static assets with `CopyToOutputDirectory` when needed. + +## Startup Rules +Register providers and plugin services in `StartupApplication : IStartupApplication`. + +Use scoped registration for providers unless the closest existing plugin uses a different lifetime: + +```csharp +services.AddScoped(); +``` + +Keep: +- `Priority => 10` unless startup order matters. +- `BeforeConfigure => false` unless middleware must run before normal configuration. +- Empty `Configure` unless middleware or endpoints are required. + +## Install And Uninstall Rules +Derive the plugin class from `BasePlugin` when installation or configuration behavior is needed. + +In `Install`: +- Save default settings with `ISettingService`. +- Add plugin-owned resources with `IPluginTranslateResource`. +- Call `base.Install()` last. + +In `Uninstall`: +- Delete plugin-owned settings. +- Delete plugin-owned resources. +- Call `base.Uninstall()` last. + +## Provider Contract +Every provider that implements `IProvider` must define: +- `ConfigurationUrl` +- `SystemName` +- `FriendlyName` +- `Priority` +- `LimitedToStores` +- `LimitedToGroups` + +Return the provider system name from a defaults constant. Keep manifest, provider, settings keys, route names, and plugin output folder aligned. + +## Payment Plugins +Use for checkout payment methods. + +Template examples: +- `Payments.StripeCheckout` for redirect payment. +- `Payments.CashOnDelivery` for simple offline payment. +- `Payments.BrainTree` for gateway integration. + +Implement: +- `IPaymentProvider` +- `{Feature}PaymentPlugin : BasePlugin, IPlugin` +- `{Feature}PaymentProvider` +- settings class +- configuration controller and view when configurable +- payment info route or redirect route when needed + +Check: +- `PaymentMethodType` +- `ProcessPayment` +- `PostProcessPayment` +- `PostRedirectPayment` +- `ValidatePaymentForm` +- `SavePaymentInfo` +- `Capture`, `Refund`, `Void`, and support flags +- `CanRePostRedirectPayment` +- `LogoURL` +- webhook or callback idempotency when external gateways are used +- transaction status transitions and order lookup safety + +Use the payment review skill for gateway correctness. + +## Shipping Plugins +Use for shipping rate calculation and pickup point logic. + +Template examples: +- `Shipping.FixedRateShipping` +- `Shipping.ByWeight` +- `Shipping.ShippingPoint` + +Implement: +- `IShippingRateCalculationProvider` +- plugin class deriving from `BasePlugin` +- settings or rate models when rates are configurable +- admin configuration controller and view when needed + +Check: +- country, state, warehouse, pickup, store, currency, and customer group behavior +- free shipping and shipping restriction interactions +- deterministic rate ordering +- handling of empty carts and non-shippable products + +## Tax Plugins +Use for tax rate calculation. + +Template examples: +- `Tax.FixedRate` +- `Tax.CountryStateZip` + +Implement: +- `ITaxProvider` +- plugin class deriving from `BasePlugin` +- settings or rate service when rates are configurable +- admin or store configuration views when needed + +Check: +- `TaxRequest` +- store-specific rates +- country, state, zip, tax category, and customer tax context +- default rate fallback +- precision and rounding expectations + +## Widget Plugins +Use for public or admin widgets rendered in widget zones. + +Template examples: +- `Widgets.GoogleAnalytics` +- `Widgets.FacebookPixel` +- `Widgets.Slider` + +Implement: +- `IWidgetProvider` +- plugin class deriving from `BasePlugin` +- view component and view +- settings and consent cookie when tracking or analytics are used +- widget zones returned by `GetWidgetZones` + +Check: +- exact widget zone names +- script injection safety +- consent behavior +- store and customer group limitations +- asset copy behavior + +## Authentication Plugins +Use for external login providers. + +Template examples: +- `Authentication.Google` +- `Authentication.Facebook` + +Implement: +- `IExternalAuthenticationProvider` +- plugin class deriving from `BasePlugin` +- authentication builder or endpoint provider when callback routes are required +- public view component for login button +- admin configuration controller and view + +Check: +- callback route names +- client ID and secret storage +- failed login handling +- account linking behavior +- event consumers that send registration or welcome messages + +Use the security review skill for OAuth, callbacks, secrets, and account linking. + +## Discount Rule Plugins +Use for discount requirement rules. + +Template example: +- `DiscountRules.Standard` + +Implement: +- `IDiscountProvider` +- one or more `IDiscountRule` implementations +- configuration controllers deriving from the existing discount rule base controller +- configuration views for each rule +- endpoint provider when routes are needed + +Check: +- `CheckRequirement` +- `GetConfigurationUrl` +- discount rule system names +- metadata parsing +- store, customer group, product, cart, and order scope +- permission checks for discount management + +## Exchange Rate Plugins +Use for currency exchange rate providers. + +Template example: +- `ExchangeRate.McExchange` + +Implement: +- `IExchangeRateProvider` +- provider-specific rate fetchers when multiple upstream sources exist +- plugin class deriving from `BasePlugin` when install state is enough + +Check: +- primary exchange currency support +- HTTP client usage +- upstream failure behavior +- currency code normalization +- duplicate or stale rates + +## Theme Plugins +Use for storefront theme replacement or extension. + +Template example: +- `Theme.Modern` + +Implement: +- theme plugin class deriving from `BasePlugin` +- theme view registration class when required by the existing theme pattern +- views under `Views/{ThemeName}` +- content under `Content` + +Check: +- view path and `_ViewStart.cshtml` +- shared layout and component overrides +- static asset copy behavior +- responsive behavior +- compatibility with storefront models and components + diff --git a/.ai/knowledge/repository-map.md b/.ai/knowledge/repository-map.md new file mode 100644 index 0000000000..fa8b7301f5 --- /dev/null +++ b/.ai/knowledge/repository-map.md @@ -0,0 +1,316 @@ +# GrandNode Repository Map + +## Root +- `GrandNode.slnx`: main solution. +- `global.json`: pinned .NET SDK behavior. +- `Directory.Packages.props`: central NuGet package versions. +- `src/Build`: common MSBuild props and targets. +- `src/Tests`: test projects. +- `src/Core`: domain, data, infrastructure, mapping, shared kernel. +- `src/Business`: business interfaces and implementations. +- `src/Web`: public site, admin panels, shared web libraries, frontend app. +- `src/Plugins`: installable provider plugins and themes. +- `src/Modules`: application modules. +- `src/Aspire`: local orchestration and service defaults. + +## Technologies In Use +- .NET and ASP.NET Core. +- MongoDB through repository/data abstractions. +- Redis for caching and data protection integration. +- Custom mediator in `Grand.Mediator` for commands, queries, and events, with a MediatR-compatible API (`IRequest`, `IRequestHandler`, `INotification`, `INotificationHandler`, `IMediator`). MediatR package is not used. +- FluentValidation for validators. +- Custom mapping framework in `Grand.Mapping` with an AutoMapper-compatible profile API (`Profile`, `CreateMap`, `ForMember`). AutoMapper package is not used. +- Razor views, partials, view components, and tag helpers. +- Vue 3 and Vite for storefront frontend assets. +- Bootstrap and Bootstrap Icons. +- DotLiquid for message templates. +- OpenAPI and Scalar in the API module. +- Aspire and OpenTelemetry for local orchestration and observability. +- Docker and Azure Pipelines for build and deployment workflows. + +## Core Projects + +### `src/Core/Grand.Domain` +Use for domain entities, settings objects, enums, and domain-level contracts. + +Examples: +- `Catalog` +- `Orders` +- `Customers` +- `Vendors` +- `Stores` +- `Payments` +- `Shipping` +- `Tax` +- `Permissions` +- `Messages` + +Do not put UI models, persistence details, or controller behavior here. + +### `src/Core/Grand.Data` +Use for repository abstractions and MongoDB/LiteDB data infrastructure. + +Use this area for: +- repository contracts and implementations +- MongoDB context behavior +- data provider configuration +- audit provider integration + +Do not put business workflow rules or UI preparation here. + +### `src/Core/Grand.Infrastructure` +Use for cross-cutting runtime infrastructure. + +Use this area for: +- startup abstractions +- caching +- plugin infrastructure +- module infrastructure +- endpoint provider infrastructure +- type scanning +- migrations infrastructure +- model binding +- validators infrastructure + +### `src/Core/Grand.Mapping` +Use for shared mapping infrastructure and mapping support. + +### `src/Core/Grand.Mediator` +Use for the mediator itself — request/notification abstractions, dispatch, and handler registration. +Handlers and the messages they handle belong to the owning project, never here. + +### `src/Core/Grand.SharedKernel` +Use for low-level shared primitives, attributes, extensions, and small cross-cutting helpers. + +## Business Projects + +### `src/Business/Grand.Business.Core` +Use for business interfaces, commands, queries, utilities, and cross-business contracts. + +Put interfaces here when implementations live in a feature-specific business project. + +### Feature Business Projects +Use these for implementations: +- `Grand.Business.Authentication` +- `Grand.Business.Catalog` +- `Grand.Business.Checkout` +- `Grand.Business.Cms` +- `Grand.Business.Common` +- `Grand.Business.Customers` +- `Grand.Business.Marketing` +- `Grand.Business.Messages` +- `Grand.Business.Storage` + +Use the closest feature project. Register implementations in that project's `Startup/StartupApplication.cs`. + +## Web Projects + +### `src/Web/Grand.Web` +Use for public storefront controllers, views, view models, commands, handlers, validators, components, and static web assets. + +Important folders: +- `Controllers` +- `Views` +- `Views/Shared` +- `Views/PdfTemplates` +- `Components` +- `Commands` +- `Features` +- `Models` +- `Validators` +- `wwwroot` +- `vueapp` + +### `src/Web/Grand.Web.Admin` +Use for main Admin panel controllers and views. + +Controllers are in: +- `src/Web/Grand.Web.Admin/Controllers` + +Views are in: +- `src/Web/Grand.Web.Admin/Areas/Admin/Views` + +### `src/Web/Grand.Web.Store` +Use for Store Owner panel controllers and views. + +Controllers are in: +- `src/Web/Grand.Web.Store/Controllers` + +Views are in: +- `src/Web/Grand.Web.Store/Areas/Store/Views` + +### `src/Web/Grand.Web.Vendor` +Use for Vendor panel controllers, views, models, services, components, and vendor-specific access behavior. + +Controllers are in: +- `src/Web/Grand.Web.Vendor/Controllers` + +Views are in: +- `src/Web/Grand.Web.Vendor/Areas/Vendor/Views` + +### `src/Web/Grand.Web.AdminShared` +Use for shared admin models, validators, mapper profiles, extensions, and contracts used by Admin, Store Owner, and sometimes Vendor panels. + +Check this project before adding duplicate admin models. + +### `src/Web/Grand.Web.Common` +Use for shared web infrastructure, base controllers, base components, themes, tag helpers, and common MVC helpers. + +### `src/Web/Grand.SharedUIResources` +Use for shared static UI resources. + +## Plugins +Plugins live under `src/Plugins`. + +Existing plugin categories: +- `Authentication.*` +- `DiscountRules.*` +- `ExchangeRate.*` +- `Payments.*` +- `Shipping.*` +- `Tax.*` +- `Theme.*` +- `Widgets.*` + +Use plugins for installable providers, integrations, widgets, payment methods, shipping methods, tax providers, authentication providers, discount rules, exchange rates, and themes. + +Plugin build output goes to: +- `src/Web/Grand.Web/Plugins/{SystemName}` + +Use the `plugin-module` skill for plugin work. + +## Modules +Modules live under `src/Modules`. + +Existing modules: +- `Grand.Module.Api` +- `Grand.Module.Installer` +- `Grand.Module.Migration` +- `Grand.Module.ScheduledTasks` + +Use modules for application-level features that are not installable provider plugins. + +Module build output goes to: +- `src/Web/Grand.Web/Modules/{ModuleName}` + +Use the `plugin-module` skill for module structure. + +## API Module +API functionality lives in: +- `src/Modules/Grand.Module.Api` + +Use for: +- REST controllers +- DTOs +- validators +- command and query handlers +- JWT +- OpenAPI metadata +- API mapping profiles + +## Installer And Migration +Installer behavior lives in: +- `src/Modules/Grand.Module.Installer` + +Migration behavior lives in: +- `src/Modules/Grand.Module.Migration` + +Use installer for initial seed data. Use migration for versioned updates to existing installations. + +Common seeded areas: +- permissions +- admin sitemap +- settings +- message templates +- stores +- schedule tasks +- localization resources + +## Scheduled Tasks +Scheduled task module: +- `src/Modules/Grand.Module.ScheduledTasks` + +Register tasks in: +- `Startup/StartupApplication.cs` + +Use keyed scoped registration: +- `AddKeyedScoped("Task name")` + +## Frontend Assets +Storefront frontend source: +- `src/Web/Grand.Web/vueapp` + +Committed frontend output: +- `src/Web/Grand.Web/wwwroot/bundles` + +Theme CSS source: +- `src/Web/Grand.Web/wwwroot/theme/css` + +Run frontend build when changing Vue source or theme CSS that affects committed bundles. + +Admin, Store and Vendor panel frontend project: +- `src/Web/Grand.SharedUIResources/adminapp` (Vite, Vitest, Playwright, Kendo grid analyzer) + +Its output `src/Web/Grand.SharedUIResources/wwwroot/administration/bundles` is not loaded by any panel and not committed yet. See `.ai/skills/frontend-bundle-workflow.md`. + +## Tests +Tests live under: +- `src/Tests` + +Choose the closest test project by target ownership: +- Business changes: `Grand.Business.*.Tests` +- Data changes: `Grand.Data.Tests` +- Domain changes: `Grand.Domain.Tests` +- Infrastructure changes: `Grand.Infrastructure.Tests` +- Mapping changes: `Grand.Mapping.Tests` +- Mediator changes: `Grand.Mediator.Tests` +- Module changes: `Grand.Modules.Tests` or module-specific tests +- API changes: `Grand.Module.Api.Tests` +- Web common changes: `Grand.Web.Common.Tests` +- Admin changes: `Grand.Web.Admin.Tests` +- Storefront changes: `Grand.Web.Tests` +- Store Owner changes: `Grand.Web.Store.Tests` +- Shared kernel changes: `Grand.SharedKernel.Tests` + +## Expansion Rules + +### Add Domain Data +1. Add or update entity or settings class in `Grand.Domain`. +2. Update data access behavior only if repository or MongoDB configuration requires it. +3. Update business services and interfaces. +4. Update UI/API models, mappers, validators, and tests. + +### Add Business Logic +1. Add interface to `Grand.Business.Core` when used across projects. +2. Add implementation to the closest `Grand.Business.*` project. +3. Register implementation in `StartupApplication`. +4. Add or update tests in the matching business test project. + +### Add Public Storefront UI +1. Update `Grand.Web` controller, command, query, handler, model, validator, component, or view. +2. Update localization resources. +3. Update frontend bundles when Vue or theme CSS source changes. +4. Add web tests when behavior changes. + +### Add Admin UI +1. Use `admin-area-changes`. +2. Update `Grand.Web.AdminShared` if shared models, validators, or mapper profiles are involved. +3. Update Admin, Store Owner, and Vendor panels only where the role should own the workflow. + +### Add Plugin Or Module +1. Use `plugin-module`. +2. Start from the closest existing plugin or module. +3. Keep output paths aligned with `Grand.Web/Plugins` or `Grand.Web/Modules`. + +### Add Message Template +1. Use `template-creation`. +2. Update `MessageTemplateNames` when code references the template. +3. Update installer seed data for new installations. +4. Add DotLiquid tokens only through `Liquid*` drops and token provider patterns. + +### Add Permission Or Menu Item +1. Update permission constants and seed data using existing installer utilities. +2. Update `StandardAdminSiteMap` when navigation is required. +3. Enforce permission server-side in controllers or services. +4. Check Admin, Store Owner, and Vendor differences. + diff --git a/.ai/knowledge/request-lifecycle.md b/.ai/knowledge/request-lifecycle.md new file mode 100644 index 0000000000..0f7fd28332 --- /dev/null +++ b/.ai/knowledge/request-lifecycle.md @@ -0,0 +1,126 @@ +# Request Lifecycle + +How a storefront or admin HTTP request becomes a rendered page. Read this before adding a controller action, middleware, or anything that depends on "the current customer/store". + +--- + +## Startup + +`src/Web/Grand.Web/Program.cs` is deliberately thin: + +```csharp +var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddAppSettingsJsonFile(args); +builder.AddServiceDefaults(); +StartupBase.ConfigureServices(builder.Services, builder.Configuration); +builder.ConfigureApplicationSettings(); +builder.Services.RegisterTasks(builder.Configuration); +var app = builder.Build(); +StartupBase.ConfigureRequestPipeline(app, builder.Environment); +await app.RunAsync(); +``` + +Everything else is discovered. `StartupBase` (`src/Core/Grand.Infrastructure/StartupBase.cs`) scans assemblies — including installed plugins and modules — for `IStartupApplication` and runs them ordered by `Priority`. + +**Never add registrations to `Program.cs`.** Add an `IStartupApplication` in the project that owns the service. + +### `IStartupApplication` + +```csharp +public interface IStartupApplication +{ + void ConfigureServices(IServiceCollection services, IConfiguration configuration); + void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment); + int Priority { get; } + bool BeforeConfigure { get; } +} +``` + +- `ConfigureServices` — DI registrations. Runs for every implementation, ordered by `Priority`. +- `Configure` — middleware. Runs in two passes: all `BeforeConfigure == true` implementations first (ordered by `Priority`), then all `BeforeConfigure == false`. +- `Priority` — lower runs earlier. Observed values in `Grand.Web.Common/Startup/`: + + | Priority | Startup | Purpose | + |---|---|---| + | `-50` | `UrlRewriteStartup` | rewrites before anything else sees the path | + | `-40` | `HostFilteringStartup` | | + | `-20` | `ForwardedHeadersStartup` | must run before scheme/IP is read | + | `-10` | `ErrorHandlerStartup` | wraps everything below it | + | `0` | `StartupApplication` | core service registrations | + | `100` | `GrandCommonStartup` | static files, powered-by, common middleware | + | `500` | `AuthenticationStartup` | auth + context middleware | + | `501` | `LoggerStartup` | | + | `1000` | `GrandMvcStartup` | endpoints — last | + | `10` | plugin startups (e.g. `Theme.Modern`) | | + +Pick a plugin/module priority by copying the nearest comparable component, not by inventing a number. + +## Per-request pipeline + +Ordered by the startups above: + +1. **Host filtering / forwarded headers / URL rewrite** — the request's real host, scheme, and path are settled. +2. **Error handling** — wraps everything downstream. +3. **Static files, powered-by header** (`GrandCommonStartup`). +4. **Install redirect** — `InstallUrlMiddleware` sends traffic to the installer when the DB is not installed. +5. **Authentication** — `UseGrandAuthentication()`. +6. **`ContextMiddleware`** — the important one, see below. +7. **`CultureSettingMiddleware`** — sets `CultureInfo` from the resolved working language. +8. **Endpoint routing / MVC** (`GrandMvcStartup`, priority 1000). + +### ContextMiddleware + +`src/Web/Grand.Web.Common/Middleware/ContextMiddleware.cs` resolves the ambient context for the request and stores it on `IContextAccessor`: + +```csharp +contextAccessor.StoreContext = await storeContextSetter.InitializeStoreContext(); +contextAccessor.WorkContext = await workContextSetter.InitializeWorkContext( + contextAccessor.StoreContext.CurrentStore.Id); +``` + +Consequences: + +- **Store is resolved before customer.** Customer resolution depends on the store — see `.ai/knowledge/scoping.md`. +- `IWorkContext` is **not** populated for requests whose route matches the skip list: `/scalar/{documentName}`, `/openapi/{documentName}.json`, `install`. Code reachable from those endpoints must not assume a current customer. +- Anything that runs *before* `ContextMiddleware` (priority < 500) cannot read `IWorkContext`. +- Background work — scheduled tasks, message queue processing — has no HTTP request and therefore no ambient context. Such code must take the store/customer explicitly. See `.ai/skills/scheduled-task.md`. + +`IWorkContext` exposes: `CurrentCustomer`, `OriginalCustomerIfImpersonated`, `CurrentVendor`, `WorkingLanguage`, `WorkingCurrency`, `StoreManager`, `TaxDisplayType`. + +## Controller → response + +``` +Controller action + └─ IMediator.Send(GetSomethingQuery) ← Grand.Web/Features/Handlers/… + └─ handler + └─ business service (Grand.Business.*) + └─ ICacheBase.GetAsync(key, …) ← Grand.Infrastructure/Caching + └─ IRepository ← Grand.Data + └─ MongoDB + └─ View(model) + └─ theme view-location resolution ← IThemeView, see theme-creation skill + └─ Razor view + view components + widget zones +``` + +Rules that fall out of this: + +1. Controllers hold no business logic. They resolve input, send a mediator request, and return a result. +2. Query handlers live in `Features/Handlers/`, command handlers in `Commands/Handler/`, mirroring the request type's folder. +3. Handlers prepare view models. Business services do not know about view models. +4. Caching sits in the business service, wrapping the repository call — not in the handler or controller. +5. Repository access happens through `IRepository`. The business layer never sees a `MongoCollection`. +6. After a write, the service publishes an entity event and clears the affected cache prefix. See `.ai/knowledge/domain-events.md` and `.ai/knowledge/caching.md`. + +## View resolution + +The active theme's `IThemeView.GetViewLocations()` is consulted before the default locations, so a theme overrides only the views it ships and everything else falls through to `Grand.Web/Views`. Widget zones are expanded by the core `Widget` view component, which asks every installed `IWidgetProvider` whether it renders in that zone. + +## Where things go + +| You are adding | Put it in | +|---|---| +| A page | controller action + mediator query + handler + view model + view | +| A cross-cutting request concern | middleware registered from an `IStartupApplication` | +| Service registration | `IStartupApplication.ConfigureServices` in the owning project | +| Something that must run before auth | `IStartupApplication` with `BeforeConfigure = true` and a low `Priority` | +| Something with no HTTP request | `IScheduleTask`, with explicit store/customer parameters | diff --git a/.ai/knowledge/scoping.md b/.ai/knowledge/scoping.md new file mode 100644 index 0000000000..f58444cf9a --- /dev/null +++ b/.ai/knowledge/scoping.md @@ -0,0 +1,96 @@ +# Scoping: Store, Vendor, Customer, Language, Currency + +GrandNode is multi-store and multi-vendor by default. Almost every leak of data across a boundary in this codebase is a missing scope filter. Read this before writing a query, a controller action, or an admin screen. + +--- + +## The five axes + +| Axis | Source | Read from | +|---|---|---| +| Store | resolved first, from host/route, by `IStoreContextSetter` | `IContextAccessor.StoreContext.CurrentStore` | +| Customer | resolved after store, by `IWorkContextSetter` | `IWorkContext.CurrentCustomer` | +| Vendor | the logged-in vendor manager, if any | `IWorkContext.CurrentVendor` | +| Language | customer preference, store default, or route | `IWorkContext.WorkingLanguage` | +| Currency | customer preference or store default | `IWorkContext.WorkingCurrency` | +| Tax display | derived from customer group and settings | `IWorkContext.TaxDisplayType` | + +Store manager context (`IWorkContext.StoreManager`) identifies the store a store-owner is administering, which is not necessarily the storefront store. + +Order matters: **store is resolved before customer**, because customer resolution depends on the store. Anything that assumes the reverse is wrong. + +## Store scoping + +Entities that can be limited to stores carry `LimitedToStores` + `Stores`. The pattern: + +```csharp +if (entity.LimitedToStores && !entity.Stores.Contains(currentStore.Id)) + return null; // or filter it out of the list +``` + +For queries, filter in the database, not after materialization: + +```csharp +query = query.Where(x => !x.LimitedToStores || x.Stores.Contains(storeId)); +``` + +Settings are store-scoped through `ISettingService` — a setting can have a global value and a per-store override. Loading a setting without a store id gives the global value, which is usually not what a storefront request wants. See `.ai/skills/settings-and-localization.md`. + +Cache keys for store-scoped data must include the store id: + +```csharp +var key = string.Format(CacheKey.TAXCATEGORIES_ALL_KEY, storeId); +``` + +Omitting the store id from a cache key makes store A serve store B's data. This is the single most common scoping bug. + +## Vendor scoping + +A vendor manager may only see and modify records their vendor owns. + +- Filter by `VendorId` in the query, never in the view. +- On write, verify the loaded entity's `VendorId` matches `IWorkContext.CurrentVendor?.Id` before saving. An id in a form post is attacker-controlled. +- Vendor area views must not expose admin-only actions. See `.ai/knowledge/template-types.md`. + +## Customer group scoping + +Entities limited to customer groups carry `LimitedToGroups` + `CustomerGroups`. Same shape as store limiting: + +```csharp +if (entity.LimitedToGroups && + !entity.CustomerGroups.Intersect(customer.Groups).Any()) + return null; +``` + +Providers (`IProvider`) carry both `LimitedToStores` and `LimitedToGroups` — a payment or shipping method can be hidden per store and per customer group. + +## Language scoping + +Localized entity properties live in a `LocalizedProperty` collection on the entity, resolved through the `GetTranslation` extension with `IWorkContext.WorkingLanguage.Id`. Do not read the raw property for display, and do not cache a localized projection without the language id in the key. + +Localization *resources* (UI strings) are separate — `ITranslationService` / `LocService` in views. See `.ai/skills/settings-and-localization.md`. + +## Currency scoping + +Prices are stored in the store's primary currency and converted for display. Do not persist a converted value. Do not compare a converted price against a stored one. + +## Where the ambient context is not available + +`IWorkContext` is populated by `ContextMiddleware`, which is skipped for `/scalar/*`, `/openapi/*.json`, and `install`, and does not exist at all for: + +- scheduled tasks +- message queue / email sending +- migrations +- plugin `Install()` / `Uninstall()` + +Code in those paths must receive store, customer, or language **explicitly as parameters**. Reaching for `IWorkContext` there yields null or a stale context. + +## Review checklist + +- [ ] Every list query filters by store, and by vendor when the caller is a vendor. +- [ ] Every write re-checks ownership against the server-side context, not the posted id. +- [ ] Every cache key for scoped data includes store id (and language id when localized). +- [ ] Settings are loaded with the correct store id. +- [ ] Localized values are read through the translation extension with the working language. +- [ ] Background code takes scope as a parameter instead of reading `IWorkContext`. +- [ ] Admin, store-area, and vendor-area versions of the same screen each apply their own scope — reusing a shared model does not reuse the filter. diff --git a/.ai/knowledge/security.md b/.ai/knowledge/security.md new file mode 100644 index 0000000000..5fdb92e064 --- /dev/null +++ b/.ai/knowledge/security.md @@ -0,0 +1,119 @@ +# Best Practice: Security + +Patterns from `Grand.Web`, `Grand.Module.Api`, `Grand.Business.*`. Complementary to `.ai/skills/security-review.md`. + +--- + +## Authorization + +### Check permission before touching data + +Every API and admin controller action must enforce permissions (either via `[PermissionAuthorize]` / `[PermissionAuthorizeAction]` attributes or explicit `IPermissionService.Authorize` checks) before returning any data or performing any mutation. + +```csharp +public async Task Get() +{ + if (!await _permissionService.Authorize(PermissionSystemName.Brands)) + return Forbid(); + + return Ok(await _mediator.Send(new GetGenericQuery())); +} +``` + +Return `Forbid()` (403), not `Unauthorized()` (401), when the user is authenticated but lacks the required permission. 401 implies "not logged in". + +### Use `[PermissionAuthorize]` for simple cases + +For a controller where every action requires the same permission, prefer the attribute over per-action code: + +```csharp +[PermissionAuthorize(PermissionSystemName.Brands)] +public class BrandController : BaseAdminController { ... } +``` + +--- + +## Input Validation + +### Use FluentValidation with record input types + +Define a record for the validator's subject to make the input immutable and explicit: + +```csharp +public record ShoppingCartStandardValidatorRecord( + Customer Customer, + Product Product, + ShoppingCartItem ShoppingCartItem); + +public class ShoppingCartStandardValidator : AbstractValidator +{ + public ShoppingCartStandardValidator(ITranslationService translationService, IAclService aclService) + { + RuleFor(x => x).Custom((value, context) => + { + if (!value.Product.Published) + context.AddFailure(translationService.GetResource("ShoppingCart.ProductUnpublished")); + }); + } +} +``` + +### Guard at service entry points + +Use `ArgumentNullException.ThrowIfNull` / `ThrowIfNullOrEmpty` at the top of every service method that accepts reference parameters: + +```csharp +public async Task SaveDeliveryDate(DeliveryDate deliveryDate) +{ + ArgumentNullException.ThrowIfNull(deliveryDate); + // ... +} +``` + +--- + +## Output Encoding + +### HTML-encode user-controlled text before rendering + +Any text that originates from user input and is rendered into HTML must be encoded with `WebUtility.HtmlEncode`: + +```csharp +name = WebUtility.HtmlEncode(_product.GetTranslation(x => x.Name, _language.Id)); +``` + +In Razor views, `@value` auto-encodes. Never use `@Html.Raw(userInput)`. + +--- + +## Query Safety + +### Never build MongoDB filter strings from user input + +Use LINQ expression predicates or typed `FilterDefinition`. The MongoDB LINQ provider translates them to safe queries — there is no query injection risk through typed expressions. + +```csharp +// safe — LINQ expression +var product = await _repository.GetOneAsync(p => p.Sku == userInput); + +// never — would open injection if the driver used string parsing +var product = await collection.Find($"{{ sku: '{userInput}' }}").FirstOrDefaultAsync(); +``` + +--- + +## Secrets + +Never commit connection strings, API keys, or credentials to source. Use `appsettings.json` with `UserSecrets` in development and environment variables / Azure Key Vault in production. No hardcoded credentials anywhere in code. + +--- + +## Summary Checklist + +- [ ] Every action checks `IPermissionService.Authorize` before data access +- [ ] Returns `Forbid()` not `Unauthorized()` for permission failures +- [ ] Business rule validation uses FluentValidation with record input types +- [ ] Service entry points guard null arguments with `ArgumentNullException.ThrowIfNull` +- [ ] User text rendered as HTML goes through `WebUtility.HtmlEncode` +- [ ] MongoDB queries use LINQ expressions, never string interpolation +- [ ] No secrets in source code diff --git a/.ai/knowledge/template-types.md b/.ai/knowledge/template-types.md new file mode 100644 index 0000000000..dac6cb9ce2 --- /dev/null +++ b/.ai/knowledge/template-types.md @@ -0,0 +1,141 @@ +# GrandNode Template Types + +## Public Storefront Razor Views +Use for customer-facing pages under: +- `src/Web/Grand.Web/Views` +- `src/Web/Grand.Web/Views/Shared` +- `src/Web/Grand.Web/Views/Shared/Partials` +- `src/Web/Grand.Web/Views/Shared/Components` + +Follow existing storefront patterns: +- Use `@model` where nearby views use strongly typed models. +- Use `Loc[...]` or `@Loc[...]` for localized text. +- Use route names such as `Url.RouteUrl(...)` when nearby templates use named routes. +- Preserve `data-cart-action`, product IDs, quick view URLs, wishlist, compare, and add-to-cart attributes. +- Preserve image `alt`, `title`, `loading`, and priority behavior. +- Keep widget zones through `Component.InvokeAsync("Widget", ...)` where they already exist. + +## Admin Views +Use for admin pages under: +- `src/Web/Grand.Web.Admin/Areas/Admin/Views` +- `src/Web/Grand.Web.AdminShared` +- plugin `Areas/Admin/Views` + +Follow existing admin patterns: +- Use admin tag helpers for labels, inputs, validation, cards, tabs, and grids. +- Use existing Kendo grid and AJAX conventions. +- Call `addAntiForgeryToken(data)` for AJAX mutations when nearby views do. +- Preserve permission assumptions from controllers and surrounding templates. +- Keep partial names like `CreateOrUpdate.TabInfo.cshtml`, `CreateOrUpdate.cshtml`, and popup views when nearby views use them. + +## Store Area Views +Use for store management pages under: +- `src/Web/Grand.Web.Store/Areas/Store/Views` + +Follow existing store-area patterns: +- Keep store-specific permissions and scope visible in forms and grids. +- Reuse partial names and tab structure from nearby entities. +- Preserve store-scoped service behavior by not adding client-only filtering. + +## Vendor Area Views +Use for vendor pages under: +- `src/Web/Grand.Web.Vendor/Areas/Vendor/Views` + +Follow existing vendor patterns: +- Preserve vendor-specific layout and navigation partials. +- Do not expose admin-only actions. +- Keep vendor data filtered by vendor-owned records. +- Use vendor components under `Views/Shared/Components` when the UI is reusable. + +## Plugin Views +Use for plugin-owned UI under: +- `src/Plugins/{SystemName}/Views` +- `src/Plugins/{SystemName}/Areas/Admin/Views` +- `src/Plugins/{SystemName}/Areas/Store/Views` + +Follow existing plugin patterns: +- Add `_ViewImports.cshtml` where the plugin area requires tag helpers or namespaces. +- Use `BaseAdminPluginController` or the existing plugin controller base for admin views. +- Keep plugin configuration views under the plugin folder. +- Ensure the plugin project uses Razor support when views are present. +- Ensure plugin project output copies views and assets into `src/Web/Grand.Web/Plugins/{SystemName}`. +- Add or update plugin localization resources during install and uninstall. + +## Theme Views +Use for theme-specific storefront overrides under: +- `src/Plugins/Theme.Modern/Views/Modern` + +Follow existing theme patterns: +- Keep `_ViewStart.cshtml` and `_ViewImports.cshtml` behavior intact. +- Match base storefront model types. +- Override only the views needed by the theme. +- Keep theme assets under the theme plugin `Content` folder. +- Preserve shared component view paths such as `Views/Modern/Shared/Components/{Component}/Default.cshtml`. + +## View Components +Use when markup needs independently loaded data or existing component extension points. + +Follow existing component patterns: +- Component class lives near the owning web or plugin project. +- Default view path is `Views/Shared/Components/{ComponentName}/Default.cshtml` or the plugin equivalent. +- Use view components for widgets, navigation blocks, product blocks, footer, menus, and externally supplied provider UI. +- Keep component names aligned with provider methods such as `GetPublicViewComponentName`. + +## Razor Partials +Use for reusable fragments under `Partials` folders. + +Follow existing partial patterns: +- Name private layout fragments with a leading underscore when nearby views do. +- Name entity edit tabs as `CreateOrUpdate.Tab{Name}.cshtml`. +- Pass explicit models to ``. +- Avoid hidden dependencies on parent `ViewData` unless nearby partials use the same convention. + +## Vue-In-Razor Templates +Use for in-page Vue component templates embedded in `.cshtml`. + +Follow existing patterns: +- Use ` -
-
- -
- -
- -
- -
-
-
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HadSpentAmount/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HadSpentAmount/Configure.cshtml deleted file mode 100644 index 1ac04e3dcb..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HadSpentAmount/Configure.cshtml +++ /dev/null @@ -1,53 +0,0 @@ -@{ - Layout = ""; -} -@using System.Text.Encodings.Web -@model DiscountRules.Standard.Models.RequirementSpentAmountModel - - - -
-
- -
-
- - -
- -
-
-
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/Configure.cshtml deleted file mode 100644 index 4dc95039a7..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/Configure.cshtml +++ /dev/null @@ -1,126 +0,0 @@ -@{ - Layout = ""; -} -@using System.Text.Encodings.Web -@model DiscountRules.Standard.Models.RequirementAllProductsModel - - - -
- -
- - - - - - - - - -
-
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/ProductAddPopup.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/ProductAddPopup.cshtml deleted file mode 100644 index f8626c1cb9..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasAllProducts/ProductAddPopup.cshtml +++ /dev/null @@ -1,200 +0,0 @@ -@{ - Layout = ""; -} -@using Grand.Domain.Common -@model DiscountRules.Standard.Models.RequirementAllProductsModel.AddProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Plugins.DiscountRules.HasAllProducts.Fields.Products.Choose"]; -} -
- -
-
-
-
-
- - @Loc["Plugins.DiscountRules.HasAllProducts.Fields.Products.Choose"] -
-
-
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
- -
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/Configure.cshtml deleted file mode 100644 index 55b30a8808..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/Configure.cshtml +++ /dev/null @@ -1,125 +0,0 @@ -@{ - Layout = ""; -} -@using System.Text.Encodings.Web -@model DiscountRules.Standard.Models.RequirementOneProductModel - - - -
- -
- - - - - - - - -
- - -
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/ProductAddPopup.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/ProductAddPopup.cshtml deleted file mode 100644 index fc917a7eec..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/HasOneProduct/ProductAddPopup.cshtml +++ /dev/null @@ -1,199 +0,0 @@ -@{ - Layout = ""; -} -@using Grand.Domain.Common -@model DiscountRules.Standard.Models.RequirementOneProductModel.AddProductModel -@inject AdminAreaSettings adminAreaSettings -@{ - //page title - ViewBag.Title = Loc["Plugins.DiscountRules.HasOneProduct.Fields.Products.Choose"]; -} -
- -
-
-
-
-
- - @Loc["Plugins.DiscountRules.HasOneProduct.Fields.Products.Choose"] -
-
-
-
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
- -
- -
-
-
-
-
- - -
-
-
-
-
-
-
-
-
-
- -
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/ShoppingCartAmount/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/ShoppingCartAmount/Configure.cshtml deleted file mode 100644 index 3b7e2f03e4..0000000000 --- a/src/Plugins/DiscountRules.Standard/Areas/Admin/Views/ShoppingCartAmount/Configure.cshtml +++ /dev/null @@ -1,45 +0,0 @@ -@{ - Layout = ""; -} -@using System.Text.Encodings.Web -@model DiscountRules.Standard.Models.RequirementShoppingCartModel - - -
-
- -
-
- @Html.EditorFor(model => model.SpentAmount) - -
- -
-
-
\ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Controllers/BaseDiscountRulePluginController.cs b/src/Plugins/DiscountRules.Standard/Controllers/BaseDiscountRulePluginController.cs new file mode 100644 index 0000000000..806f0fc744 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/BaseDiscountRulePluginController.cs @@ -0,0 +1,9 @@ +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Microsoft.AspNetCore.Mvc; + +namespace DiscountRules.Standard.Controllers; + +[AuthorizeAdminOrStore] +[AutoValidateAntiforgeryToken] +public abstract class BaseDiscountRulePluginController : BaseController; diff --git a/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesCustomerGroupsController.cs b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesCustomerGroupsController.cs new file mode 100644 index 0000000000..f4c836c949 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesCustomerGroupsController.cs @@ -0,0 +1,100 @@ +using DiscountRules.Standard.Models; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Permissions; +using Grand.Domain.Discounts; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace DiscountRules.Standard.Controllers; + +public class DiscountRulesCustomerGroupsController : BaseDiscountRulePluginController +{ + private readonly IDiscountService _discountService; + private readonly IGroupService _groupService; + private readonly IPermissionService _permissionService; + + public DiscountRulesCustomerGroupsController( + IDiscountService discountService, + IGroupService groupService, + IPermissionService permissionService) + { + _discountService = discountService; + _groupService = groupService; + _permissionService = permissionService; + } + + public async Task Configure(string discountId, string discountRequirementId) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + DiscountRule discountRequirement = null; + if (!string.IsNullOrEmpty(discountRequirementId)) + { + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + if (discountRequirement == null) + return Content("Failed to load requirement."); + } + + var model = new RequirementCustomerGroupsModel { + RequirementId = !string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "", + DiscountId = discountId, + CustomerGroupId = discountRequirement?.Metadata + }; + + //customer groups + model.AvailableCustomerGroups.Add(new SelectListItem { Text = "Select customer group", Value = "" }); + foreach (var cr in await _groupService.GetAllCustomerGroups(showHidden: true)) + model.AvailableCustomerGroups.Add(new SelectListItem { + Text = cr.Name, Value = cr.Id, + Selected = discountRequirement != null && cr.Id == discountRequirement.Metadata + }); + + //add a prefix + ViewData.TemplateInfo.HtmlFieldPrefix = + $"DiscountRulesCustomerGroups{(!string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "")}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(string discountId, string discountRequirementId, string customerGroupId) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + DiscountRule discountRequirement = null; + if (!string.IsNullOrEmpty(discountRequirementId)) + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + + if (discountRequirement != null) + { + //update existing rule + discountRequirement.Metadata = customerGroupId; + await _discountService.UpdateDiscount(discount); + } + else + { + //save new rule + discountRequirement = new DiscountRule { + DiscountRequirementRuleSystemName = "DiscountRules.Standard.MustBeAssignedToCustomerGroup", + Metadata = customerGroupId + }; + discount.DiscountRules.Add(discountRequirement); + await _discountService.UpdateDiscount(discount); + } + + return Json(new { Result = true, NewRequirementId = discountRequirement.Id }); + } +} \ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHadSpentAmountController.cs b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHadSpentAmountController.cs new file mode 100644 index 0000000000..818f39afca --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHadSpentAmountController.cs @@ -0,0 +1,90 @@ +using DiscountRules.Standard.Models; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Permissions; +using Grand.Domain.Discounts; +using Microsoft.AspNetCore.Mvc; +using System.Globalization; + +namespace DiscountRules.Standard.Controllers; + +public class DiscountRulesHadSpentAmountController : BaseDiscountRulePluginController +{ + private readonly IDiscountService _discountService; + private readonly IPermissionService _permissionService; + + public DiscountRulesHadSpentAmountController( + IDiscountService discountService, + IPermissionService permissionService) + { + _discountService = discountService; + _permissionService = permissionService; + } + + public async Task Configure(string discountId, string discountRequirementId) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + double spentAmountRequirement = 0; + if (!string.IsNullOrEmpty(discountRequirementId)) + { + var discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + if (discountRequirement == null) + return Content("Failed to load requirement."); + + spentAmountRequirement = Convert.ToDouble(discountRequirement.Metadata); + } + + var model = new RequirementSpentAmountModel { + RequirementId = !string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "", + DiscountId = discountId, + SpentAmount = spentAmountRequirement + }; + + //add a prefix + ViewData.TemplateInfo.HtmlFieldPrefix = + $"DiscountRulesHadSpentAmount{discount.Id}-{(!string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "")}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(string discountId, string discountRequirementId, double spentAmount) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + DiscountRule discountRequirement = null; + if (!string.IsNullOrEmpty(discountRequirementId)) + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + + if (discountRequirement != null) + { + //update existing rule + discountRequirement.Metadata = spentAmount.ToString(CultureInfo.InvariantCulture); + await _discountService.UpdateDiscount(discount); + } + else + { + //save new rule + discountRequirement = new DiscountRule { + DiscountRequirementRuleSystemName = "DiscountRules.Standard.HadSpentAmount", + Metadata = spentAmount.ToString(CultureInfo.InvariantCulture) + }; + discount.DiscountRules.Add(discountRequirement); + await _discountService.UpdateDiscount(discount); + } + + return new JsonResult(new { Result = true, NewRequirementId = discountRequirement.Id }); + } +} \ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasAllProductsController.cs b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasAllProductsController.cs new file mode 100644 index 0000000000..16c754fec8 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasAllProductsController.cs @@ -0,0 +1,257 @@ +using DiscountRules.Standard.Models; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Catalog; +using Grand.Domain.Discounts; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace DiscountRules.Standard.Controllers; + +public class DiscountRulesHasAllProductsController : BaseDiscountRulePluginController +{ + private readonly IDiscountService _discountService; + private readonly IPermissionService _permissionService; + private readonly IProductService _productService; + private readonly IStoreService _storeService; + private readonly ITranslationService _translationService; + private readonly IVendorService _vendorService; + private readonly IContextAccessor _contextAccessor; + private readonly IEnumTranslationService _enumTranslationService; + + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + public DiscountRulesHasAllProductsController(IDiscountService discountService, + IPermissionService permissionService, + IContextAccessor contextAccessor, + ITranslationService translationService, + IStoreService storeService, + IVendorService vendorService, + IProductService productService, + IEnumTranslationService enumTranslationService) + { + _discountService = discountService; + _permissionService = permissionService; + _contextAccessor = contextAccessor; + _translationService = translationService; + _storeService = storeService; + _vendorService = vendorService; + _productService = productService; + _enumTranslationService = enumTranslationService; + } + + public async Task Configure(string discountId, string discountRequirementId) + { + if (!await AuthorizeManageDiscounts()) + return Content("Access denied"); + + var discount = await GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + var restrictedProductIds = string.Empty; + + if (!string.IsNullOrEmpty(discountRequirementId)) + { + var discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + if (discountRequirement == null) + return Content("Failed to load requirement."); + + restrictedProductIds = discountRequirement.Metadata; + } + + var model = new RequirementAllProductsModel { + RequirementId = !string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "", + DiscountId = discountId, + Products = restrictedProductIds + }; + + //add a prefix + ViewData.TemplateInfo.HtmlFieldPrefix = + $"DiscountRulesHasAllProducts{discount.Id}-{(!string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "")}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(string discountId, string discountRequirementId, string productIds) + { + if (!await AuthorizeManageDiscounts()) + return Content("Access denied"); + + var discount = await GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + DiscountRule discountRequirement = null; + + if (!string.IsNullOrEmpty(discountRequirementId)) + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + + if (discountRequirement != null) + { + //update existing rule + discountRequirement.Metadata = productIds; + await _discountService.UpdateDiscount(discount); + } + else + { + //save new rule + discountRequirement = new DiscountRule { + DiscountRequirementRuleSystemName = "DiscountRules.HasAllProducts", + Metadata = productIds + }; + discount.DiscountRules.Add(discountRequirement); + await _discountService.UpdateDiscount(discount); + } + + return new JsonResult(new { Result = true, NewRequirementId = discountRequirement.Id }); + } + + public async Task ProductAddPopup(string btnId, string productIdsInput) + { + if (!await AuthorizeManageProducts()) + return Content("Access denied"); + + var model = new RequirementAllProductsModel.AddProductModel { + //a vendor should have access only to his products + IsLoggedInAsVendor = _contextAccessor.WorkContext.CurrentVendor != null + }; + + //stores - when acting as a store manager, scope to their store + if (!string.IsNullOrEmpty(CurrentStoreId)) + { + var store = await _storeService.GetStoreById(CurrentStoreId); + if (store != null) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } + else + { + model.AvailableStores.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var s in await _storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + + //vendors (only shown to admins) + model.AvailableVendors.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var v in await _vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } + + //product types + model.AvailableProductTypes = _enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); + model.AvailableProductTypes.Insert(0, + new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + + ViewBag.productIdsInput = productIdsInput; + ViewBag.btnId = btnId; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task ProductAddPopupList(DataSourceRequest command, + RequirementAllProductsModel.AddProductModel model) + { + if (!await AuthorizeManageProducts()) + return Content("Access denied"); + + //a vendor should have access only to his products + if (_contextAccessor.WorkContext.CurrentVendor != null) model.SearchVendorId = _contextAccessor.WorkContext.CurrentVendor.Id; + //a store manager should only search within their store + if (!string.IsNullOrEmpty(CurrentStoreId)) model.SearchStoreId = CurrentStoreId; + + var searchCategoryIds = new List(); + if (!string.IsNullOrEmpty(model.SearchCategoryId)) + searchCategoryIds.Add(model.SearchCategoryId); + + var products = (await _productService.SearchProducts( + categoryIds: searchCategoryIds, + collectionId: model.SearchCollectionId, + storeId: model.SearchStoreId, + vendorId: model.SearchVendorId, + productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, + keywords: model.SearchProductName, + pageIndex: command.Page - 1, + pageSize: command.PageSize, + showHidden: true + )).products; + var gridModel = new DataSourceResult { + Data = products.Select(x => new RequirementAllProductsModel.ProductModel { + Id = x.Id, + Name = x.Name, + Published = x.Published + }), + Total = products.TotalCount + }; + + return new JsonResult(gridModel); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task LoadProductFriendlyNames(string productIds) + { + var result = ""; + + if (!await AuthorizeManageProducts()) + return new JsonResult(new { Text = result }); + + if (string.IsNullOrWhiteSpace(productIds)) return new JsonResult(new { Text = result }); + var ids = new List(); + var rangeArray = productIds + .Split([','], StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .ToList(); + + //we support three ways of specifying products: + //1. The comma-separated list of product identifiers (e.g. 77, 123, 156). + //2. The comma-separated list of product identifiers with quantities. + // {Product ID}:{Quantity}. For example, 77:1, 123:2, 156:3 + //3. The comma-separated list of product identifiers with quantity range. + // {Product ID}:{Min quantity}-{Max quantity}. For example, 77:1-3, 123:2-5, 156:3-8 + foreach (var str1 in rangeArray) + { + var str2 = str1; + //we do not display specified quantities and ranges + //parse only product names + if (str2.Contains(':')) + str2 = str2[..str2.IndexOf(":", StringComparison.Ordinal)]; + + ids.Add(str2); + } + + var products = await _productService.GetProductsByIds(ids.ToArray(), true); + for (var i = 0; i <= products.Count - 1; i++) + { + result += products[i].Name; + if (i != products.Count - 1) + result += ", "; + } + + return new JsonResult(new { Text = result }); + } + + private async Task AuthorizeManageDiscounts() + { + return await _permissionService.Authorize(StandardPermission.ManageDiscounts); + } + + private async Task AuthorizeManageProducts() + { + return await _permissionService.Authorize(StandardPermission.ManageProducts); + } + + private async Task GetDiscountById(string discountId) + { + return await _discountService.GetDiscountById(discountId); + } +} diff --git a/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasOneProductController.cs b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasOneProductController.cs new file mode 100644 index 0000000000..b214e08ad7 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesHasOneProductController.cs @@ -0,0 +1,231 @@ +using DiscountRules.Standard.Models; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Catalog.Products; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Domain.Catalog; +using Grand.Domain.Discounts; +using Grand.Domain.Permissions; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Web.Common.DataSource; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace DiscountRules.Standard.Controllers; + +public class DiscountRulesHasOneProductController : BaseDiscountRulePluginController +{ + private readonly IDiscountService _discountService; + private readonly IPermissionService _permissionService; + private readonly IProductService _productService; + private readonly IStoreService _storeService; + private readonly ITranslationService _translationService; + private readonly IVendorService _vendorService; + private readonly IContextAccessor _contextAccessor; + private readonly IEnumTranslationService _enumTranslationService; + + private Vendor CurrentVendor => _contextAccessor.WorkContext.CurrentVendor; + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public DiscountRulesHasOneProductController(IDiscountService discountService, + IPermissionService permissionService, + IContextAccessor contextAccessor, + ITranslationService translationService, + IStoreService storeService, + IVendorService vendorService, + IProductService productService, + IEnumTranslationService enumTranslationService) + { + _discountService = discountService; + _permissionService = permissionService; + _contextAccessor = contextAccessor; + _translationService = translationService; + _storeService = storeService; + _vendorService = vendorService; + _productService = productService; + _enumTranslationService = enumTranslationService; + } + + private async Task AuthorizeAsync(Permission permission) + { + if (!await _permissionService.Authorize(permission)) + return Content("Access denied"); + return null; + } + + private async Task GetDiscountAsync(string discountId) + { + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + return discount; + } + + public async Task Configure(string discountId, string discountRequirementId) + { + var authResult = await AuthorizeAsync(StandardPermission.ManageDiscounts); + if (authResult != null) return authResult; + + var discount = await GetDiscountAsync(discountId); + + var restrictedProductIds = string.Empty; + if (!string.IsNullOrEmpty(discountRequirementId)) + { + var discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + if (discountRequirement == null) + return Content("Failed to load requirement."); + + restrictedProductIds = discountRequirement.Metadata; + } + + var model = new RequirementOneProductModel { + RequirementId = !string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "", + DiscountId = discountId, + Products = restrictedProductIds + }; + + //add a prefix + ViewData.TemplateInfo.HtmlFieldPrefix = + $"DiscountRulesHasOneProduct{discount.Id}-{(!string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "")}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(string discountId, string discountRequirementId, string productIds) + { + var authResult = await AuthorizeAsync(StandardPermission.ManageDiscounts); + if (authResult != null) return authResult; + + var discount = await GetDiscountAsync(discountId); + + DiscountRule discountRequirement = null; + if (!string.IsNullOrEmpty(discountRequirementId)) + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + + if (discountRequirement != null) + { + //update existing rule + discountRequirement.Metadata = productIds; + await _discountService.UpdateDiscount(discount); + } + else + { + //save new rule + discountRequirement = new DiscountRule { + DiscountRequirementRuleSystemName = "DiscountRules.HasOneProduct", + Metadata = productIds + }; + discount.DiscountRules.Add(discountRequirement); + await _discountService.UpdateDiscount(discount); + } + + return new JsonResult(new { Result = true, NewRequirementId = discountRequirement.Id }); + } + + public async Task ProductAddPopup(string btnId, string productIdsInput) + { + var authResult = await AuthorizeAsync(StandardPermission.ManageProducts); + if (authResult != null) return authResult; + + var model = new RequirementOneProductModel.AddProductModel { + //a vendor should have access only to his products + IsLoggedInAsVendor = CurrentVendor != null + }; + + //stores - when acting as a store manager, scope to their store + if (!string.IsNullOrEmpty(CurrentStoreId)) + { + var store = await _storeService.GetStoreById(CurrentStoreId); + if (store != null) + model.AvailableStores.Add(new SelectListItem { Text = store.Shortcut, Value = store.Id }); + } + else + { + model.AvailableStores.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var s in await _storeService.GetAllStores()) + model.AvailableStores.Add(new SelectListItem { Text = s.Shortcut, Value = s.Id }); + + //vendors (only shown to admins) + model.AvailableVendors.Add(new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + foreach (var v in await _vendorService.GetAllVendors(showHidden: true)) + model.AvailableVendors.Add(new SelectListItem { Text = v.Name, Value = v.Id }); + } + + //product types + model.AvailableProductTypes = _enumTranslationService.ToSelectList(ProductType.SimpleProduct, false).ToList(); + model.AvailableProductTypes.Insert(0, + new SelectListItem { Text = _translationService.GetResource("Admin.Common.All"), Value = "" }); + + ViewBag.productIdsInput = productIdsInput; + ViewBag.btnId = btnId; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task ProductAddPopupList(DataSourceRequest command, + RequirementOneProductModel.AddProductModel model) + { + var authResult = await AuthorizeAsync(StandardPermission.ManageProducts); + if (authResult != null) return authResult; + + //a vendor should have access only to his products + if (CurrentVendor != null) model.SearchVendorId = CurrentVendor.Id; + //a store manager should only search within their store + if (!string.IsNullOrEmpty(CurrentStoreId)) model.SearchStoreId = CurrentStoreId; + + var searchCategoryIds = new List(); + if (!string.IsNullOrEmpty(model.SearchCategoryId)) + searchCategoryIds.Add(model.SearchCategoryId); + + var products = (await _productService.SearchProducts( + categoryIds: searchCategoryIds, + collectionId: model.SearchCollectionId, + storeId: model.SearchStoreId, + vendorId: model.SearchVendorId, + productType: model.SearchProductTypeId > 0 ? (ProductType?)model.SearchProductTypeId : null, + keywords: model.SearchProductName, + pageIndex: command.Page - 1, + pageSize: command.PageSize, + showHidden: true + )).products; + var gridModel = new DataSourceResult { + Data = products.Select(x => new RequirementOneProductModel.ProductModel { + Id = x.Id, + Name = x.Name, + Published = x.Published + }), + Total = products.TotalCount + }; + + return new JsonResult(gridModel); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task LoadProductFriendlyNames(string productIds) + { + var authResult = await AuthorizeAsync(StandardPermission.ManageProducts); + if (authResult != null) return authResult; + + var result = ""; + + if (string.IsNullOrWhiteSpace(productIds)) return new JsonResult(new { Text = result }); + var ids = productIds + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Split(':')[0].Trim()) + .ToList(); + + var products = await _productService.GetProductsByIds(ids.ToArray(), true); + result = string.Join(", ", products.Select(p => p.Name)); + + return new JsonResult(new { Text = result }); + } +} diff --git a/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesShoppingCartAmountController.cs b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesShoppingCartAmountController.cs new file mode 100644 index 0000000000..c047ba4efe --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Controllers/DiscountRulesShoppingCartAmountController.cs @@ -0,0 +1,89 @@ +using DiscountRules.Standard.Models; +using Grand.Business.Core.Interfaces.Catalog.Discounts; +using Grand.Business.Core.Interfaces.Common.Security; +using Grand.Domain.Permissions; +using Grand.Domain.Discounts; +using Microsoft.AspNetCore.Mvc; +using System.Globalization; + +namespace DiscountRules.Standard.Controllers; + +public class DiscountRulesShoppingCartAmountController : BaseDiscountRulePluginController +{ + private readonly IDiscountService _discountService; + private readonly IPermissionService _permissionService; + + public DiscountRulesShoppingCartAmountController(IDiscountService discountService, + IPermissionService permissionService) + { + _discountService = discountService; + _permissionService = permissionService; + } + + public async Task Configure(string discountId, string discountRequirementId) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + double spentAmountRequirement = 0; + if (!string.IsNullOrEmpty(discountRequirementId)) + { + var discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + if (discountRequirement == null) + return Content("Failed to load requirement."); + + spentAmountRequirement = Convert.ToDouble(discountRequirement.Metadata); + } + + var model = new RequirementShoppingCartModel { + RequirementId = !string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "", + DiscountId = discountId, + SpentAmount = spentAmountRequirement + }; + + //add a prefix + ViewData.TemplateInfo.HtmlFieldPrefix = + $"DiscountRulesShoppingCart{(!string.IsNullOrEmpty(discountRequirementId) ? discountRequirementId : "")}"; + + return View(model); + } + + + [HttpPost] + public async Task Configure(string discountId, string discountRequirementId, double spentAmount) + { + if (!await _permissionService.Authorize(StandardPermission.ManageDiscounts)) + return Content("Access denied"); + + var discount = await _discountService.GetDiscountById(discountId); + if (discount == null) + throw new ArgumentException("Discount could not be loaded"); + + DiscountRule discountRequirement = null; + if (!string.IsNullOrEmpty(discountRequirementId)) + discountRequirement = discount.DiscountRules.FirstOrDefault(dr => dr.Id == discountRequirementId); + + if (discountRequirement != null) + { + //update existing rule + discountRequirement.Metadata = spentAmount.ToString(CultureInfo.InvariantCulture); + await _discountService.UpdateDiscount(discount); + } + else + { + //save new rule + discountRequirement = new DiscountRule { + DiscountRequirementRuleSystemName = "DiscountRequirement.ShoppingCart", + Metadata = spentAmount.ToString(CultureInfo.InvariantCulture) + }; + discount.DiscountRules.Add(discountRequirement); + await _discountService.UpdateDiscount(discount); + } + + return Json(new { Result = true, NewRequirementId = discountRequirement.Id }); + } +} \ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/DiscountRules.Standard.csproj b/src/Plugins/DiscountRules.Standard/DiscountRules.Standard.csproj index e8be89b4b8..fd82146403 100644 --- a/src/Plugins/DiscountRules.Standard/DiscountRules.Standard.csproj +++ b/src/Plugins/DiscountRules.Standard/DiscountRules.Standard.csproj @@ -1,45 +1,25 @@  - - - true - false - + + + + true + false + - - ..\..\Web\Grand.Web\Plugins\DiscountRules.Standard - $(OutputPath) - + + ..\..\Web\Grand.Web\Plugins\DiscountRules.Standard + $(OutputPath) + - - ..\..\Web\Grand.Web\Plugins\DiscountRules.Standard - $(OutputPath) - - - - - false - - - false - - - false - - - false - - - false - all - - - false - - - - - Always - - + + ..\..\Web\Grand.Web\Plugins\DiscountRules.Standard + $(OutputPath) + + + + Always + + + \ No newline at end of file diff --git a/src/Plugins/DiscountRules.Standard/EndpointProvider.cs b/src/Plugins/DiscountRules.Standard/EndpointProvider.cs index 47cbed67cc..c218ded62c 100644 --- a/src/Plugins/DiscountRules.Standard/EndpointProvider.cs +++ b/src/Plugins/DiscountRules.Standard/EndpointProvider.cs @@ -10,50 +10,56 @@ public void RegisterEndpoint(IEndpointRouteBuilder endpointRouteBuilder) { //CustomerGroups endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.CustomerGroups.Configure", - "Admin/CustomerGroups/Configure", - new { controller = "CustomerGroups", action = "Configure" } + "DiscountRulesCustomerGroups/Configure", + new { controller = "DiscountRulesCustomerGroups", action = "Configure" } ); //HadSpentAmount endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HadSpentAmount.Configure", - "Admin/HadSpentAmount/Configure", - new { controller = "HadSpentAmount", action = "Configure" } + "DiscountRulesHadSpentAmount/Configure", + new { controller = "DiscountRulesHadSpentAmount", action = "Configure" } + ); + + //ShoppingCartAmount + endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.ShoppingCartAmount.Configure", + "DiscountRulesShoppingCartAmount/Configure", + new { controller = "DiscountRulesShoppingCartAmount", action = "Configure" } ); //HasAllProducts endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasAllProducts.Configure", - "Admin/HasAllProducts/Configure", - new { controller = "HasAllProducts", action = "Configure" } + "DiscountRulesHasAllProducts/Configure", + new { controller = "DiscountRulesHasAllProducts", action = "Configure" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasAllProducts.ProductAddPopup", - "Admin/HasAllProducts/ProductAddPopup", - new { controller = "HasAllProducts", action = "ProductAddPopup" } + "DiscountRulesHasAllProducts/ProductAddPopup", + new { controller = "DiscountRulesHasAllProducts", action = "ProductAddPopup" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasAllProducts.ProductAddPopupList", - "Admin/HasAllProducts/ProductAddPopupList", - new { controller = "HasAllProducts", action = "ProductAddPopupList" } + "DiscountRulesHasAllProducts/ProductAddPopupList", + new { controller = "DiscountRulesHasAllProducts", action = "ProductAddPopupList" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasAllProducts.LoadProductFriendlyNames", - "Admin/HasAllProducts/LoadProductFriendlyNames", - new { controller = "HasAllProducts", action = "LoadProductFriendlyNames" } + "DiscountRulesHasAllProducts/LoadProductFriendlyNames", + new { controller = "DiscountRulesHasAllProducts", action = "LoadProductFriendlyNames" } ); //HasOneProduct endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasOneProduct.Configure", - "Admin/HasOneProduct/Configure", - new { controller = "HasOneProduct", action = "Configure" } + "DiscountRulesHasOneProduct/Configure", + new { controller = "DiscountRulesHasOneProduct", action = "Configure" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasOneProduct.ProductAddPopup", - "Admin/HasOneProduct/ProductAddPopup", - new { controller = "HasOneProduct", action = "ProductAddPopup" } + "DiscountRulesHasOneProduct/ProductAddPopup", + new { controller = "DiscountRulesHasOneProduct", action = "ProductAddPopup" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasOneProduct.ProductAddPopupList", - "Admin/HasOneProduct/ProductAddPopupList", - new { controller = "HasOneProduct", action = "ProductAddPopupList" } + "DiscountRulesHasOneProduct/ProductAddPopupList", + new { controller = "DiscountRulesHasOneProduct", action = "ProductAddPopupList" } ); endpointRouteBuilder.MapControllerRoute("Plugin.DiscountRules.HasOneProduct.LoadProductFriendlyNames", - "Admin/HasOneProduct/LoadProductFriendlyNames", - new { controller = "HasOneProduct", action = "LoadProductFriendlyNames" } + "DiscountRulesHasOneProduct/LoadProductFriendlyNames", + new { controller = "DiscountRulesHasOneProduct", action = "LoadProductFriendlyNames" } ); } diff --git a/src/Plugins/DiscountRules.Standard/Providers/CustomerGroupDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/CustomerGroupDiscountRule.cs index ed6a6293c4..d0a62c90c7 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/CustomerGroupDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/CustomerGroupDiscountRule.cs @@ -41,7 +41,7 @@ public async Task CheckRequirement(DiscountRuleVal public string GetConfigurationUrl(string discountId, string discountRequirementId) { //configured - var result = "Admin/CustomerGroups/Configure/?discountId=" + discountId; + var result = $"/DiscountRulesCustomerGroups/Configure/?discountId=" + discountId; if (!string.IsNullOrEmpty(discountRequirementId)) result += $"&discountRequirementId={discountRequirementId}"; return result; diff --git a/src/Plugins/DiscountRules.Standard/Providers/HadSpentAmountDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/HadSpentAmountDiscountRule.cs index 0e70c5ba87..398948168b 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/HadSpentAmountDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/HadSpentAmountDiscountRule.cs @@ -65,7 +65,7 @@ public async Task CheckRequirement(DiscountRuleVal public string GetConfigurationUrl(string discountId, string discountRequirementId) { //configured - var result = "Admin/HadSpentAmount/Configure/?discountId=" + discountId; + var result = $"/DiscountRulesHadSpentAmount/Configure/?discountId=" + discountId; if (!string.IsNullOrEmpty(discountRequirementId)) result += $"&discountRequirementId={discountRequirementId}"; return result; diff --git a/src/Plugins/DiscountRules.Standard/Providers/HasAllProductsDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/HasAllProductsDiscountRule.cs index d8942dadba..89edd276b5 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/HasAllProductsDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/HasAllProductsDiscountRule.cs @@ -124,7 +124,7 @@ into g public string GetConfigurationUrl(string discountId, string discountRequirementId) { //configured - var result = "Admin/HasAllProducts/Configure/?discountId=" + discountId; + var result = $"/DiscountRulesHasAllProducts/Configure/?discountId=" + discountId; if (!string.IsNullOrEmpty(discountRequirementId)) result += $"&discountRequirementId={discountRequirementId}"; return result; diff --git a/src/Plugins/DiscountRules.Standard/Providers/HasOneProductDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/HasOneProductDiscountRule.cs index 0ed59bd6fa..5ad908b292 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/HasOneProductDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/HasOneProductDiscountRule.cs @@ -123,7 +123,7 @@ into g public string GetConfigurationUrl(string discountId, string discountRequirementId) { //configured - var result = "Admin/HasOneProduct/Configure/?discountId=" + discountId; + var result = $"/DiscountRulesHasOneProduct/Configure/?discountId=" + discountId; if (!string.IsNullOrEmpty(discountRequirementId)) result += $"&discountRequirementId={discountRequirementId}"; return result; diff --git a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs index b5c6c166b3..9b584ee8aa 100644 --- a/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs +++ b/src/Plugins/DiscountRules.Standard/Providers/ShoppingCartDiscountRule.cs @@ -86,7 +86,7 @@ public async Task CheckRequirement(DiscountRuleVal public string GetConfigurationUrl(string discountId, string discountRequirementId) { //configured - var result = "Admin/ShoppingCartAmount/Configure/?discountId=" + discountId; + var result = $"/DiscountRulesShoppingCartAmount/Configure/?discountId=" + discountId; if (!string.IsNullOrEmpty(discountRequirementId)) result += $"&discountRequirementId={discountRequirementId}"; return result; diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesCustomerGroups/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesCustomerGroups/Configure.cshtml new file mode 100644 index 0000000000..a9e95e156d --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesCustomerGroups/Configure.cshtml @@ -0,0 +1,53 @@ +@{ + Layout = ""; +} +@using System.Text.Encodings.Web +@model DiscountRules.Standard.Models.RequirementCustomerGroupsModel + + + +@* the rule's form inside the discount's requirement card: the field, then Save for this rule + alone (outline - the discount's own Save is the screen's primary) and the saved message the + script fades in *@ +
+ +
+ + +
+
diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHadSpentAmount/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHadSpentAmount/Configure.cshtml new file mode 100644 index 0000000000..f5ce9ad9ad --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHadSpentAmount/Configure.cshtml @@ -0,0 +1,51 @@ +@{ + Layout = ""; +} +@using System.Text.Encodings.Web +@model DiscountRules.Standard.Models.RequirementSpentAmountModel + + + +@* the rule's form inside the discount's requirement card: the field, then Save for this rule + alone (outline - the discount's own Save is the screen's primary) and the saved message the + script fades in *@ +
+ +
+ + +
+
diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/Configure.cshtml new file mode 100644 index 0000000000..dcda2a0aff --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/Configure.cshtml @@ -0,0 +1,126 @@ +@{ + Layout = ""; +} +@using System.Text.Encodings.Web +@model DiscountRules.Standard.Models.RequirementAllProductsModel + + + +@* the product ids with their names under them, then Save for this rule alone and the product + picker (both outline - the discount's own Save is the screen's primary). The ids the scripts + and the picker popup use are unchanged *@ +
+ + + + + +
+ diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/ProductAddPopup.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/ProductAddPopup.cshtml new file mode 100644 index 0000000000..2ddf9ab1b9 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasAllProducts/ProductAddPopup.cshtml @@ -0,0 +1,127 @@ +@{ + Layout = ""; +} +@using Grand.Domain.Common +@model DiscountRules.Standard.Models.RequirementAllProductsModel.AddProductModel +@{ + //page title + ViewBag.Title = Loc["Plugins.DiscountRules.HasAllProducts.Fields.Products.Choose"]; +} +
+ + @* the Category product picker's shape: the name as the quick search, the rest behind Filters, + the grid in a flush card. A row's Select adds the product; Save is the popup's one primary, + so Search is the outline submit *@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/Configure.cshtml new file mode 100644 index 0000000000..8b4615c1b1 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/Configure.cshtml @@ -0,0 +1,124 @@ +@{ + Layout = ""; +} +@using System.Text.Encodings.Web +@model DiscountRules.Standard.Models.RequirementOneProductModel + + + +@* the product ids with their names under them, then Save for this rule alone and the product + picker (both outline - the discount's own Save is the screen's primary). The ids the scripts + and the picker popup use are unchanged *@ +
+ + + + + +
+ diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/ProductAddPopup.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/ProductAddPopup.cshtml new file mode 100644 index 0000000000..51e4f5c711 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesHasOneProduct/ProductAddPopup.cshtml @@ -0,0 +1,127 @@ +@{ + Layout = ""; +} +@using Grand.Domain.Common +@model DiscountRules.Standard.Models.RequirementOneProductModel.AddProductModel +@{ + //page title + ViewBag.Title = Loc["Plugins.DiscountRules.HasOneProduct.Fields.Products.Choose"]; +} +
+ + @* the Category product picker's shape: the name as the quick search, the rest behind Filters, + the grid in a flush card. A row's Select adds the product; Save is the popup's one primary, + so Search is the outline submit *@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ +
diff --git a/src/Plugins/DiscountRules.Standard/Views/DiscountRulesShoppingCartAmount/Configure.cshtml b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesShoppingCartAmount/Configure.cshtml new file mode 100644 index 0000000000..8e2850e89e --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/DiscountRulesShoppingCartAmount/Configure.cshtml @@ -0,0 +1,51 @@ +@{ + Layout = ""; +} +@using System.Text.Encodings.Web +@model DiscountRules.Standard.Models.RequirementShoppingCartModel + + +@* the rule's form inside the discount's requirement card: the field, then Save for this rule + alone (outline - the discount's own Save is the screen's primary) and the saved message the + script fades in *@ +
+ +
+ + +
+
diff --git a/src/Plugins/DiscountRules.Standard/Views/_ViewImports.cshtml b/src/Plugins/DiscountRules.Standard/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..44d7f0eae9 --- /dev/null +++ b/src/Plugins/DiscountRules.Standard/Views/_ViewImports.cshtml @@ -0,0 +1,11 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common +@addTagHelper *, Grand.Web + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common +@using Grand.Web.Common.Localization +@using Grand.Web.Common.Page + +@inject LocService Loc \ No newline at end of file diff --git a/src/Plugins/ExchangeRate.McExchange/ExchangeRate.McExchange.csproj b/src/Plugins/ExchangeRate.McExchange/ExchangeRate.McExchange.csproj index 2368cd65a7..23e8be1ca1 100644 --- a/src/Plugins/ExchangeRate.McExchange/ExchangeRate.McExchange.csproj +++ b/src/Plugins/ExchangeRate.McExchange/ExchangeRate.McExchange.csproj @@ -1,5 +1,6 @@  + ..\..\Web\Grand.Web\Plugins\ExchangeRate.McExchange\ @@ -11,27 +12,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs b/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs index 8f41dd8c19..1dbb67f8cd 100644 --- a/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs +++ b/src/Plugins/Payments.BrainTree/Areas/Admin/Controllers/PaymentBrainTreeController.cs @@ -3,6 +3,7 @@ using Grand.Domain.Permissions; using Grand.Web.Common.Controllers; using Grand.Web.Common.Filters; +using Grand.Web.Common.Helpers; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; using Payments.BrainTree.Models; @@ -18,11 +19,11 @@ public class PaymentBrainTreeController : BasePaymentController public PaymentBrainTreeController(ISettingService settingService, ITranslationService translationService, - BrainTreePaymentSettings brainTreePaymentSettings) + IAdminStoreService adminStoreService) { _settingService = settingService; _translationService = translationService; - _brainTreePaymentSettings = brainTreePaymentSettings; + _adminStoreService = adminStoreService; } #endregion @@ -31,49 +32,62 @@ public PaymentBrainTreeController(ISettingService settingService, private readonly ISettingService _settingService; private readonly ITranslationService _translationService; - private readonly BrainTreePaymentSettings _brainTreePaymentSettings; + private readonly IAdminStoreService _adminStoreService; #endregion #region Methods - public IActionResult Configure() + public async Task Configure() { + //load settings for a chosen store scope - the injected settings instance would always be the + //one of the store hosting the admin panel, which ignores the scope the admin selected + var storeScope = await _adminStoreService.GetActiveStore(); + var brainTreePaymentSettings = await _settingService.LoadSetting(storeScope); + var model = new ConfigurationModel { - Use3DS = _brainTreePaymentSettings.Use3DS, - UseSandBox = _brainTreePaymentSettings.UseSandBox, - PublicKey = _brainTreePaymentSettings.PublicKey, - PrivateKey = _brainTreePaymentSettings.PrivateKey, - MerchantId = _brainTreePaymentSettings.MerchantId, - AdditionalFee = _brainTreePaymentSettings.AdditionalFee, - AdditionalFeePercentage = _brainTreePaymentSettings.AdditionalFeePercentage, - DisplayOrder = _brainTreePaymentSettings.DisplayOrder + Use3DS = brainTreePaymentSettings.Use3DS, + UseSandBox = brainTreePaymentSettings.UseSandBox, + PublicKey = brainTreePaymentSettings.PublicKey, + PrivateKey = brainTreePaymentSettings.PrivateKey, + MerchantId = brainTreePaymentSettings.MerchantId, + AdditionalFee = brainTreePaymentSettings.AdditionalFee, + AdditionalFeePercentage = brainTreePaymentSettings.AdditionalFeePercentage, + DisplayOrder = brainTreePaymentSettings.DisplayOrder }; return View(model); } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Configure(ConfigurationModel model) { if (!ModelState.IsValid) - return Configure(); + return await Configure(); + + //load settings for a chosen store scope + var storeScope = await _adminStoreService.GetActiveStore(); + var brainTreePaymentSettings = await _settingService.LoadSetting(storeScope); //save settings - _brainTreePaymentSettings.Use3DS = model.Use3DS; - _brainTreePaymentSettings.UseSandBox = model.UseSandBox; - _brainTreePaymentSettings.PublicKey = model.PublicKey; - _brainTreePaymentSettings.PrivateKey = model.PrivateKey; - _brainTreePaymentSettings.MerchantId = model.MerchantId; - _brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; - _brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; - _brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + brainTreePaymentSettings.Use3DS = model.Use3DS; + brainTreePaymentSettings.UseSandBox = model.UseSandBox; + brainTreePaymentSettings.PublicKey = model.PublicKey; + brainTreePaymentSettings.PrivateKey = model.PrivateKey; + brainTreePaymentSettings.MerchantId = model.MerchantId; + brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; + brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(brainTreePaymentSettings, storeScope); - await _settingService.SaveSetting(_brainTreePaymentSettings); + //now clear settings cache + await _settingService.ClearCache(); Success(_translationService.GetResource("Admin.Plugins.Saved")); - return Configure(); + return await Configure(); } #endregion diff --git a/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml b/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml index e760990fc8..9a45ecd403 100644 --- a/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml +++ b/src/Plugins/Payments.BrainTree/Areas/Admin/Views/PaymentBrainTree/Configure.cshtml @@ -1,91 +1,40 @@ @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Payments.BrainTree.FriendlyName"]; } @model Payments.BrainTree.Models.ConfigurationModel +@* the store scope, then the mode switches, the credentials and the fee; Save as the one primary + action in the page header instead of the _ConfigurePlugin chrome *@
+ + + + -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
-
- -
-
-
-
-
- \ No newline at end of file + @await Component.InvokeAsync("StoreScope") + + +
+ + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+
+ diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs b/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs new file mode 100644 index 0000000000..e9d818e9c6 --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Controllers/PaymentBrainTreeController.cs @@ -0,0 +1,107 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.BrainTree.Models; + +namespace Payments.BrainTree.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the BrainTree payment method. +/// +/// The settings - the gateway credentials included - are read and written for the store the +/// manager is bound to (), so each store transacts on its own +/// BrainTree account. The provider builds a BraintreeGateway per call from the settings +/// instance the container resolved for the current store, so no further change is needed for +/// the payment itself to use them. Note that the first save creates a store row covering the +/// whole settings class, after which this store stops inheriting later global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentBrainTreeController : BasePaymentController +{ + #region Ctor + + public PaymentBrainTreeController(ISettingService settingService, + ITranslationService translationService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _contextAccessor = contextAccessor; + } + + #endregion + + #region Fields + + private readonly ISettingService _settingService; + private readonly ITranslationService _translationService; + private readonly IContextAccessor _contextAccessor; + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + #endregion + + #region Methods + + public async Task Configure() + { + var brainTreePaymentSettings = await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + Use3DS = brainTreePaymentSettings.Use3DS, + UseSandBox = brainTreePaymentSettings.UseSandBox, + PublicKey = brainTreePaymentSettings.PublicKey, + PrivateKey = brainTreePaymentSettings.PrivateKey, + MerchantId = brainTreePaymentSettings.MerchantId, + AdditionalFee = brainTreePaymentSettings.AdditionalFee, + AdditionalFeePercentage = brainTreePaymentSettings.AdditionalFeePercentage, + DisplayOrder = brainTreePaymentSettings.DisplayOrder + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var brainTreePaymentSettings = await _settingService.LoadSetting(CurrentStoreId); + + //save settings + brainTreePaymentSettings.Use3DS = model.Use3DS; + brainTreePaymentSettings.UseSandBox = model.UseSandBox; + brainTreePaymentSettings.PublicKey = model.PublicKey; + brainTreePaymentSettings.PrivateKey = model.PrivateKey; + brainTreePaymentSettings.MerchantId = model.MerchantId; + brainTreePaymentSettings.AdditionalFee = model.AdditionalFee; + brainTreePaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + brainTreePaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(brainTreePaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } + + #endregion +} diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml b/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml new file mode 100644 index 0000000000..c7d57110c9 --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Views/PaymentBrainTree/Configure.cshtml @@ -0,0 +1,38 @@ +@model Payments.BrainTree.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +@* the mode switches, the credentials and the fee; Save as the one primary action *@ +
+ + + + + + +
+ + +
+
+ +
+ + + +
+
+ +
+ + + +
+
+
+
diff --git a/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.BrainTree/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.BrainTree/Payments.BrainTree.csproj b/src/Plugins/Payments.BrainTree/Payments.BrainTree.csproj index c588da6a22..2b8789b69c 100644 --- a/src/Plugins/Payments.BrainTree/Payments.BrainTree.csproj +++ b/src/Plugins/Payments.BrainTree/Payments.BrainTree.csproj @@ -1,5 +1,6 @@  + true false @@ -20,35 +21,7 @@
- - - - - - - - - - false - - - false - - - false - - - false - - - false - all - - - false - - diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs b/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs index c14c995caa..dd3536d202 100644 --- a/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Controllers/PaymentCashOnDeliveryController.cs @@ -51,6 +51,7 @@ public async Task Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Configure(ConfigurationModel model) { if (!ModelState.IsValid) diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml b/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml index 361a316231..3f42a58b69 100644 --- a/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Admin/Views/PaymentCashOnDelivery/Configure.cshtml @@ -1,104 +1,48 @@ @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Payments.CashOnDelivery.FriendlyName"]; } @using Microsoft.AspNetCore.Mvc.Razor @model Payments.CashOnDelivery.Models.ConfigurationModel -@await Component.InvokeAsync("StoreScope") +@{ + Func + template = @
+ + + + +
; +} +@* the page owns its header instead of the _ConfigurePlugin chrome: the store scope, the + description customers read at checkout, then the fee and the switches *@
+ + + + - @{ - Func - template = @
-
-
- -
-
- - -
-
- -
; - } + @await Component.InvokeAsync("StoreScope") -
- -
-
-
- -
-
+ + +
+ - -
-
-
- -
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
-
-
- -
+ +
+ + + +
+ + + + +
-
-
- \ No newline at end of file + + + diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs new file mode 100644 index 0000000000..2f637361b6 --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Controllers/PaymentCashOnDeliveryController.cs @@ -0,0 +1,94 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.CashOnDelivery.Models; + +namespace Payments.CashOnDelivery.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the cash on delivery payment method. +/// +/// The settings are read and written for the store the manager is bound to +/// (), never globally - the storefront already resolves every +/// ISettings class for the current store, so a per-store row takes effect on checkout +/// of that store only. Note that the very first save creates a store row covering the whole +/// settings class, after which this store stops inheriting later global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class PaymentCashOnDeliveryController : BasePaymentController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly ITranslationService _translationService; + + public PaymentCashOnDeliveryController( + ISettingService settingService, + ITranslationService translationService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var cashOnDeliveryPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + DescriptionText = cashOnDeliveryPaymentSettings.DescriptionText, + AdditionalFee = cashOnDeliveryPaymentSettings.AdditionalFee, + AdditionalFeePercentage = cashOnDeliveryPaymentSettings.AdditionalFeePercentage, + ShippableProductRequired = cashOnDeliveryPaymentSettings.ShippableProductRequired, + DisplayOrder = cashOnDeliveryPaymentSettings.DisplayOrder, + SkipPaymentInfo = cashOnDeliveryPaymentSettings.SkipPaymentInfo, + //the store manager can only configure his own store, so no store selector is offered + ActiveStore = CurrentStoreId + }; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var cashOnDeliveryPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + cashOnDeliveryPaymentSettings.DescriptionText = model.DescriptionText; + cashOnDeliveryPaymentSettings.AdditionalFee = model.AdditionalFee; + cashOnDeliveryPaymentSettings.AdditionalFeePercentage = model.AdditionalFeePercentage; + cashOnDeliveryPaymentSettings.ShippableProductRequired = model.ShippableProductRequired; + cashOnDeliveryPaymentSettings.DisplayOrder = model.DisplayOrder; + cashOnDeliveryPaymentSettings.SkipPaymentInfo = model.SkipPaymentInfo; + + await _settingService.SaveSetting(cashOnDeliveryPaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml new file mode 100644 index 0000000000..7311fb4ae3 --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/PaymentCashOnDelivery/Configure.cshtml @@ -0,0 +1,35 @@ +@model Payments.CashOnDelivery.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +@* the description customers read at checkout, then the fee and the switches; Save as the one + primary action *@ +
+ + + + + + +
+ + + +
+
+ +
+ + + + + +
+
+
+
diff --git a/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.CashOnDelivery/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.CashOnDelivery/Payments.CashOnDelivery.csproj b/src/Plugins/Payments.CashOnDelivery/Payments.CashOnDelivery.csproj index 198028a579..fdc6f6863f 100644 --- a/src/Plugins/Payments.CashOnDelivery/Payments.CashOnDelivery.csproj +++ b/src/Plugins/Payments.CashOnDelivery/Payments.CashOnDelivery.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - all - - - false - - diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Admin/Controllers/StripeCheckoutController.cs b/src/Plugins/Payments.StripeCheckout/Areas/Admin/Controllers/StripeCheckoutController.cs index b1e44a16a2..cbe85c6a1e 100644 --- a/src/Plugins/Payments.StripeCheckout/Areas/Admin/Controllers/StripeCheckoutController.cs +++ b/src/Plugins/Payments.StripeCheckout/Areas/Admin/Controllers/StripeCheckoutController.cs @@ -55,6 +55,7 @@ public async Task Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Configure(ConfigurationModel model) { if (!await _permissionService.Authorize(StandardPermission.ManagePaymentMethods)) diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Admin/Views/StripeCheckout/Configure.cshtml b/src/Plugins/Payments.StripeCheckout/Areas/Admin/Views/StripeCheckout/Configure.cshtml index 7eeb909012..9e975e36ae 100644 --- a/src/Plugins/Payments.StripeCheckout/Areas/Admin/Views/StripeCheckout/Configure.cshtml +++ b/src/Plugins/Payments.StripeCheckout/Areas/Admin/Views/StripeCheckout/Configure.cshtml @@ -1,84 +1,48 @@ -@inject IUrlHelperFactory UrlHelperFactory -@inject IActionContextAccessor ActionContextAccessor -@inject IContextAccessor contextAccessor +@inject IContextAccessor contextAccessor @{ - Layout = "_ConfigurePlugin"; - var urlHelper = UrlHelperFactory.GetUrlHelper(ActionContextAccessor.ActionContext!); - var webhookUrl = contextAccessor.StoreContext.CurrentHost.Url.TrimEnd('/') + urlHelper.RouteUrl(StripeCheckoutDefaults.WebHook); + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Plugins.Payments.StripeCheckout.FriendlyName"]; + var webhookUrl = contextAccessor.StoreContext.CurrentHost.Url.TrimEnd('/') + Url.RouteUrl(StripeCheckoutDefaults.WebHook); } @using Grand.Infrastructure -@using Microsoft.AspNetCore.Mvc.Infrastructure -@using Microsoft.AspNetCore.Mvc.Routing @using Payments.StripeCheckout @model Payments.StripeCheckout.Models.ConfigurationModel -@await Component.InvokeAsync("StoreScope") +@* the setup steps as a note, then the credentials; Save as the one primary action in the page + header instead of the _ConfigurePlugin chrome *@
-

- For plugin configuration please check:
-
- 1. Generate API keys from the Stripe Dashboard: Click here.
- 2. Register webhook endpoints with Stripe Click here.
- 3. Your Webhook URL: @webhookUrl
- 4. Fill in your credentials below.
- 5. Save the configuration.
-
- Note: Ensure that you have configured your primary store currency that is supported by selected payment service provider.
-

-
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
+ + + + + + @await Component.InvokeAsync("StoreScope") + +
+ For plugin configuration please check:
+
+ 1. Generate API keys from the Stripe Dashboard: Click here.
+ 2. Register webhook endpoints with Stripe Click here.
+ 3. Your Webhook URL: @webhookUrl
+ 4. Fill in your credentials below.
+ 5. Save the configuration.
+
+ Note: Ensure that you have configured your primary store currency that is supported by selected payment service provider.
+
+ +
+ + + +
-
-
- -
-
+
+ - -
+
-
-
- -
-
- - -
-
-
-
-
-
- -
-
-
-
- \ No newline at end of file + + + diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs b/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs new file mode 100644 index 0000000000..5844e9f12f --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Controllers/StripeCheckoutController.cs @@ -0,0 +1,102 @@ +using Grand.Business.Core.Interfaces.Common.Configuration; +using Grand.Business.Core.Interfaces.Common.Localization; +using Grand.Business.Core.Interfaces.Common.Stores; +using Grand.Domain.Permissions; +using Grand.Infrastructure; +using Grand.Web.Common.Controllers; +using Grand.Web.Common.Filters; +using Grand.Web.Common.Security.Authorization; +using Microsoft.AspNetCore.Mvc; +using Payments.StripeCheckout.Models; + +namespace Payments.StripeCheckout.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the Stripe Checkout payment method. +/// +/// The settings - the API key and the webhook secret included - are read and written for the +/// store the manager is bound to (), so each store transacts on +/// its own Stripe account. Every Stripe call is made through a client built from the settings +/// instance the container resolved for the current store. Note that the first save creates a +/// store row covering the whole settings class, after which this store stops inheriting later +/// global changes to it. +/// +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[PermissionAuthorize(PermissionSystemName.PaymentMethods)] +public class StripeCheckoutController : BasePaymentController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly IStoreService _storeService; + private readonly ITranslationService _translationService; + + public StripeCheckoutController( + ISettingService settingService, + ITranslationService translationService, + IStoreService storeService, + IContextAccessor contextAccessor) + { + _settingService = settingService; + _translationService = translationService; + _storeService = storeService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current store manager is bound to. AuthorizeStore already rejects a + /// customer without one, so this is never empty here. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var stripeCheckoutPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + var model = new ConfigurationModel { + ApiKey = stripeCheckoutPaymentSettings.ApiKey, + WebhookEndpointSecret = stripeCheckoutPaymentSettings.WebhookEndpointSecret, + Description = stripeCheckoutPaymentSettings.Description, + Line = stripeCheckoutPaymentSettings.Line, + DisplayOrder = stripeCheckoutPaymentSettings.DisplayOrder, + //the store manager can only configure his own store, so no store selector is offered + StoreScope = CurrentStoreId + }; + + //the webhook has to be registered with Stripe against the manager's own storefront, not + //against whichever host happens to serve this panel + var store = await _storeService.GetStoreById(CurrentStoreId); + ViewBag.WebhookUrl = $"{store?.Url?.TrimEnd('/')}{Url.RouteUrl(StripeCheckoutDefaults.WebHook)}"; + + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task Configure(ConfigurationModel model) + { + if (!ModelState.IsValid) + return await Configure(); + + var stripeCheckoutPaymentSettings = + await _settingService.LoadSetting(CurrentStoreId); + + stripeCheckoutPaymentSettings.ApiKey = model.ApiKey; + stripeCheckoutPaymentSettings.WebhookEndpointSecret = model.WebhookEndpointSecret; + stripeCheckoutPaymentSettings.Description = model.Description; + stripeCheckoutPaymentSettings.Line = model.Line; + stripeCheckoutPaymentSettings.DisplayOrder = model.DisplayOrder; + + await _settingService.SaveSetting(stripeCheckoutPaymentSettings, CurrentStoreId); + + //now clear settings cache + await _settingService.ClearCache(); + + Success(_translationService.GetResource("Admin.Plugins.Saved")); + + return await Configure(); + } +} diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml new file mode 100644 index 0000000000..2055137b91 --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/StripeCheckout/Configure.cshtml @@ -0,0 +1,42 @@ +@model Payments.StripeCheckout.Models.ConfigurationModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Payment.Methods"]; +} + + +@* the setup steps as a note, then the credentials; Save as the one primary action *@ +
+ + + + + +
+ For plugin configuration please check:
+
+ 1. Generate API keys from the Stripe Dashboard: Click here.
+ 2. Register webhook endpoints with Stripe Click here.
+ 3. Your Webhook URL: @ViewBag.WebhookUrl
+ 4. Fill in your credentials below.
+ 5. Save the configuration.
+
+ Note: Ensure that you have configured your primary store currency that is supported by selected payment service provider.
+
+ +
+ + + + +
+
+ + + +
+
+
+
diff --git a/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Payments.StripeCheckout/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Payments.StripeCheckout/Payments.StripeCheckout.csproj b/src/Plugins/Payments.StripeCheckout/Payments.StripeCheckout.csproj index ab6ab924a2..533e643d1c 100644 --- a/src/Plugins/Payments.StripeCheckout/Payments.StripeCheckout.csproj +++ b/src/Plugins/Payments.StripeCheckout/Payments.StripeCheckout.csproj @@ -1,5 +1,6 @@ + true false @@ -19,35 +20,7 @@ - - - - - - - - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs b/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs index f25399af63..dd91fded09 100644 --- a/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs +++ b/src/Plugins/Payments.StripeCheckout/Services/StripeCheckoutService.cs @@ -2,7 +2,7 @@ using Grand.Business.Core.Interfaces.Checkout.Payments; using Grand.Domain.Orders; using Grand.Infrastructure; -using MediatR; +using Grand.Mediator; using Microsoft.Extensions.Logging; using Stripe; using Stripe.Checkout; @@ -93,8 +93,6 @@ private async Task CreatePaymentTransaction(PaymentIntent paymentIntent) private async Task CreateUrlSession(Order order) { - StripeConfiguration.ApiKey = _stripeCheckoutPaymentSettings.ApiKey; - var storeLocation = _contextAccessor.StoreContext.CurrentHost.Url.TrimEnd('/'); var options = new SessionCreateOptions { @@ -119,7 +117,10 @@ private async Task CreateUrlSession(Order order) SuccessUrl = $"{storeLocation}/orderdetails/{order.Id}", CancelUrl = $"{storeLocation}/Plugins/PaymentStripeCheckout/CancelOrder/{order.Id}" }; - var service = new SessionService(); + //the api key is store-specific, so it travels with the client - the static + //StripeConfiguration.ApiKey is process-wide and two stores checking out at the same time + //would overwrite each other's credentials + var service = new SessionService(new StripeClient(_stripeCheckoutPaymentSettings.ApiKey)); var session = await service.CreateAsync(options); return session; 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/Areas/Admin/Views/ShippingByWeight/AddPopup.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/AddPopup.cshtml index 4e42d98bd0..099fe8cda0 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/AddPopup.cshtml +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/AddPopup.cshtml @@ -7,22 +7,9 @@ asp-route-btnId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["btnId"])" asp-route-formId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["formId"])"> - -
-
-
-
-
- - @Loc["Admin.Common.AddNew"] -
-
-
- -
-
-
-
+ + + - \ No newline at end of file + diff --git a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/Configure.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/Configure.cshtml index 5371a8e782..d1f67f874f 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/Configure.cshtml +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/Configure.cshtml @@ -1,32 +1,63 @@ -@using Grand.Domain.Common -@model Shipping.ByWeight.Models.ShippingByWeightListModel -@inject AdminAreaSettings adminAreaSettings +@model Shipping.ByWeight.Models.ShippingByWeightListModel @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Shipping.ByWeight.FriendlyName"]; } +@* the rates grid in a flush card with Add record in its header, then the general settings; Save + (which posts those settings through the #savegeneralsettings script) is the one primary action. + The form still wraps everything the script serializes *@
- -
- @Loc["Plugins.Shipping.ByWeight.Formula"]: - @Loc["Plugins.Shipping.ByWeight.Formula.Value"] -
- @{ var addNewUrl = Url.Action("AddPopup", "ShippingByWeight", new { btnId = "btnRefresh", formId = "shipping-byweight-form" }); } + + + + -
-
-
-
- + + + + + @Loc["Plugins.Shipping.ByWeight.AddRecord"] + + + + + + + + + + + @*DataHtml is markup the plugin builds from the rate values*@ + + + + @Loc["Admin.Common.Edit"] + + + + + + + +
+ + +
+
+ -
-
-
-
-
- -
- - -
-
-
-
- -
-
- - -
-
-
- - -
-
-
-
-
- \ No newline at end of file + $.ajax({ + cache: false, + type: 'POST', + url: '@Url.Action("SaveGeneralSettings", "ShippingByWeight")', + data: postData, + dataType: 'json', + success: function (data) { + alert('Saved'); + }, + error: function (xhr, ajaxOptions, thrownError) { + alert('Failed to add record.'); + } + }); + return false; + }); + }); + + diff --git a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/EditPopup.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/EditPopup.cshtml index ab2c5fcd9e..0fa7867dae 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/EditPopup.cshtml +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/EditPopup.cshtml @@ -7,21 +7,9 @@ asp-route-btnId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["btnId"])" asp-route-formId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["formId"])"> -
-
-
-
-
- - @Loc["Admin.Common.Edit"] -
-
-
- -
-
-
-
+ + + - \ No newline at end of file + diff --git a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/_CreateOrUpdate.cshtml b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/_CreateOrUpdate.cshtml index afd2668cfa..ed3258e412 100644 --- a/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/_CreateOrUpdate.cshtml +++ b/src/Plugins/Shipping.ByWeight/Areas/Admin/Views/ShippingByWeight/_CreateOrUpdate.cshtml @@ -35,122 +35,26 @@ }); -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- [@Model.BaseWeightIn] - -
-
-
-
- -
-
- [@Model.BaseWeightIn] - -
-
-
-
- -
-
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- [@Model.PrimaryStoreCurrencyCode] - -
-
-
-
- -
-
- - -
-
-
-
- -
-
-
-
\ No newline at end of file +@* where the rate applies, then the weight range and what it costs; the units are the ends of + their controls instead of bare text after them *@ +
+ + + + + + +
+
+ + + + + + +
+
+ +
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..9503c1e605 --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/AddPopup.cshtml @@ -0,0 +1,29 @@ +@{ + Layout = ""; +} +@model Shipping.ByWeight.Models.ShippingByWeightModel +
+ + + + + +
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..7339f60795 --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/Configure.cshtml @@ -0,0 +1,132 @@ +@model Shipping.ByWeight.Models.ShippingByWeightListModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Shipping.Providers"]; +} + +@* the rates grid in a flush card with Add record in its header, then the general settings; Save + (which posts those settings through the #savegeneralsettings script) is the one primary action. + The form still wraps everything the script serializes *@ +
+ @{ + var addNewUrl = Url.Action("AddPopup", "ShippingByWeight", new { area = "Store", btnId = "btnRefresh", formId = "shipping-byweight-form" }); + } + + + + + +
+ @Loc["Plugins.Shipping.ByWeight.Formula"]: + @Loc["Plugins.Shipping.ByWeight.Formula.Value"] +
+ + + + + @Loc["Plugins.Shipping.ByWeight.AddRecord"] + + + + + + + + + + + @*DataHtml is markup the plugin builds from the rate values*@ + + + + {{ $texts.edit }} + + + + + + + +
+ + +
+
+
+ + + + + +
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..1ab42d3729 --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/EditPopup.cshtml @@ -0,0 +1,29 @@ +@{ + Layout = ""; +} +@model Shipping.ByWeight.Models.ShippingByWeightModel +
+ + + + + +
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..96f1ac0cfe --- /dev/null +++ b/src/Plugins/Shipping.ByWeight/Areas/Store/Views/ShippingByWeight/_CreateOrUpdate.cshtml @@ -0,0 +1,62 @@ +@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*@ + +@* where the rate applies, then the weight range and what it costs; the units are the ends of + their controls instead of bare text after them *@ +
+ @*the record always belongs to the owner's own store, so no store selector is offered*@ + + + + + +
+
+ + + + + + +
+
+ +
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.ByWeight/ByWeightShippingProvider.cs b/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs index ae58733116..484f70d6c9 100644 --- a/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs +++ b/src/Plugins/Shipping.ByWeight/ByWeightShippingProvider.cs @@ -230,8 +230,7 @@ public async Task GetShippingOptions(GetShippingOptio var weight = await GetTotalWeight(getShippingOptionRequest); - var shippingMethods = - await _shippingMethodService.GetAllShippingMethods(countryId, _contextAccessor.WorkContext.CurrentCustomer); + var shippingMethods = await _shippingMethodService.GetAllShippingMethods(countryId, _contextAccessor.WorkContext.CurrentCustomer, _contextAccessor.StoreContext.CurrentStore.Id); foreach (var shippingMethod in shippingMethods) { double? rate = null; 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 3fea1f3dfd..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,15 +43,18 @@ 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); - return await _cacheBase.GetAsync(key, () => + 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 Task.FromResult(new PagedList(query, pageIndex, pageSize)); + return await _sbwRepository.PagedAsync(query, pageIndex, pageSize); }); } diff --git a/src/Plugins/Shipping.ByWeight/Shipping.ByWeight.csproj b/src/Plugins/Shipping.ByWeight/Shipping.ByWeight.csproj index ab9f7e9877..7f1f31cc81 100644 --- a/src/Plugins/Shipping.ByWeight/Shipping.ByWeight.csproj +++ b/src/Plugins/Shipping.ByWeight/Shipping.ByWeight.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Controllers/ShippingFixedRateController.cs b/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Controllers/ShippingFixedRateController.cs index 0455f5af82..421375e397 100644 --- a/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Controllers/ShippingFixedRateController.cs +++ b/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Controllers/ShippingFixedRateController.cs @@ -34,6 +34,7 @@ public IActionResult Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Configure(DataSourceRequest command) { if (!await _permissionService.Authorize(StandardPermission.ManageShippingSettings)) diff --git a/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Views/ShippingFixedRate/Configure.cshtml b/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Views/ShippingFixedRate/Configure.cshtml index 5061dd7bd1..5f0a297d73 100644 --- a/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Views/ShippingFixedRate/Configure.cshtml +++ b/src/Plugins/Shipping.FixedRateShipping/Areas/Admin/Views/ShippingFixedRate/Configure.cshtml @@ -1,99 +1,20 @@ @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Shipping.FixedRate.FriendlyName"]; } -
-
-
-
- -
-
-
\ No newline at end of file +@* the grid edits inline, so there is no primary action on this screen *@ + + + + + + + + + diff --git a/src/Plugins/Shipping.FixedRateShipping/FixedRateShippingProvider.cs b/src/Plugins/Shipping.FixedRateShipping/FixedRateShippingProvider.cs index 1323187b80..87280904aa 100644 --- a/src/Plugins/Shipping.FixedRateShipping/FixedRateShippingProvider.cs +++ b/src/Plugins/Shipping.FixedRateShipping/FixedRateShippingProvider.cs @@ -63,8 +63,7 @@ public async Task GetShippingOptions(GetShippingOptio !string.IsNullOrEmpty(getShippingOptionRequest.ShippingAddress.CountryId) ? getShippingOptionRequest.ShippingAddress.CountryId : ""; - var shippingMethods = - await _shippingMethodService.GetAllShippingMethods(restrictByCountryId, getShippingOptionRequest.Customer); + var shippingMethods = await _shippingMethodService.GetAllShippingMethods(restrictByCountryId, getShippingOptionRequest.Customer, _contextAccessor.StoreContext.CurrentStore.Id); foreach (var shippingMethod in shippingMethods) { var shippingOption = new ShippingOption { diff --git a/src/Plugins/Shipping.FixedRateShipping/Shipping.FixedRateShipping.csproj b/src/Plugins/Shipping.FixedRateShipping/Shipping.FixedRateShipping.csproj index 5f05f2feb2..b6586751e2 100644 --- a/src/Plugins/Shipping.FixedRateShipping/Shipping.FixedRateShipping.csproj +++ b/src/Plugins/Shipping.FixedRateShipping/Shipping.FixedRateShipping.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Controllers/ShippingPointController.cs b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Controllers/ShippingPointController.cs index fa0b1e38dc..c6a0fcfa6c 100644 --- a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Controllers/ShippingPointController.cs +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Controllers/ShippingPointController.cs @@ -42,6 +42,7 @@ public IActionResult Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task List(DataSourceRequest command) { var shippingPoints = await _shippingPointService.GetAllStoreShippingPoint("", @@ -90,6 +91,7 @@ public async Task Create() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Create(ShippingPointModel model) { if (ModelState.IsValid) @@ -115,6 +117,7 @@ public async Task Edit(string id) } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Edit(ShippingPointModel model) { if (ModelState.IsValid) @@ -133,6 +136,7 @@ public async Task Edit(ShippingPointModel model) } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Delete(string id) { var model = await _shippingPointService.GetStoreShippingPointById(id); diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Configure.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Configure.cshtml index 1f279b129e..1fcf40a08b 100644 --- a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Configure.cshtml +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Configure.cshtml @@ -1,138 +1,79 @@ @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Shipping.ShippingPoint.FriendlyName"]; } +@* the points grid in a flush card, Add new in its header; the grid deletes and the popups save, + so the screen has no primary action of its own *@
-
-
-
-
- -
- + + + + + + + + + + + {{ $texts.edit }} + + + + + + + - - -
-
-
\ No newline at end of file + + diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Create.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Create.cshtml index 842a6e9aff..ca8176d568 100644 --- a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Create.cshtml +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Create.cshtml @@ -8,29 +8,14 @@ asp-route-btnId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["btnId"])" asp-route-formId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["formId"])"> -
-
-
-
-
- @Loc["Admin.Common.AddNew"] -
-
-
- -
-
-
-
+ + +
+
-
-
-
- -
-
+ - \ No newline at end of file + diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Edit.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Edit.cshtml index 4e1c5b98f0..f14f8ea298 100644 --- a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Edit.cshtml +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/Edit.cshtml @@ -8,29 +8,14 @@ asp-route-btnId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["btnId"])" asp-route-formId="@HtmlExtensions.HtmlEncodeSafe(Context.Request.Query["formId"])"> -
-
-
-
-
- @Loc["Admin.Common.Edit"] -
-
-
- -
-
-
-
+ + +
+
-
-
-
- -
-
+ - \ No newline at end of file + diff --git a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/_CreateOrUpdate.cshtml b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/_CreateOrUpdate.cshtml index 3f3d285505..ff24d44630 100644 --- a/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/_CreateOrUpdate.cshtml +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Admin/Views/ShippingPoint/_CreateOrUpdate.cshtml @@ -12,70 +12,20 @@ } -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
- -
- - -
-
-
-
\ No newline at end of file +
+ + +
+
+ + + +
+
+ + + + + + +
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..19b8e89465 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Configure.cshtml @@ -0,0 +1,78 @@ +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Shipping.Providers"]; +} + +@* the points grid in a flush card, Add new in its header; the grid deletes and the popups save, + so the screen has no primary action of its own *@ +
+ + + + + @Loc["Admin.Common.AddNew"] + + + + + + + + + + + {{ $texts.edit }} + + + + + + + + + +
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..84ed4925ee --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Create.cshtml @@ -0,0 +1,49 @@ +@{ + Layout = ""; +} +@model Shipping.ShippingPoint.Models.ShippingPointModel + +
+ + + +
+ +
+
+ +
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..8babf2ce30 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/Edit.cshtml @@ -0,0 +1,49 @@ +@{ + Layout = ""; +} +@model Shipping.ShippingPoint.Models.ShippingPointModel + +
+ + + +
+ +
+
+ +
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..1bdc313765 --- /dev/null +++ b/src/Plugins/Shipping.ShippingPoint/Areas/Store/Views/ShippingPoint/_CreateOrUpdate.cshtml @@ -0,0 +1,31 @@ +@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 diff --git a/src/Plugins/Shipping.ShippingPoint/MapperConfiguration.cs b/src/Plugins/Shipping.ShippingPoint/MapperConfiguration.cs index bb454edf84..deb4292711 100644 --- a/src/Plugins/Shipping.ShippingPoint/MapperConfiguration.cs +++ b/src/Plugins/Shipping.ShippingPoint/MapperConfiguration.cs @@ -1,4 +1,4 @@ -using AutoMapper; +using Grand.Mapping; using Grand.Infrastructure.Mapper; using Shipping.ShippingPoint.Domain; using Shipping.ShippingPoint.Models; diff --git a/src/Plugins/Shipping.ShippingPoint/Shipping.ShippingPoint.csproj b/src/Plugins/Shipping.ShippingPoint/Shipping.ShippingPoint.csproj index e8496425b9..142598e9ba 100644 --- a/src/Plugins/Shipping.ShippingPoint/Shipping.ShippingPoint.csproj +++ b/src/Plugins/Shipping.ShippingPoint/Shipping.ShippingPoint.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Tax.CountryStateZip/Areas/Admin/Controllers/TaxCountryStateZipController.cs b/src/Plugins/Tax.CountryStateZip/Areas/Admin/Controllers/TaxCountryStateZipController.cs index fa899e62e1..6c93cf1075 100644 --- a/src/Plugins/Tax.CountryStateZip/Areas/Admin/Controllers/TaxCountryStateZipController.cs +++ b/src/Plugins/Tax.CountryStateZip/Areas/Admin/Controllers/TaxCountryStateZipController.cs @@ -68,7 +68,7 @@ public async Task Configure() [AutoValidateAntiforgeryToken] public async Task RatesList(DataSourceRequest command) { - var records = await _taxRateService.GetAllTaxRates(command.Page - 1, command.PageSize); + var records = await _taxRateService.GetAllTaxRates(pageIndex: command.Page - 1, pageSize: command.PageSize); var taxRatesModel = new List(); foreach (var x in records) { diff --git a/src/Plugins/Tax.CountryStateZip/Areas/Admin/Views/TaxCountryStateZip/Configure.cshtml b/src/Plugins/Tax.CountryStateZip/Areas/Admin/Views/TaxCountryStateZip/Configure.cshtml index e18faefd28..32e41d4e8a 100644 --- a/src/Plugins/Tax.CountryStateZip/Areas/Admin/Views/TaxCountryStateZip/Configure.cshtml +++ b/src/Plugins/Tax.CountryStateZip/Areas/Admin/Views/TaxCountryStateZip/Configure.cshtml @@ -1,122 +1,12 @@ @using Grand.Domain.Common @using Microsoft.AspNetCore.Routing @model Tax.CountryStateZip.Models.TaxRateListModel -@inject AdminAreaSettings adminAreaSettings @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Tax.CountryStateZip.FriendlyName"]; } -
- + -
-
-
-
-
- @Loc["Plugins.Tax.CountryStateZip.AddRecord.Hint"] -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
-
-
-
- -
-
- - -
+@* the rates grid edits inline; adding a rate is the screen's one primary action. The form wraps + only the add card, as it wrapped only the add fields before, because the #addtaxrate script + posts $(this.form).serialize() *@ + + + + + + + + + + + + + + +
+ @Loc["Plugins.Tax.CountryStateZip.AddRecord.Hint"]
-
-
- -
-
- - -
+
+ + + + + +
-
-
- -
-
- - -
+
+
-
-
-
-
- -
-
-
-
- - \ No newline at end of file + + +
diff --git a/src/Plugins/Tax.CountryStateZip/Areas/Store/Controllers/TaxCountryStateZipController.cs b/src/Plugins/Tax.CountryStateZip/Areas/Store/Controllers/TaxCountryStateZipController.cs new file mode 100644 index 0000000000..dac2347473 --- /dev/null +++ b/src/Plugins/Tax.CountryStateZip/Areas/Store/Controllers/TaxCountryStateZipController.cs @@ -0,0 +1,172 @@ +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Common.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 Tax.CountryStateZip.Domain; +using Tax.CountryStateZip.Models; +using Tax.CountryStateZip.Services; + +namespace Tax.CountryStateZip.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the country/state/zip tax provider. +/// A store owner can add, edit and delete tax rates, but only the ones that +/// belong to his own store (). Records owned by +/// other stores or global (store = *) records are never returned nor mutated. +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[AutoValidateAntiforgeryToken] +[PermissionAuthorize(PermissionSystemName.TaxSettings)] +public class TaxCountryStateZipController : BaseController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ICountryService _countryService; + private readonly ITaxCategoryService _taxCategoryService; + private readonly ITaxRateService _taxRateService; + + public TaxCountryStateZipController(ITaxCategoryService taxCategoryService, + ICountryService countryService, + ITaxRateService taxRateService, + IContextAccessor contextAccessor) + { + _taxCategoryService = taxCategoryService; + _countryService = countryService; + _taxRateService = taxRateService; + _contextAccessor = contextAccessor; + } + + /// + /// The store the current staff/store-manager is bound to. + /// + private string CurrentStoreId => _contextAccessor.WorkContext.CurrentCustomer.StaffStoreId; + + public async Task Configure() + { + var taxCategories = await _taxCategoryService.GetAllTaxCategories(CurrentStoreId); + if (taxCategories.Count == 0) + return Content("No tax categories can be loaded"); + + var model = new TaxRateListModel { + //the store owner can only add records for his own store, so no store selector is offered + AddStoreId = CurrentStoreId + }; + + //tax categories + foreach (var tc in taxCategories) + model.AvailableTaxCategories.Add(new SelectListItem { Text = tc.Name, Value = tc.Id }); + + //countries + var countries = await _countryService.GetAllCountries(showHidden: true); + foreach (var c in countries) + model.AvailableCountries.Add(new SelectListItem { Text = c.Name, Value = c.Id }); + + //states + model.AvailableStates.Add(new SelectListItem { Text = "*", Value = "" }); + var defaultCountry = countries.FirstOrDefault(); + if (defaultCountry != null) + { + var states = await _countryService.GetStateProvincesByCountryId(defaultCountry.Id); + foreach (var s in states) + model.AvailableStates.Add(new SelectListItem { Text = s.Name, Value = s.Id }); + } + + return View(model); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task RatesList(DataSourceRequest command) + { + //only the current store records - filtered and paged in the service layer + var records = await _taxRateService.GetAllTaxRates(CurrentStoreId, command.Page - 1, command.PageSize); + + var taxRatesModel = new List(); + foreach (var x in records) + { + var m = new TaxRateModel { + Id = x.Id, + StoreId = x.StoreId, + TaxCategoryId = x.TaxCategoryId, + CountryId = x.CountryId, + StateProvinceId = x.StateProvinceId, + Zip = x.Zip, + Percentage = x.Percentage + }; + //tax category + var tc = await _taxCategoryService.GetTaxCategoryById(x.TaxCategoryId); + m.TaxCategoryName = tc != null ? tc.Name : ""; + //country + var c = await _countryService.GetCountryById(x.CountryId); + m.CountryName = c != null ? c.Name : "Unavailable"; + //state + var s = c?.StateProvinces.FirstOrDefault(z => z.Id == x.StateProvinceId); + m.StateProvinceName = s != null ? s.Name : "*"; + //zip + m.Zip = !string.IsNullOrEmpty(x.Zip) ? x.Zip : "*"; + taxRatesModel.Add(m); + } + + var gridModel = new DataSourceResult { + Data = taxRatesModel, + Total = records.TotalCount + }; + + return Json(gridModel); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task RateUpdate(TaxRateModel model) + { + var taxRate = await _taxRateService.GetTaxRateById(model.Id); + //guard: a store owner can only edit his own store records + if (taxRate == null || taxRate.StoreId != CurrentStoreId) + return new JsonResult(""); + + taxRate.Zip = model.Zip == "*" ? null : model.Zip; + taxRate.Percentage = model.Percentage; + await _taxRateService.UpdateTaxRate(taxRate); + + return new JsonResult(""); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Delete)] + public async Task RateDelete(string id) + { + var taxRate = await _taxRateService.GetTaxRateById(id); + //guard: a store owner can only delete his own store records + if (taxRate == null || taxRate.StoreId != CurrentStoreId) + return new JsonResult(""); + + await _taxRateService.DeleteTaxRate(taxRate); + + return new JsonResult(""); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Create)] + public async Task AddTaxRate(TaxRateListModel model) + { + var taxRate = new TaxRate { + //force the current store - the owner cannot create records for another store + StoreId = CurrentStoreId, + TaxCategoryId = model.AddTaxCategoryId, + CountryId = model.AddCountryId, + StateProvinceId = model.AddStateProvinceId, + Zip = model.AddZip, + Percentage = model.AddPercentage + }; + await _taxRateService.InsertTaxRate(taxRate); + + return Json(new { Result = true }); + } +} diff --git a/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/TaxCountryStateZip/Configure.cshtml b/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/TaxCountryStateZip/Configure.cshtml new file mode 100644 index 0000000000..60234dd355 --- /dev/null +++ b/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/TaxCountryStateZip/Configure.cshtml @@ -0,0 +1,97 @@ +@using Microsoft.AspNetCore.Routing +@model Tax.CountryStateZip.Models.TaxRateListModel +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Tax.Providers"]; +} + + + + +@* the rates grid edits inline; adding a rate is the screen's one primary action. The form wraps + only the add card, as it wrapped only the add fields before, because the #addtaxrate script + posts $(this.form).serialize() *@ + + + + + + + + + + + +
+ +
+ @Loc["Plugins.Tax.CountryStateZip.AddRecord.Hint"] +
+
+ + + + + +
+
+ +
+
+ +
+
diff --git a/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Tax.CountryStateZip/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Tax.CountryStateZip/CountryStateZipTaxProvider.cs b/src/Plugins/Tax.CountryStateZip/CountryStateZipTaxProvider.cs index 5520072d01..f584cb98e2 100644 --- a/src/Plugins/Tax.CountryStateZip/CountryStateZipTaxProvider.cs +++ b/src/Plugins/Tax.CountryStateZip/CountryStateZipTaxProvider.cs @@ -1,7 +1,6 @@ -using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Catalog.Tax; using Grand.Business.Core.Interfaces.Common.Localization; using Grand.Business.Core.Utilities.Catalog; -using Grand.Infrastructure; using Grand.Infrastructure.Caching; using Tax.CountryStateZip.Infrastructure.Cache; using Tax.CountryStateZip.Services; @@ -15,18 +14,15 @@ public class CountryStateZipTaxProvider : ITaxProvider private readonly CountryStateZipTaxSettings _countryStateZipTaxSettings; private readonly ITaxRateService _taxRateService; private readonly ITranslationService _translationService; - private readonly IContextAccessor _contextAccessor; public CountryStateZipTaxProvider(ITranslationService translationService, ICacheBase cacheBase, - IContextAccessor contextAccessor, ITaxRateService taxRateService, CountryStateZipTaxSettings countryStateZipTaxSettings) { _translationService = translationService; _cacheBase = cacheBase; - _contextAccessor = contextAccessor; _taxRateService = taxRateService; _countryStateZipTaxSettings = countryStateZipTaxSettings; } @@ -51,65 +47,62 @@ public CountryStateZipTaxProvider(ITranslationService translationService, /// Tax public async Task GetTaxRate(TaxRequest calculateTaxRequest) { - var result = new TaxResult(); - if (calculateTaxRequest.Address == null) { - result.Errors.Add("Address is not set"); - return result; + var errorResult = new TaxResult(); + errorResult.Errors.Add("Address is not set"); + return errorResult; } - const string cacheKey = ModelCacheEventConsumer.ALL_TAX_RATES_MODEL_KEY; - var allTaxRates = await _cacheBase.GetAsync(cacheKey, async () => - { - var taxes = await _taxRateService.GetAllTaxRates(); - return taxes.Select(x => new TaxRateForCaching { - Id = x.Id, - StoreId = x.StoreId, - TaxCategoryId = x.TaxCategoryId, - CountryId = x.CountryId, - StateProvinceId = x.StateProvinceId, - Zip = x.Zip, - Percentage = x.Percentage - }); - }); - - var storeId = _contextAccessor.StoreContext.CurrentStore.Id; - var taxCategoryId = calculateTaxRequest.TaxCategoryId; - var countryId = calculateTaxRequest.Address.CountryId; - var stateProvinceId = calculateTaxRequest.Address.StateProvinceId; - var zip = calculateTaxRequest.Address.ZipPostalCode?.Trim() ?? string.Empty; - - var existingRates = allTaxRates - .Where(taxRate => taxRate.CountryId == countryId && taxRate.TaxCategoryId == taxCategoryId).ToList(); - - //filter by store - var matchedByStore = existingRates.Where(taxRate => storeId == taxRate.StoreId).ToList(); - - //not found? use the default ones (ID == 0) - if (!matchedByStore.Any()) - matchedByStore.AddRange(existingRates.Where(taxRate => string.IsNullOrEmpty(taxRate.StoreId))); - - //filter by state/province - var matchedByStateProvince = - matchedByStore.Where(taxRate => stateProvinceId == taxRate.StateProvinceId).ToList(); - - //not found? use the default ones (ID == 0) - if (!matchedByStateProvince.Any()) - matchedByStateProvince.AddRange(matchedByStore.Where(taxRate => - string.IsNullOrEmpty(taxRate.StateProvinceId))); - - //filter by zip - if(!string.IsNullOrEmpty(zip)) - { - var matchedByZip = matchedByStateProvince.Where(taxRate => zip.Equals(taxRate.Zip, StringComparison.OrdinalIgnoreCase)).ToList(); - if (matchedByZip.Any()) - { - result.TaxRate = matchedByZip[0].Percentage; - return result; - } - } + var allTaxRates = await GetAllTaxRatesFromCache(); + + var address = calculateTaxRequest.Address; + var storeId = calculateTaxRequest.Store?.Id ?? string.Empty; + var zip = address.ZipPostalCode?.Trim() ?? string.Empty; + + var byCountryAndCategory = allTaxRates + .Where(r => r.CountryId == address.CountryId && r.TaxCategoryId == calculateTaxRequest.TaxCategoryId) + .ToList(); + + var byStore = MatchOrFallback(byCountryAndCategory, + r => r.StoreId == storeId, + r => string.IsNullOrEmpty(r.StoreId)); + + var byStateProvince = MatchOrFallback(byStore, + r => r.StateProvinceId == address.StateProvinceId, + r => string.IsNullOrEmpty(r.StateProvinceId)); - return result; + var matched = byStateProvince + .OrderByDescending(r => !string.IsNullOrWhiteSpace(r.Zip)) + .FirstOrDefault(r => + string.IsNullOrWhiteSpace(r.Zip) || + (!string.IsNullOrEmpty(zip) && zip.Equals(r.Zip, StringComparison.OrdinalIgnoreCase))); + + return new TaxResult { TaxRate = matched?.Percentage ?? 0 }; + } + + private async Task> GetAllTaxRatesFromCache() + { + return await _cacheBase.GetAsync( + ModelCacheEventConsumer.ALL_TAX_RATES_MODEL_KEY, + async () => (await _taxRateService.GetAllTaxRates()) + .Select(x => new TaxRateForCaching { + Id = x.Id, + StoreId = x.StoreId, + TaxCategoryId = x.TaxCategoryId, + CountryId = x.CountryId, + StateProvinceId = x.StateProvinceId, + Zip = x.Zip, + Percentage = x.Percentage + }).ToList()); + } + + private static List MatchOrFallback( + List source, + Func exact, + Func fallback) + { + List matched = [..source.Where(exact)]; + return matched.Count > 0 ? matched : [..source.Where(fallback)]; } -} \ No newline at end of file +} diff --git a/src/Plugins/Tax.CountryStateZip/Infrastructure/Cache/ModelCacheEventConsumer.cs b/src/Plugins/Tax.CountryStateZip/Infrastructure/Cache/ModelCacheEventConsumer.cs index c46bec2358..44f31f610f 100644 --- a/src/Plugins/Tax.CountryStateZip/Infrastructure/Cache/ModelCacheEventConsumer.cs +++ b/src/Plugins/Tax.CountryStateZip/Infrastructure/Cache/ModelCacheEventConsumer.cs @@ -1,6 +1,6 @@ using Grand.Infrastructure.Caching; using Grand.Infrastructure.Events; -using MediatR; +using Grand.Mediator; using Tax.CountryStateZip.Domain; namespace Tax.CountryStateZip.Infrastructure.Cache; diff --git a/src/Plugins/Tax.CountryStateZip/Services/ITaxRateService.cs b/src/Plugins/Tax.CountryStateZip/Services/ITaxRateService.cs index bd462a29ec..565ef62bcf 100644 --- a/src/Plugins/Tax.CountryStateZip/Services/ITaxRateService.cs +++ b/src/Plugins/Tax.CountryStateZip/Services/ITaxRateService.cs @@ -17,8 +17,11 @@ public interface ITaxRateService /// /// Gets all tax rates /// + /// Store identifier; pass an empty string to return rates of all stores + /// Page index + /// Page size /// Tax rates - Task> GetAllTaxRates(int pageIndex = 0, int pageSize = int.MaxValue); + Task> GetAllTaxRates(string storeId = "", int pageIndex = 0, int pageSize = int.MaxValue); /// /// Gets a tax rate diff --git a/src/Plugins/Tax.CountryStateZip/Services/TaxRateService.cs b/src/Plugins/Tax.CountryStateZip/Services/TaxRateService.cs index ddff997867..f361e32d94 100644 --- a/src/Plugins/Tax.CountryStateZip/Services/TaxRateService.cs +++ b/src/Plugins/Tax.CountryStateZip/Services/TaxRateService.cs @@ -2,7 +2,7 @@ using Grand.Domain; using Grand.Infrastructure.Caching; using Grand.Infrastructure.Extensions; -using MediatR; +using Grand.Mediator; using Tax.CountryStateZip.Domain; namespace Tax.CountryStateZip.Services; @@ -33,7 +33,7 @@ public TaxRateService(IMediator mediator, #region Constants - private const string TAXRATE_ALL_KEY = "Grand.taxrate.all-{0}-{1}"; + private const string TAXRATE_ALL_KEY = "Grand.taxrate.all-{0}-{1}-{2}"; private const string TAXRATE_PATTERN_KEY = "Grand.taxrate."; #endregion @@ -68,15 +68,22 @@ public virtual async Task DeleteTaxRate(TaxRate taxRate) /// Gets all tax rates /// /// Tax rates - public virtual async Task> GetAllTaxRates(int pageIndex = 0, int pageSize = int.MaxValue) + public virtual async Task> GetAllTaxRates(string storeId = "", int pageIndex = 0, + int pageSize = int.MaxValue) { - var key = string.Format(TAXRATE_ALL_KEY, pageIndex, pageSize); + var key = string.Format(TAXRATE_ALL_KEY, storeId, pageIndex, pageSize); return await _cacheBase.GetAsync(key, async () => { - var query = from tr in _taxRateRepository.Table - orderby tr.StoreId, tr.CountryId, tr.StateProvinceId, tr.Zip, tr.TaxCategoryId - select tr; - return await Task.FromResult(new PagedList(query, pageIndex, pageSize)); + var query = _taxRateRepository.Table.AsQueryable(); + //filter by store when requested; an empty storeId returns rates of all stores + if (!string.IsNullOrEmpty(storeId)) + query = query.Where(tr => tr.StoreId == storeId); + + var ordered = query + .OrderBy(tr => tr.StoreId).ThenBy(tr => tr.CountryId).ThenBy(tr => tr.StateProvinceId) + .ThenBy(tr => tr.Zip).ThenBy(tr => tr.TaxCategoryId); + + return await _taxRateRepository.PagedAsync(ordered, pageIndex, pageSize); }); } diff --git a/src/Plugins/Tax.CountryStateZip/Tax.CountryStateZip.csproj b/src/Plugins/Tax.CountryStateZip/Tax.CountryStateZip.csproj index 93c278f6bf..e4944e7d89 100644 --- a/src/Plugins/Tax.CountryStateZip/Tax.CountryStateZip.csproj +++ b/src/Plugins/Tax.CountryStateZip/Tax.CountryStateZip.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Tax.FixedRate/Areas/Admin/Controllers/TaxFixedRateController.cs b/src/Plugins/Tax.FixedRate/Areas/Admin/Controllers/TaxFixedRateController.cs index d3cc999c23..ef4a3f04cf 100644 --- a/src/Plugins/Tax.FixedRate/Areas/Admin/Controllers/TaxFixedRateController.cs +++ b/src/Plugins/Tax.FixedRate/Areas/Admin/Controllers/TaxFixedRateController.cs @@ -28,6 +28,7 @@ public IActionResult Configure() } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task Configure(DataSourceRequest command) { var taxRateModels = new List(); @@ -46,6 +47,7 @@ public async Task Configure(DataSourceRequest command) } [HttpPost] + [AutoValidateAntiforgeryToken] public async Task TaxRateUpdate(FixedTaxRateModel model) { var taxCategoryId = model.TaxCategoryId; diff --git a/src/Plugins/Tax.FixedRate/Areas/Admin/Views/TaxFixedRate/Configure.cshtml b/src/Plugins/Tax.FixedRate/Areas/Admin/Views/TaxFixedRate/Configure.cshtml index 99d422b75d..d65ef7ec12 100644 --- a/src/Plugins/Tax.FixedRate/Areas/Admin/Views/TaxFixedRate/Configure.cshtml +++ b/src/Plugins/Tax.FixedRate/Areas/Admin/Views/TaxFixedRate/Configure.cshtml @@ -1,93 +1,20 @@ @{ - Layout = "_ConfigurePlugin"; + Layout = "_AdminLayout"; + ViewBag.Title = Loc["Tax.FixedRate.FriendlyName"]; } -
- \ No newline at end of file +@* the grid edits inline, so there is no primary action on this screen *@ + + + + + + + + + diff --git a/src/Plugins/Tax.FixedRate/Areas/Store/Controllers/TaxFixedRateController.cs b/src/Plugins/Tax.FixedRate/Areas/Store/Controllers/TaxFixedRateController.cs new file mode 100644 index 0000000000..ebc39dc4a9 --- /dev/null +++ b/src/Plugins/Tax.FixedRate/Areas/Store/Controllers/TaxFixedRateController.cs @@ -0,0 +1,103 @@ +using Grand.Business.Core.Interfaces.Catalog.Tax; +using Grand.Business.Core.Interfaces.Common.Configuration; +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 Tax.FixedRate.Models; + +namespace Tax.FixedRate.Areas.Store.Controllers; + +/// +/// Store-manager configuration of the fixed tax rate provider. +/// Shared (global) rates are managed by the administrator and are read-only here - +/// only a store specific rate can be edited, and editing is always scoped to +/// (it never modifies the shared/other stores value). +/// +[Area("Store")] +[AuthorizeStore] +[AuthorizeMenu] +[AutoValidateAntiforgeryToken] +[PermissionAuthorize(PermissionSystemName.TaxSettings)] +public class TaxFixedRateController : BaseController +{ + private readonly IContextAccessor _contextAccessor; + private readonly ISettingService _settingService; + private readonly ITaxCategoryService _taxCategoryService; + + public TaxFixedRateController(ITaxCategoryService taxCategoryService, + ISettingService settingService, + IContextAccessor contextAccessor) + { + _taxCategoryService = taxCategoryService; + _settingService = settingService; + _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] + [PermissionAuthorizeAction(PermissionActionName.List)] + public async Task Configure(DataSourceRequest command) + { + var taxRateModels = new List(); + foreach (var taxCategory in await _taxCategoryService.GetAllTaxCategories(CurrentStoreId)) + { + taxRateModels.Add(new FixedTaxRateModel { + TaxCategoryId = taxCategory.Id, + TaxCategoryName = taxCategory.Name, + Rate = await GetTaxRate(taxCategory.Id, CurrentStoreId), + StoreId = taxCategory.StoreId + }); + } + + var gridModel = new DataSourceResult { + Data = taxRateModels, + Total = taxRateModels.Count + }; + return Json(gridModel); + } + + [HttpPost] + [PermissionAuthorizeAction(PermissionActionName.Edit)] + public async Task TaxRateUpdate(FixedTaxRateModel model) + { + if (!await HasStoreRate(model.TaxCategoryId)) + return new JsonResult(""); + + await _settingService.SetSetting($"Tax.TaxProvider.FixedRate.TaxCategoryId{model.TaxCategoryId}", + new FixedTaxRate { Rate = model.Rate }, CurrentStoreId); + + return new JsonResult(""); + } + + /// + /// Whether a store specific override exists for the tax category (store = current store). + /// + [NonAction] + private async Task HasStoreRate(string taxCategoryId) + { + var taxCategory = await _taxCategoryService.GetTaxCategoryById(taxCategoryId); + return taxCategory != null && taxCategory.StoreId == CurrentStoreId; + } + + [NonAction] + private async Task GetTaxRate(string taxCategoryId, string storeId) + { + //store override when present, otherwise the shared/global value (built-in fallback) + var rate = (await _settingService.GetSettingByKey( + $"Tax.TaxProvider.FixedRate.TaxCategoryId{taxCategoryId}", storeId: storeId))?.Rate; + return rate ?? 0; + } +} diff --git a/src/Plugins/Tax.FixedRate/Areas/Store/Views/TaxFixedRate/Configure.cshtml b/src/Plugins/Tax.FixedRate/Areas/Store/Views/TaxFixedRate/Configure.cshtml new file mode 100644 index 0000000000..ef6431ebed --- /dev/null +++ b/src/Plugins/Tax.FixedRate/Areas/Store/Views/TaxFixedRate/Configure.cshtml @@ -0,0 +1,22 @@ +@{ + Layout = "~/Areas/Store/Views/Shared/_StoreLayout.cshtml"; + ViewBag.Title = Loc["Admin.Configuration.Tax.Providers"]; +} + + +@* the grid edits inline, so there is no primary action on this screen *@ + + + + + + @*a shared/global rate (empty StoreId) is managed by the administrator; only a store specific rate can be edited - the controller checks it too*@ + + + + diff --git a/src/Plugins/Tax.FixedRate/Areas/Store/Views/_ViewImports.cshtml b/src/Plugins/Tax.FixedRate/Areas/Store/Views/_ViewImports.cshtml new file mode 100644 index 0000000000..0d1778c763 --- /dev/null +++ b/src/Plugins/Tax.FixedRate/Areas/Store/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, Grand.Web.Common + +@using Microsoft.AspNetCore.Mvc.ViewFeatures +@using Grand.Infrastructure.Extensions +@using Grand.Web.Common.Localization + +@inject LocService Loc diff --git a/src/Plugins/Tax.FixedRate/FixedRateTaxProvider.cs b/src/Plugins/Tax.FixedRate/FixedRateTaxProvider.cs index f12648aa6c..a4e49f3c11 100644 --- a/src/Plugins/Tax.FixedRate/FixedRateTaxProvider.cs +++ b/src/Plugins/Tax.FixedRate/FixedRateTaxProvider.cs @@ -37,7 +37,8 @@ public FixedRateTaxProvider(ISettingService settingService, ITranslationService public async Task GetTaxRate(TaxRequest calculateTaxRequest) { var result = new TaxResult { - TaxRate = await GetTaxRate(calculateTaxRequest.TaxCategoryId) + TaxRate = await GetTaxRate(calculateTaxRequest.TaxCategoryId, + calculateTaxRequest.Store?.Id ?? string.Empty) }; return result; } @@ -46,10 +47,14 @@ public async Task GetTaxRate(TaxRequest calculateTaxRequest) /// Gets a tax rate /// /// The tax category identifier + /// The store identifier the tax is being calculated for /// Tax rate - private async Task GetTaxRate(string taxCategoryId) + private async Task GetTaxRate(string taxCategoryId, string storeId) { - var rate = (await _settingService.GetSettingByKey($"Tax.TaxProvider.FixedRate.TaxCategoryId{taxCategoryId}"))?.Rate; + //use the store specific rate when defined, otherwise fall back to the global rate + //(SettingService.GetSettingByKey resolves the store override first and the global value second) + var rate = (await _settingService.GetSettingByKey( + $"Tax.TaxProvider.FixedRate.TaxCategoryId{taxCategoryId}", storeId: storeId))?.Rate; return rate ?? 0; } } \ No newline at end of file diff --git a/src/Plugins/Tax.FixedRate/Models/FixedTaxRateModel.cs b/src/Plugins/Tax.FixedRate/Models/FixedTaxRateModel.cs index 0df58e2f8e..9e6d454133 100644 --- a/src/Plugins/Tax.FixedRate/Models/FixedTaxRateModel.cs +++ b/src/Plugins/Tax.FixedRate/Models/FixedTaxRateModel.cs @@ -6,6 +6,12 @@ public class FixedTaxRateModel { public string TaxCategoryId { get; set; } + /// + /// Store the rate belongs to. Empty means the shared (global) rate, which is read-only + /// in the store panel; the current store id means a store specific, editable rate. + /// + public string StoreId { get; set; } + [GrandResourceDisplayName("Plugins.Tax.FixedRate.Fields.TaxCategoryName")] public string TaxCategoryName { get; set; } diff --git a/src/Plugins/Tax.FixedRate/Tax.FixedRate.csproj b/src/Plugins/Tax.FixedRate/Tax.FixedRate.csproj index 24b252df64..2316dea02e 100644 --- a/src/Plugins/Tax.FixedRate/Tax.FixedRate.csproj +++ b/src/Plugins/Tax.FixedRate/Tax.FixedRate.csproj @@ -1,5 +1,6 @@  + true false @@ -15,27 +16,6 @@ $(OutputPath) - - - false - - - false - - - false - - - false - - - false - - - false - all - - diff --git a/src/Plugins/Theme.Modern/Content/css/account-cart.css b/src/Plugins/Theme.Modern/Content/css/account-cart.css new file mode 100644 index 0000000000..0fa3bdc783 --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/account-cart.css @@ -0,0 +1,198 @@ +/* Modern - account, cart, checkout, order details, compare, newsletter, footer. */ + +/* ---------- newsletter (Default Partials/NewsletterBox, above the footer) ---------- */ + +.newsletter.bg-info, +[dark-theme="true"] .newsletter.bg-info { + width: auto; + margin-top: 64px !important; /* beats the !important .mt-3 utility */ + margin-inline: auto; + max-width: 1320px; + padding: clamp(28px, 4vw, 48px) clamp(20px, 4vw, 56px); + background: var(--mdn-ink) !important; /* beats the !important .bg-info utility */ + border-radius: var(--mdn-r-block); + color: var(--mdn-on-ink); +} +@media (max-width: 1399.98px) { + .newsletter.bg-info { margin-inline: 12px; } +} +.newsletter .newsletter-title { color: var(--mdn-on-ink); font-size: clamp(24px, 2.6vw, 34px); font-weight: 600; letter-spacing: -.025em; } +.newsletter .newsletter-desc { color: color-mix(in srgb, var(--mdn-on-ink) 70%, transparent); font-size: 16px; } +.newsletter .input-group { gap: 8px; flex-wrap: nowrap; } +.newsletter .newsletter-email.form-control, +[dark-theme="true"] .newsletter .newsletter-email.form-control { + min-height: 48px; + margin: 0; + padding-inline: 20px; + background: color-mix(in srgb, var(--mdn-on-ink) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--mdn-on-ink) 22%, transparent); + border-radius: var(--mdn-r-pill); + color: var(--mdn-on-ink); + text-align: left; +} +/* Bootstrap squares the inner corners at (0,6,0): .input-group:not(.has-validation) > :not(:last-child)... */ +.newsletter .input-group > .newsletter-email.form-control:not(:last-child):not(.dropdown-toggle) { border-radius: var(--mdn-r-pill); } +.newsletter .newsletter-email.form-control::placeholder { color: color-mix(in srgb, var(--mdn-on-ink) 55%, transparent); } +.newsletter .newsletter-email-button.btn, +[dark-theme="true"] .newsletter .newsletter-email-button.btn { + min-height: 48px; + padding-inline: 20px; + background: var(--mdn-on-ink); + border: 0; + border-radius: var(--mdn-r-pill); + color: var(--mdn-ink); +} +.newsletter .form-check-label.text-white, +.newsletter .text-white { color: var(--mdn-on-ink) !important; } /* beats .text-white */ + +/* ---------- footer ---------- */ + +.mdn-footer, +[dark-theme="true"] .mdn-footer { + margin-top: 48px !important; /* beats .mt-3 */ + padding-top: 48px; + background: var(--mdn-surface); + color: var(--mdn-ink); +} +.mdn-footer .list-group, +.mdn-footer .list-group.bg-light { flex-direction: column; align-items: flex-start; justify-content: flex-start; margin: 0 !important; background: transparent !important; } /* beats .bg-light / .my-3 */ +.mdn-footer .list-group-item, +.mdn-footer .list-group-item:not(.list-group-item-action), +[dark-theme="true"] .mdn-footer .list-group-item, +[dark-theme="true"] .mdn-footer .list-group-item-action { + width: auto; + padding: 5px 0; + background: transparent; + border: 0; + color: var(--mdn-muted-text); + font-size: 14px; + text-align: left; +} +.mdn-footer .list-group-item-action:hover, +[dark-theme="true"] .mdn-footer .list-group-item-action:hover { background: transparent; color: var(--mdn-ink); } +.mdn-footer .about-us .list-group-item { display: flex; flex-direction: column; } +.mdn-footer .about-us .list-group-item span:first-child { color: var(--mdn-ink); font-size: 12px; font-weight: 600; } + +@media (min-width: 992px) { + .mdn-footer .container > .row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 48px; } + .mdn-footer .footer-block { width: auto; max-width: none; padding: 0; } + .mdn-footer .mdn-social { grid-column: 1 / -1; } + /* Default shows these only as the phone accordion headers; on desktop they head the columns */ + .mdn-footer .step-title, + [dark-theme="true"] .mdn-footer .step-title { + display: block !important; /* beats .d-lg-none */ + margin: 0 0 8px !important; /* beats the .mb-1 utility */ + padding: 0 !important; /* beats the .p-3 utility */ + background: transparent; + border: 0; + pointer-events: none; + } + .mdn-footer .step-title .h5 { display: block; color: var(--mdn-ink); font-size: 13px; font-weight: 600; text-align: left !important; } /* beats the .text-center utility */ +} +@media (max-width: 991.98px) { + .mdn-footer .step-title, + [dark-theme="true"] .mdn-footer .step-title { background: var(--mdn-bg); border: 0; border-radius: var(--mdn-r-input); } +} + +.mdn-social { flex-direction: row !important; gap: 8px; justify-content: flex-start !important; margin-top: 24px; } /* beats .justify-content-center */ +.mdn-social .list-group-item, +[dark-theme="true"] .mdn-footer .mdn-social .list-group-item { padding: 0; border: 0; background: transparent; filter: none; } +.mdn-social a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--mdn-bg); + border-radius: var(--mdn-r-pill); + color: var(--mdn-ink); + font-size: 17px; + transition: background-color var(--mdn-dur) var(--mdn-ease), color var(--mdn-dur) var(--mdn-ease); +} +.mdn-social a:hover { background: var(--mdn-ink); color: var(--mdn-on-ink); } + +.mdn-footer .footer-lower { margin-top: 32px; border-color: var(--mdn-line) !important; color: var(--mdn-muted-text); } /* beats .border-top's colour */ +.mdn-footer .footer-lower a.text-success { color: var(--mdn-ink) !important; } /* beats .text-success */ + +/* ---------- account ---------- */ + +.block-account-navigation .list-group-item, +.block-account-navigation ul li a, +[dark-theme="true"] .block-account-navigation .list-group-item { + padding: 10px 14px; + border: 0; + border-radius: var(--mdn-r-input); + background: transparent; + color: var(--mdn-muted-text); +} +.block-account-navigation ul li a:hover, +.block-account-navigation ul li a.active, +[dark-theme="true"] .block-account-navigation ul li a.active, +[dark-theme="true"] .block-account-navigation ul li a:hover { + background: var(--mdn-surface); + border-color: transparent; + color: var(--mdn-ink); +} +.block-account-navigation ul li a.active { font-weight: 600; } + +/* ---------- cart ---------- */ + +.shopping-cart.table, +[dark-theme="true"] .shopping-cart.table td { background: transparent; border-color: var(--mdn-line); } +.shopping-cart-container .product-picture img { background: var(--mdn-surface); border-radius: var(--mdn-r-input); } +.shopping-cart-container .product a { color: var(--mdn-ink); font-weight: 500; } +.shopping-cart .prod-total, +.shopping-cart .unit-price, +.shopping-cart .product-unit-price { color: var(--mdn-ink); font-family: var(--mdn-mono); } +.shopping-cart .qty-input { max-width: 80px; text-align: center; border-radius: var(--mdn-r-pill); } + +.shopping-cart-page .totals-inner, +[dark-theme="true"] .shopping-cart-page .totals-inner { + padding: 20px; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); +} +.totals .value-summary { font-family: var(--mdn-mono); } +.checkout-buttons .checkout-button { min-height: 52px; font-size: 16px; text-transform: none !important; } /* beats .text-uppercase */ + +/* ---------- checkout ---------- */ + +.checkout-page .card, +[dark-theme="true"] .checkout-page .card { background: var(--mdn-surface); border-radius: var(--mdn-r-card); } +.checkout-page .card-header h2 { font-size: 18px; font-weight: 600; } +.checkout-page .nav-pills .nav-link { border-radius: var(--mdn-r-pill); color: var(--mdn-muted-text); } +.checkout-page .nav-pills .nav-link.active { background: var(--mdn-ink); color: var(--mdn-on-ink); } + +/* one-page checkout step headers and totals (Default: teal / cyan) */ +.opc-header .card-header .btn-info, +[dark-theme="true"] .opc-header .card-header .btn-info { background: transparent; color: var(--mdn-muted-text); } +.opc-header .card-header .btn-info:not(.collapsed), +[dark-theme="true"] .opc-header .card-header .btn-info:not(.collapsed) { color: var(--mdn-ink); } +.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 { background: var(--mdn-accent); } +.opc-totals, +.checkout-page .col-more .order-summary-content .cart-footer { + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); +} +.opc-totals .qty-opc { background: var(--mdn-accent); color: var(--mdn-on-accent); } + +/* ---------- order details ---------- */ + +.order-details-page .card, +[dark-theme="true"] .order-details-page .card { background: var(--mdn-surface); } +.order-details-page .card-header { font-weight: 600; } + +/* ---------- compare ---------- */ + +.compare-products-table, +[dark-theme="true"] .compare-products-table { --bs-table-bg: transparent; border-color: var(--mdn-line); } +.compare-products-table img { background: var(--mdn-surface); border-radius: var(--mdn-r-input); } + +/* column heads carry .price too; only the values are mono */ +.shopping-cart th, +.shopping-cart thead .price { font-family: var(--mdn-sans); } diff --git a/src/Plugins/Theme.Modern/Content/css/base.css b/src/Plugins/Theme.Modern/Content/css/base.css new file mode 100644 index 0000000000..ee37ae5644 --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/base.css @@ -0,0 +1,354 @@ +/* Modern - base: type, buttons, forms, cards, badges, breadcrumbs, drawers, modals. */ + +body, +#app, +#app[dark-theme="true"] { + background-color: var(--mdn-bg); + color: var(--mdn-ink); + font-family: var(--mdn-sans); + font-size: 15px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +@media (min-width: 992px) { + body { font-size: 16px; } +} + +::selection { background: color-mix(in srgb, var(--mdn-accent) 25%, transparent); } + +:focus-visible { + outline: 2px solid var(--mdn-accent); + outline-offset: 2px; +} + +a, +[dark-theme="true"] a { color: var(--mdn-ink); text-decoration: none; } +a:hover, +[dark-theme="true"] a:hover { color: var(--mdn-accent); } + +h1, .h1, h2, .h2, h3, .h3, h4, .h4, .generalTitle { + color: var(--mdn-ink); + font-family: var(--mdn-sans); + font-weight: 600; + letter-spacing: -.02em; + line-height: 1.15; +} +h1, .h1 { font-size: clamp(28px, 3.2vw, 40px); } +h2, .h2, .generalTitle { font-size: clamp(22px, 2.4vw, 30px); } + +.mdn-mono, +.price, +.actual-price, +.old-price, +.sku, +.cart-qty, +.wishlist-qty, +.compare-qty { + font-family: var(--mdn-mono); + font-variant-numeric: tabular-nums; + letter-spacing: -.01em; +} + +.mdn-section-title { + margin: 0; + font-size: clamp(20px, 2vw, 26px); + font-weight: 600; + letter-spacing: -.02em; +} + +hr { border-color: var(--mdn-line); opacity: 1; } + +/* ---------- buttons ---------- */ + +.btn { + border-radius: var(--mdn-r-pill); + font-weight: 500; + letter-spacing: -.005em; + transition: background-color var(--mdn-dur) var(--mdn-ease), color var(--mdn-dur) var(--mdn-ease), + border-color var(--mdn-dur) var(--mdn-ease), transform var(--mdn-dur) var(--mdn-ease); +} +.btn:active { transform: scale(.98); } + +/* Default uses .btn-info for the primary action (add to cart, checkout, subscribe) */ +.btn-info, +.btn-primary, +.btn-dark, +[dark-theme="true"] .btn-info { + --bs-btn-bg: var(--mdn-ink); + --bs-btn-border-color: var(--mdn-ink); + --bs-btn-color: var(--mdn-on-ink); + --bs-btn-hover-bg: color-mix(in srgb, var(--mdn-ink) 85%, var(--mdn-bg)); + --bs-btn-hover-border-color: color-mix(in srgb, var(--mdn-ink) 85%, var(--mdn-bg)); + --bs-btn-hover-color: var(--mdn-on-ink); + --bs-btn-active-bg: var(--mdn-ink); + --bs-btn-active-border-color: var(--mdn-ink); + --bs-btn-active-color: var(--mdn-on-ink); + --bs-btn-disabled-bg: var(--mdn-surface-2); + --bs-btn-disabled-border-color: var(--mdn-surface-2); + --bs-btn-disabled-color: var(--mdn-muted-text); +} + +.btn-secondary, +.btn-light, +[dark-theme="true"] .btn-secondary, +[dark-theme="true"] .btn-light { + --bs-btn-bg: var(--mdn-surface); + --bs-btn-border-color: var(--mdn-surface); + --bs-btn-color: var(--mdn-ink); + --bs-btn-hover-bg: var(--mdn-surface-2); + --bs-btn-hover-border-color: var(--mdn-surface-2); + --bs-btn-hover-color: var(--mdn-ink); + --bs-btn-active-bg: var(--mdn-surface-2); + --bs-btn-active-border-color: var(--mdn-surface-2); + --bs-btn-active-color: var(--mdn-ink); +} + +.btn-outline-info, +.btn-outline-secondary, +.btn-outline-primary, +[dark-theme="true"] .btn-outline-secondary { + --bs-btn-bg: transparent; + --bs-btn-border-color: var(--mdn-line); + --bs-btn-color: var(--mdn-ink); + --bs-btn-hover-bg: var(--mdn-surface); + --bs-btn-hover-border-color: var(--mdn-line); + --bs-btn-hover-color: var(--mdn-ink); + --bs-btn-active-bg: var(--mdn-surface-2); + --bs-btn-active-border-color: var(--mdn-line); + --bs-btn-active-color: var(--mdn-ink); +} + +.btn-link { --bs-btn-color: var(--mdn-ink); --bs-btn-hover-color: var(--mdn-accent); } + +/* Default paints .btn-secondary #333 and .btn-outline-secondary #333 directly in light mode + too (common.css "buttons"); in Modern the secondary button is a soft surface */ +.btn-secondary, +.btn-outline-secondary { + color: var(--bs-btn-color); + background-color: var(--bs-btn-bg); + border-color: var(--bs-btn-border-color); +} +.btn-secondary:hover, +.btn-secondary:focus, +.btn-secondary:not(:disabled):not(.disabled):active, +.btn-outline-secondary:hover { + color: var(--bs-btn-hover-color); + background-color: var(--bs-btn-hover-bg); + border-color: var(--bs-btn-hover-border-color); +} + +/* Default's dark mode sets color / background / border on these buttons directly + (common.css, "[dark-theme="true"] .btn-info { color: #fff }" and friends), which bypasses + the variables above - a white add-to-cart button with white text. Point them back. */ +[dark-theme="true"] .btn-info, +[dark-theme="true"] .btn-outline-info, +[dark-theme="true"] .btn-secondary, +[dark-theme="true"] .btn-light, +[dark-theme="true"] .btn-outline-secondary, +[dark-theme="true"] #ModalAddToCart .btn-dark, +[dark-theme="true"] #ModalAddToCart .btn-outline-secondary { + color: var(--bs-btn-color); + background-color: var(--bs-btn-bg); + border-color: var(--bs-btn-border-color); +} +[dark-theme="true"] .btn-info:hover, +[dark-theme="true"] .btn-outline-info:hover, +[dark-theme="true"] .btn-secondary:hover, +[dark-theme="true"] .btn-light:hover, +[dark-theme="true"] .btn-outline-secondary:hover, +[dark-theme="true"] #ModalAddToCart .btn-dark:hover, +[dark-theme="true"] #ModalAddToCart .btn-outline-secondary:hover { + color: var(--bs-btn-hover-color); + background-color: var(--bs-btn-hover-bg); + border-color: var(--bs-btn-hover-border-color); +} + +/* ---------- forms ---------- */ + +.form-control, +.form-select, +.custom-select { + min-height: 44px; + background-color: var(--mdn-bg); + border: 1px solid var(--mdn-line); + border-radius: var(--mdn-r-input); + color: var(--mdn-ink); + transition: border-color var(--mdn-dur) var(--mdn-ease), box-shadow var(--mdn-dur) var(--mdn-ease); +} +.form-control:focus, +.form-select:focus { + border-color: var(--mdn-accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--mdn-accent) 18%, transparent); +} +.form-control::placeholder { color: var(--mdn-muted-text); } +.form-label, .col-form-label { color: var(--mdn-ink); font-weight: 500; } + +.form-check-input { border-color: var(--mdn-line); background-color: var(--mdn-bg); } +.form-check-input:checked, +[data-bs-theme=dark] .form-check-input:checked { + background-color: var(--mdn-accent); + border-color: var(--mdn-accent); +} + +/* ---------- surfaces ---------- */ + +.card, +[dark-theme="true"] .card { + background-color: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); + color: var(--mdn-ink); +} +.card-header, +.card-footer, +[dark-theme="true"] .card-header, +[dark-theme="true"] .card-footer { + background: transparent; + border-color: var(--mdn-line); +} + +.list-group-item, +[dark-theme="true"] .list-group-item { + background-color: transparent; + border-color: var(--mdn-line); + color: var(--mdn-ink); +} + +.table, +[dark-theme="true"] .table { + --bs-table-bg: transparent; + --bs-table-color: var(--mdn-ink); + --bs-table-border-color: var(--mdn-line); +} + +.alert { + border: 0; + border-radius: var(--mdn-r-card); +} +.alert-info, +[dark-theme="true"] .alert-info { + background: color-mix(in srgb, var(--mdn-accent) 10%, var(--mdn-bg)); + color: var(--mdn-ink); +} + +/* ---------- badges ---------- */ + +.badge { + border-radius: var(--mdn-r-pill); + font-weight: 600; + letter-spacing: 0; + padding: .35em .7em; +} +/* .text-bg-info is !important in Bootstrap; this is Default's product Flag badge */ +.text-bg-info { + background-color: var(--mdn-accent) !important; + color: var(--mdn-on-accent) !important; +} +.text-info, [dark-theme="true"] .text-info { color: var(--mdn-accent) !important; } /* the utility and Default's dark override are both !important */ + +/* ---------- breadcrumbs, pagination ---------- */ + +.breadcrumb { + margin: 16px 0 8px; + padding: 0; + background: transparent; + font-size: 13px; + --bs-breadcrumb-divider-color: var(--mdn-muted-text); +} +.breadcrumb a, .breadcrumb-item { color: var(--mdn-muted-text); } +.breadcrumb-item.active, +[dark-theme="true"] .breadcrumb-item.active { color: var(--mdn-ink); } + +.pagination { + --bs-pagination-bg: transparent; + --bs-pagination-border-color: transparent; + --bs-pagination-color: var(--mdn-ink); + --bs-pagination-hover-bg: var(--mdn-surface); + --bs-pagination-hover-border-color: transparent; + --bs-pagination-hover-color: var(--mdn-ink); + --bs-pagination-active-bg: var(--mdn-ink); + --bs-pagination-active-border-color: var(--mdn-ink); + --bs-pagination-active-color: var(--mdn-on-ink); + --bs-pagination-border-radius: var(--mdn-r-pill); + gap: 4px; +} +.pagination .page-link { border-radius: var(--mdn-r-pill); min-width: 40px; text-align: center; } + +/* ---------- drawers and modals ---------- */ + +.offcanvas, +.b-sidebar, +[dark-theme="true"] .b-sidebar { + background-color: var(--mdn-bg); + color: var(--mdn-ink); + border-color: var(--mdn-line); +} +.b-sidebar-header, +.b-sidebar-footer, +[dark-theme="true"] .b-sidebar-header, +[dark-theme="true"] .b-sidebar-footer { + background-color: var(--mdn-bg); + border-color: var(--mdn-line); +} +.offcanvas-backdrop.show, +.modal-backdrop.show { + opacity: 1; + background: rgba(10, 10, 12, .45); + backdrop-filter: blur(4px); +} + +.modal-content, +[dark-theme="true"] .modal-content { + background-color: var(--mdn-bg); + border: 0; + border-radius: var(--mdn-r-block); + box-shadow: var(--mdn-shadow-lift); + color: var(--mdn-ink); +} +[data-bs-theme=dark] .modal .btn-close, +[data-bs-theme=dark] .offcanvas .btn-close { filter: invert(1) grayscale(1) brightness(2); } + +/* ---------- Default's hard-coded teal (#0E606C) and cyan (#1FB0C7, #17a2b8) ---------- */ + +.up-btn, +[dark-theme="true"] .up-btn { background: var(--mdn-ink); color: var(--mdn-on-ink); } +.modal-close:hover, +.modal-close:focus { color: var(--mdn-accent); } +#search-box .input-group .search-box-button { color: var(--mdn-ink); } +#search-box .modal-body .search-box-text:focus { border-color: var(--mdn-accent); } +.quick-view-overview .actual-price, +[dark-theme="true"] .quick-view-overview .actual-price, +.advanced-search-results .price { color: var(--mdn-ink); } + +.pagination .currentpage.page-item .page-link, +.pagination .current-page.page-item .page-link { + background-color: var(--mdn-ink); + border-color: var(--mdn-ink); + color: var(--mdn-on-ink); +} +.pagination .page-item:not(.currentpage):hover .page-link { + background-color: var(--mdn-surface); + border-color: var(--mdn-surface); + color: var(--mdn-ink); +} +.nav-tabs .nav-link.active::before { background: var(--mdn-ink); } + +/* ---------- page chrome ---------- */ + +.main-container { padding-bottom: 48px; } + +.page-loader-container, +[dark-theme="true"] .page-loader-container { background: var(--mdn-bg); } + +.up-btn { border-radius: var(--mdn-r-pill); } + +/* !important on purpose: this block has to beat every transition in Default and Modern */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + transition-duration: .01ms !important; + animation-duration: .01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !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 deleted file mode 100644 index 0d5a580753..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/blog-news/blog-news.css +++ /dev/null @@ -1,140 +0,0 @@ -.card.post-news, -.post-news .card-header, -.post-news .card-footer { - border: 0; - border-radius: 0; - background-color: transparent; -} -.card.post-news { - height: 100%; - padding: 0; - border: 0; -} -.card.post-news .card-header { - padding: 0; -} -.card.post-news .card-body, -.card.post-news .card-footer { - color: #555; - padding: 10px 0; -} -.card.post-news .card-footer { - padding: 10px 0 0 0; -} -.card.post-news .img-container { - position: relative; - display: flex; - align-items: center; -} -.card.post-news img { - max-width: 100%; -} -.card.post-news .category { - display: table; - margin: 10px 0 0 0; - font-size: 14px; - font-weight: 500; - line-height: 1; - color: #555; - text-align: center; -} -.card.post-news .title { - margin: 5px 0; -} -.card.post-news .title a { - font-size: 16px; - font-weight: 700; -} -.card.post-news .date { - display: inline-flex; - align-items: center; - justify-content: center; -} -.card.post-news .date { - color: #25b232; - font-size: 14px; - font-weight: 500; -} -.card.post-news .date .part:not(:last-child) { - margin: 0 3px 0 0; -} -.card.post-news .date .month { - text-transform: capitalize; -} -.card.post-news .date .month::after { - content: ","; - position: relative; -} -.card.post-news .post-date { - position: absolute; - top: 10px; - left: 10px; - display: flex; - flex-direction: column; - justify-content: c; - align-items: center; - padding: 10px; - border-left: 1px solid #d6d6d6; - border-bottom: 1px solid #d6d6d6; - background-color: #fff; - line-height: 1; -} -.card.post-news .post-date .day { - color: #000; - font-size: 24px; - font-weight: 700; -} -.card.post-news .post-date .month { - margin: 0 0 2px 5px; - color: #4faacd; - font-size: 16px; - font-weight: 400; -} -.card.post-news .short { - color: #777; - font-size: 16px; - font-weight: 400; - line-height: 22px; -} -.card.post-news .short { - color: #555; - font-size: 14px; - font-weight: 400; - line-height: 22px; -} -.post-col { - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} - -@media (min-width: 1200px) { - .generalLeftSide.show + .generalSideRight .post-col { - -ms-flex: 0 0 50%; - flex: 0 0 50%; - max-width: 50%; - } -} -@media (max-width: 575px) { - .post-news, .post-news .card-header, .post-news .card-footer { - text-align: center; - } -} - -.post-news-page.card { - display: block; - background-color: transparent; - border: 0; -} -.post-news-page .card-header { - position: relative; - background-color: transparent; - border: 0; - float: left; - padding: 0; - margin: 0 15px 15px 0; - min-width: 320px; - min-height: 100px; -} -.post-news-page .card-body { - padding: 0; -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/cart/cart.css b/src/Plugins/Theme.Modern/Content/css/cart/cart.css deleted file mode 100644 index 24747946dc..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/cart/cart.css +++ /dev/null @@ -1,323 +0,0 @@ -/* commons */ - -.cart-view .sidebar-cart.cart-only { - pointer-events: none; -} - -/* shopping cart */ - -.shopping-cart { - margin: 0 0 15px 0; - color: #1c1b1b; -} -.shopping-cart.table thead th { - color: #1c1b1b; - font-size: 16px; - font-weight: 700; - text-transform: uppercase; - border: 1px solid rgba(0,0,0,.1); -} -.shopping-cart.table td { - text-align: center; - vertical-align: middle; - border: 1px solid rgba(0,0,0,.1); -} -.shopping-cart.table td img { - background-color: #eaf0fa; -} -.shopping-cart.table .product-name { - font-size: 14px; - font-weight: 400; -} -.shopping-cart.table .attributes { - color: #888; -} -.shopping-cart.table .product-unit-price, -.shopping-cart .prod-total { - font-size: 18px; - font-weight: 700; - color: #1c1b1b; -} -.shopping-cart .prod-total { - color: #25b232; -} -.shopping-cart .qty-input { - max-width: 75px; - min-width: 60px; - margin: 0 auto; -} -.shopping-cart .sku-number { - width: 100%; - display: flex; - justify-content: center; -} -.shopping-cart-page .totals-inner { - position: -webkit-sticky; - position: sticky; - top: 15px; - padding: 15px; - margin: 0 0 15px 0; - border: 2px solid #000; - border-radius: 0; -} -.totals-inner table, .totals-inner .table-hover tbody tr:hover { - color: #1c1b1b; -} -.checkout-buttons { - display: inline-flex; - flex-wrap: nowrap; - width: 100%; -} -.checkout-buttons .btn { - width: 100%; -} -.coupon-button { - display: inline-flex; - align-items: center; - justify-content: space-between; -} -.coupon-button::after { - display: inline-block; - margin-left: .255em; - vertical-align: .255em; - content: ""; - border-top: .4em solid; - border-right: .4em solid transparent; - border-bottom: 0; - border-left: .4em solid transparent; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.coupon-button.not-collapsed::after { - -webkit-transform: rotate(180deg); - transform: rotate(180deg); -} - -/* checkout */ - -.opc-header { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - margin: 0 0 15px 0; - pointer-events: none; -} -.opc-header .card-header { - border: 0; - padding: 0; - margin: 0 22px 8px 0; - background-color: transparent; -} -.opc-header .card-header .btn-info { - position: relative; - background-color: transparent; - border: 0; - padding: 0; - font-size: 14px; - font-weight: 700; - color: #1c1b1b; -} -[dark-theme="true"] .opc-header .card-header .btn-info { - color: #fff; -} -.opc-header .card-header .btn-info.not-collapsed { - color:#25b232; - font-size: 16px; - font-weight: 700; -} -.opc-header .card-header .btn-info::before, -.opc-header .card-header .btn-info::after { - content: ""; - position: absolute; - top: 9px; - right: -14px; - width: 6px; - height: 1px; - background: #aaa; - transform: rotate(45deg); -} -.opc-header .card-header .btn-info::after { - top: 13px; - transform: rotate(-45deg); -} -[dark-theme="true"] .opc-header .card-header .btn-info::before, -[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 { - background:#25b232; -} -.opc-header .card-header:last-child .btn-info::before, -.opc-header .card-header:last-child .btn-info::after { - display: none; -} -.opc .card, [dark-theme="true"] .opc .card { - background-color: #eaf0fa; - border: 0; - border-radius: 0; -} -.opc .card-body { - border: 1px solid rgba(0,0,0,.125); -} -[dark-theme="true"] .opc .card-body { - background-color: #2d3133; -} -.opc .card-header .btn:not(.allow) { - pointer-events: none; - color: #aaa; -} -.opc .card.active ~ .card:not(.active), -.opc .card.active ~ .card:not(.active) .btn.allow { - pointer-events: none !important; - opacity: 0.5 !important; -} -.opc-totals, .checkout-page .col-more .order-summary-content .cart-footer { - top: 0; - position: -webkit-sticky !important; - position: sticky !important; - padding: 15px 0; - margin: 0 0 15px 0; - border: 2px solid#25b232; - border-radius: 0.25rem; -} -[dark-theme="true"] .opc-totals, [dark-theme="true"] .checkout-page .col-more .order-summary-content .cart-footer { - background-color: #2d3133; -} -.checkout-page .totals .table-responsive { - margin: 0; -} -.totals .table td { - padding: 5px; - border-top: 0; -} -.cart-total-left { - font-size: 14px; - font-weight: 700; -} -.cart-total-right { - font-size: 16px; - font-weight: 700; -} -.checkout-page .shopping-cart .qty-input { - padding: 0; -} -.opc-totals .order-summary-content { - margin: 0 !important; -} -.opc-totals .shopping-cart th.unit-price, -.opc-totals .shopping-cart td.unit-price, -.opc-totals .card-group { - display: none; -} -.opc-totals .qty-opc { - display: flex; - align-items: center; - justify-content: center; - margin: 0 auto; - font-size: 16px; - font-weight: 500; - color: #fff; - background:#25b232; - width: 30px; - height: 30px; - border-radius: 100%; -} -.opc-totals .shopping-cart.table td { - vertical-align: top; -} -.checkout-page .totals, -.opc-totals .order-summary-content .selected-checkout-attributes, -.opc-totals .cart-out-container { - flex: 0 0 100%; - max-width: 100%; -} -@media (min-width: 576px) { - .card-group > .card + .card .card-body { - border-left: 0 - } -} -@media (min-width: 1200px) { - .checkout-page .col-more .order-summary-content .cart-footer { - margin: 0 0 0 auto; - max-width: 50%; - } - .opc-totals .shopping-cart-container { - max-height: 400px; - overflow-x: hidden; - overflow-y: auto; - } -} -@media (max-width: 1199px) { - .shopping-cart.table .attributes { - font-size: 10px; - } - .opc .order-summary .cart-footer { - visibility: visible; - opacity: 1; - height: auto; - } - .checkout-page .col-more .order-summary-content .cart-footer { - margin: 0; - max-width: 100%; - } -} -@media (max-width: 991px) { - .shopping-cart { - border-bottom: 0; - } - .shopping-cart thead { - display: none; - } - .shopping-cart tbody { - display: inline-flex; - flex-wrap: wrap; - width: 100%; - } - .shopping-cart.table tr { - display: flex; - flex-direction: column; - align-items: center; - width: calc(33.3333% - 5px); - margin: 0 5px 5px 0; - } - .shopping-cart.table td { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - text-align: center; - border: 1px solid #ddd; - border-left: 2px solid #000; - border-right: 2px solid #000; - border-top: 0; - } - .shopping-cart.table td:first-child { - border-top: 2px solid #000; - } - .shopping-cart.table td:last-child { - border-bottom: 2px solid #000; - } -} -@media (max-width: 767px) { - .shopping-cart.table tr { - width: calc(50% - 5px); - } -} -@media (max-width: 575px) { - .shopping-cart.table tr { - width: 100%; - } - .order-completed-page .jumbotron .display-3 { - font-size: 56px; - } -} - -@media (max-width: 991px) { - [dark-theme="true"] .shopping-cart.table td, [dark-theme="true"] .shopping-cart.table td:first-child, [dark-theme="true"] .shopping-cart.table td:last-child { - border-color: #fff; - } -} diff --git a/src/Plugins/Theme.Modern/Content/css/catalog.css b/src/Plugins/Theme.Modern/Content/css/catalog.css new file mode 100644 index 0000000000..1cd0c858c9 --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/catalog.css @@ -0,0 +1,249 @@ +/* Modern - catalog: product card, list card, listing page, filters. */ + +/* ---------- product card ---------- */ + +/* Default draws the card as a white ::before box with a pop-up footer on hover + (common.css "product box"); the Modern card is a surface well with the essentials only */ +.product-box.mdn-card, +[dark-theme="true"] .product-box.mdn-card { + position: relative; + height: 100%; + display: flex; + flex-direction: column; + background: transparent; + border: 0; + box-shadow: none; + transition: transform var(--mdn-dur) var(--mdn-ease); +} +.product-box.mdn-card::before, +[dark-theme="true"] .product-box.mdn-card::before { display: none; } +.product-box.mdn-card:not(.mobile):hover { transform: translateY(-2px); } +/* Default paints the hovered card #2d3133 in dark mode, at (0,4,0) */ +[dark-theme="true"] .product-box.mdn-card:not(.mobile):hover { background-color: transparent; } + +.product-box.mdn-card .picture-container { + background: var(--mdn-surface); + border-radius: var(--mdn-r-card); + transition: box-shadow var(--mdn-dur) var(--mdn-ease); +} +.product-box.mdn-card:not(.mobile):hover .picture-container { box-shadow: var(--mdn-shadow-lift); } + +/* Default keeps the square with .img-container::before { padding-top: 100% } and absolutely + positioned images. They fill the well: lifestyle photos bleed to the rounded edge, and a + packshot on white melts into the surface through multiply. */ +.product-box.mdn-card .picture-container .img-container img, +[dark-theme="true"] .product-box.mdn-card .picture-container .img-container img { + inset: 0; + width: 100%; + height: 100%; + max-width: none; + object-fit: cover; + background: transparent; + mix-blend-mode: multiply; + transition: transform .4s var(--mdn-ease); +} +.product-box.mdn-card:not(.mobile):hover .main-product-img { transform: scale(1.03); } +[data-bs-theme=dark] .product-box.mdn-card .picture-container .img-container img { mix-blend-mode: normal; } + +.mdn-card__badges { + position: absolute; + top: 12px; + left: 12px; + z-index: 3; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; +} +.product-box.mdn-card .mdn-card__badges .product-label { position: static; margin: 0; } +.mdn-badge { + display: inline-block; + padding: 4px 9px; + border-radius: var(--mdn-r-pill); + font-family: var(--mdn-mono); + font-size: 12px; + font-weight: 600; + line-height: 1.2; +} +.mdn-badge--sale { background: var(--mdn-sale); color: var(--mdn-on-sale); } + +.mdn-card__actions { + position: absolute; + top: 12px; + right: 12px; + z-index: 3; + display: flex; + flex-direction: column; + gap: 6px; + opacity: 0; + transform: translateX(4px); + transition: opacity var(--mdn-dur) var(--mdn-ease), transform var(--mdn-dur) var(--mdn-ease); +} +.product-box.mdn-card:hover .mdn-card__actions, +.product-box.mdn-card:focus-within .mdn-card__actions, +.product-box.mdn-card.mobile .mdn-card__actions { opacity: 1; transform: none; } +@media (hover: none) { + .mdn-card__actions { opacity: 1; transform: none; } +} + +.mdn-card__icon.btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: var(--mdn-bg); + border: 0; + border-radius: var(--mdn-r-pill); + box-shadow: var(--mdn-shadow); + color: var(--mdn-ink); +} +.mdn-card__icon.btn:hover { background: var(--mdn-ink); color: var(--mdn-on-ink); } +.mdn-card__icon .b-icon.bi { font-size: 15px; } + +/* quick view as a pill along the bottom of the image */ +.product-box.mdn-card .quick-view-button, +[dark-theme="true"] .product-box.mdn-card .quick-view-button { + left: 50%; + bottom: 12px; + z-index: 3; + display: inline-flex; + align-items: center; + gap: 6px; + width: auto; + padding: 6px 14px; + background: var(--mdn-bg); + border: 0; + border-radius: var(--mdn-r-pill); + box-shadow: var(--mdn-shadow); + color: var(--mdn-ink); + font-size: 13px; + text-transform: none; + white-space: nowrap; + opacity: 0; + transform: translate(-50%, 6px); + transition: opacity var(--mdn-dur) var(--mdn-ease), transform var(--mdn-dur) var(--mdn-ease); +} +.product-box.mdn-card:hover .quick-view-button, +.product-box.mdn-card .quick-view-button:focus-visible { opacity: 1; transform: translate(-50%, 0); } +.product-box.mdn-card.mobile .quick-view-button { display: none; } + +.product-box.mdn-card .card-body { display: flex; flex-direction: column; gap: 6px; flex: 1; padding: 14px 4px 0; } +.product-box.mdn-card .card-title { font-size: 15px; line-height: 1.35; } +.product-box.mdn-card .card-title a, +[dark-theme="true"] .product-box.mdn-card .card-title a { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + color: var(--mdn-ink); + font-size: 15px; + font-weight: 500; + letter-spacing: -.01em; +} +.product-box.mdn-card .sku { color: var(--mdn-muted-text); font-size: 12px; } +.product-box.mdn-card .rating { gap: 6px; font-size: 12px; } +.product-box.mdn-card .rating .b-rating + a, +[dark-theme="true"] .product-box.mdn-card .rating .b-rating + a { color: var(--mdn-muted-text); font-size: 12px; font-weight: 400; } +.product-box.mdn-card .prices-rating { display: flex; flex-direction: column; gap: 8px; margin-top: auto; } +.product-box.mdn-card .countdown-box { margin: 0; padding: 6px 10px; border-radius: var(--mdn-r-input); font-size: 12px; } + +.mdn-card__buy { display: flex; align-items: center; gap: 8px; } +.product-box.mdn-card .mdn-card__buy .prices { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 8px; margin: 0; flex: 1; min-width: 0; } +.product-box.mdn-card .mdn-card__buy .actual-price, +[dark-theme="true"] .product-box.mdn-card .mdn-card__buy .actual-price { color: var(--mdn-ink); font-size: 16px; font-weight: 600; } +.product-box.mdn-card .mdn-card__buy .old-price { margin: 0; color: var(--mdn-muted-text); font-size: 13px; } +.mdn-card__buy .catalog-quantity { width: 64px; min-height: 40px; text-align: center; } + +.mdn-card__add.btn { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border-radius: var(--mdn-r-pill); +} +.mdn-card__add .b-icon.bi { font-size: 18px; } + +/* specifications, attributes and the short description stay in the markup for crawlers; + the grid card shows only the essentials (Default pops them up in a hover footer) */ +.product-box.mdn-card .card-footer { display: none; } + +/* ---------- grids ---------- */ + +.product-grid .row { row-gap: 32px; --bs-gutter-x: 20px; } +.product-grid .product-container { margin-bottom: 0 !important; } /* beats Default's .mb-2 utility */ + +/* ---------- list view (Default CatalogProductListView, restyled) ---------- */ + +.product-list .product-box.list, +[dark-theme="true"] .product-list .product-box.list { + padding: 16px; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); +} +.product-list .product-box.list::before { display: none; } +.product-list .product-box.list .picture-container { border-radius: var(--mdn-r-input); background: var(--mdn-bg); } +.product-list .product-box.list .picture-container .img-container img { background: transparent; object-fit: contain; } +.product-list .product-box.list .actual-price, +[dark-theme="true"] .product-list .product-box.list .actual-price { color: var(--mdn-ink); font-size: 18px; font-weight: 600; } +.product-list .product-box.list .old-price { color: var(--mdn-muted-text); } +.product-list .product-box.list .btn { text-transform: none; font-weight: 500; } + +/* ---------- listing page ---------- */ + +.category-page > .generalTitle, +.brand-page > .generalTitle, +.collection-page > .generalTitle { margin: 8px 0 20px; } + +.page .categoryGrid .card, +[dark-theme="true"] .page .categoryGrid .card { + overflow: hidden; + background: var(--mdn-surface); + border-radius: var(--mdn-r-card); +} + +/* sort / view / page-size bar (Default Partials/Selectors) */ +.catalog-selectors, +[dark-theme="true"] .catalog-selectors { + align-items: center; + padding: 6px 8px; + background: var(--mdn-surface); + border-radius: var(--mdn-r-pill); +} +.catalog-selectors .form-select { min-height: 36px; border: 0; border-radius: var(--mdn-r-pill); } +/* the bar wraps on phones, where a pill radius turns it into a blob */ +@media (max-width: 767.98px) { + .catalog-selectors, + [dark-theme="true"] .catalog-selectors { border-radius: var(--mdn-r-card); padding: 10px 12px; } +} +.catalog-selectors .viewmode-icon a { color: var(--mdn-muted-text); } +.catalog-selectors .viewmode-icon .selected, +.catalog-selectors .viewmode-icon .text-info { color: var(--mdn-ink) !important; } /* beats .text-info */ + +/* filters column and its mobile drawer (Default _TwoColumns + Partials/Filtering) */ +.generalLeftSide .product-filter.card, +.generalLeftSide .product-filter .card, +.generalLeftSide .filtered-items.card, +[dark-theme="true"] .generalLeftSide .card { + background: transparent; + border-bottom: 1px solid var(--mdn-line); + border-radius: 0; +} +.generalLeftSide .card-header { padding: 14px 0; border: 0; font-size: 15px; font-weight: 600; } +.generalLeftSide .card-body { padding: 0 0 14px; } +.generalLeftSide .btn.btn-light, +[dark-theme="true"] .generalLeftSide .btn.btn-light { border-radius: var(--mdn-r-pill); } +#sidebar-mobile-button { border-radius: var(--mdn-r-pill); } + +/* the current category in the side navigation (Default: teal / cyan) */ +.generalLeftSide li.active.last > a, +.generalLeftSide .block-category-navigation li.active .sublist > li.last > a, +[dark-theme="true"] .generalLeftSide li.active.last > a, +[dark-theme="true"] .generalLeftSide .block-category-navigation li.active .sublist > li.last > a { color: var(--mdn-ink); font-weight: 600; } +[dark-theme="true"] .generalLeftSide a:hover, +[dark-theme="true"] .block-recently-viewed-products .card .card-body h5 a:hover { color: var(--mdn-accent); } diff --git a/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css b/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css deleted file mode 100644 index e91306c6aa..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/catalog/catalog.css +++ /dev/null @@ -1,865 +0,0 @@ -/* breadcrumb */ - -.breadcrumb { - margin: 0; - padding: 15px 0 0 0; -} - -/* category list */ - -.catalog-page .generalTitle { - margin: 0 0 15px 0; -} -.catalog-page .generalTitle span { - font-size: 20px; -} -.categoryGrid { - margin: 0 0 15px 0; - padding: 0 0 15px 0; -} -.category-item { - display: block; -} -.category-item img { - max-width: 100%; - width: auto; - height: auto; -} -.category-item .title { - font-size: 22px; - font-weight: 400; - color: #000; - font-family: 'Poppins', sans-serif; - margin: 5px 0 0 0; - white-space: pre-wrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* catalog left side */ - -#TwoColumns { - position: relative; - height: calc(100% - 57px); -} -.generalLeftSide:not(.show) { - opacity: 0; - visibility: hidden; - -webkit-transform: translateX(-100%); - transform: translateX(-100%); -} -.generalSideRight { - margin: 0 0 0 25%; - padding: 0 15px; - min-height: 500px; - z-index: 3; - transition: all .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); -} -.generalSideRight .product-container, -.swiper-slide { - -webkit-transition: all .4s ease-in-out; - transition: all .4s ease-in-out; -} -.generalLeftSide:not(.show) + .generalSideRight { - -ms-flex: 0 0 100%; - flex: 0 0 100%; - max-width: 100%; - margin: 0; -} -.generalLeftSide::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,0.3); - border-radius: 4px; - background-color: #232323; -} -.generalLeftSide::-webkit-scrollbar { - position: absolute; - width: 8px; - background-color: #232323; -} -.generalLeftSide::-webkit-scrollbar-thumb { - border-radius: 4px; - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,.3); - background-color: #111; -} -.generalLeftSide { - position: absolute; - top: 0; - bottom: 0; - left: 0; - overflow-x: hidden; - overflow-y: auto; - max-height: calc(100% - 54px); - background-color: #fff; - margin: 15px 0 0 0; - border: 1px solid rgba(0,0,0,.1); - padding: 0 15px 15px 15px; - opacity: 1; - visibility: visible; - direction: rtl; - z-index: 4; - --webkit-transition: all .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - transition: all .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); -} -.generalLeftSide * { - direction: ltr; -} -.generalLeftSide .modal-close, -.generalLeftSide .modal-close:focus, -.generalLeftSide .modal-close:hover, -.generalLeftSide .modal-close:active, -.generalLeftSide .modal-close:not(:disabled):not(.disabled):active { - top: -1px; - right: -1px; - display: flex; - justify-content: center; - align-items: center; - border: 1px solid rgba(0,0,0,.2); - border-radius: 0; - outline: none; -} -.generalLeftSide .modal-close::before { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - border: 1px solid transparent; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - z-index: 2; -} -.generalLeftSide .modal-close:focus::before, -.generalLeftSide .modal-close:hover::before, -.generalLeftSide .modal-close:active::before { - border-color: #1c1b1b; -} -.generalLeftSide .viewBox { - margin: 15px 0; -} -.generalLeftSide .block { - font-family: 'Poppins', sans-serif; -} -.generalLeftSide .product-spec-filter .btn-secondary, -.generalLeftSide .product-spec-filter li a { - font-size: 14px; -} -.generalLeftSide .available-items { - margin: 0 0 15px 0; - padding: 10px; - border: 0; - background: #eaf0fa; - border-radius: .25rem; -} -.generalLeftSide .available-items ul { - margin: 0 0 5px 0; -} -.generalLeftSide .available-items ul .title h6 { - font-size: 14px; - font-weight: 700; - text-transform: uppercase; -} -.generalLeftSide .available-items ul .btn { - color: #1c1b1b; - font-size: 14px; - font-weight: 700; - background-color: #fff; -} -.generalLeftSide .available-items ul .btn.color-container { - background-color: transparent; -} -.generalLeftSide .block h2.h5 { - margin: 15px 0; -} -.generalLeftSide .block h2.h5 strong { - font-size: 16px; - font-weight: 900; - text-transform: uppercase; -} -.generalLeftSide .block ul { - flex-direction: row; - justify-content: center; - flex-wrap: wrap; - padding-left: 0; - list-style-type: none; -} -.generalLeftSide .viewBox > .list > li.active { - position: relative; -} -.generalLeftSide .viewBox ul.sublist { - padding-left: 15px; -} -.generalLeftSide li a { - position: relative; - color: #1c1b1b; - text-decoration: none; - font-size: 14px; - font-weight: 400; - display: flex; - padding-bottom: 10px; -} -.generalLeftSide li a:hover { - color: #25b232; -} -.generalLeftSide .block-category-navigation li.active > .sublist > li a { - color: #555; - font-weight: 400; -} -.generalLeftSide li.active.last > a { - color: #25b232; - font-weight: 700; -} -.generalLeftSide li a .total { - position: relative; - right: -5px; - font-size: 12px; - font-weight: 400; - opacity: .8; -} -.generalLeftSide .block-category-navigation li.active .sublist > li.last > a { - color: #25b232; - font-weight: 500; -} -.generalLeftSide li { - position: relative; -} -.generalLeftSide li.active.last > a .total, .generalLeftSide li a:hover .total { - opacity: 1; -} -.generalLeftSide .block-category-navigation li.active > .sublist > li::before, .generalLeftSide .block-category-navigation li.active > .sublist > li::after { - content: ""; - position: absolute; - top: 0; - left: -15px; - width: 10px; - height: 105%; - border-left: 1px solid #1c1b1b; -} -.generalLeftSide .block-category-navigation li.active > .sublist > li::before { - height: 105%; -} -.generalLeftSide .block-category-navigation li.active > .sublist > li:last-child::before { - display: none; -} -.generalLeftSide .block-category-navigation li.active > .sublist > li::after { - height: 10px; - border-bottom: 1px solid #1c1b1b; -} -.generalLeftSide .btn.color-container { - margin: 0 5px 5px 0; -} -.generalLeftSide .btn.color-container:focus, .generalLeftSide .btn.color-container:active { - box-shadow: none; - border-radius: 100%; -} -.generalLeftSide .btn.color-container .color { - width: 32px; - height: 32px; -} -.b-sidebar-body .generalLeftSide { - padding: 0; -} -a.left-side-toggler { - display: inline-flex; - align-items: flex-end; - padding: 10px; - margin: 0 15px 0 0; - color: #fff; - border: 1px solid #333; - background-color: #1c1b1b; - cursor: pointer; - z-index: 3; -} -a.left-side-toggler:active, -a.left-side-toggler:focus, -a.left-side-toggler:hover { - color: #fff; -} -.left-side-toggler .inner { - display: flex; - flex-direction: column; - justify-content: space-between; - align-items: flex-end; - width: 36px; - height: 17px; -} -.left-side-toggler .inner span { - height: 3px; - background: #25b232; - -webkit-transition: width .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - transition: width .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); -} -.left-side-toggler .inner span:first-child { - width: 100%; -} -.left-side-toggler .inner span:nth-child(2) { - width: 75%; -} -.left-side-toggler .inner span:last-child { - width: 50%; -} -.left-side-toggler:hover .inner span { - width: 100%; -} -.left-side-toggler .text { - margin: 0 0 0 5px; - font-size: 18px; - font-weight: 700; - text-transform: uppercase; - line-height: 1; -} - -/* parallax banner */ - -.parallax-banner { - position: relative; - width: calc(100% + 30px); - margin: 0 -15px; - height: 300px; - display: inline-flex; - align-items: center; - justify-content: space-between; - padding: 5%; - background: #eaf0fa; - overflow: hidden; -} -.parallax-banner .content { - justify-content: center; - align-items: center; - max-width: 37%; -} -.parallax-banner::before { - content: ""; - position: absolute; - top: -50%; - left: -10%; - width: 50%; - height: 200%; - border: 15px solid #000; - background: #fff; - border-radius: 50%; - z-index: -1; -} -.parallax-banner .content, .parallax-banner .content .title { - display: flex; - flex-direction: column; -} -.parallax-banner .content .title { - margin: 0 0 15px 0; -} -.parallax-banner .content .title .up { - font-size: 50px; - font-weight: 900; - line-height: 1; -} -.parallax-banner .content .title .down { - color: #25b232; - font-size: 20px; - font-weight: 700; -} -.parallax-banner .content .promo { - display: inline-flex; - align-items: center; - 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"; -} -.parallax-banner .content .promo .number { - margin: 0 5px 0 0; - font-size: 80px; - font-weight: 700; - line-height: 1; - letter-spacing: -4px; -} -.parallax-banner .content .promo .off { - display: flex; - flex-direction: column; - line-height: 1; -} -.parallax-banner .content .promo .off div:first-child { - font-size: 31px; - font-weight: 700; - line-height: 1; -} -.parallax-banner .content .promo .off div:last-child { - font-size: 36px; - font-weight: 300; -} -.parallax-banner img { - position: absolute; - right: 0; - left: unset !important; - top: unset !important; - bottom: unset; - max-width: 400px; -} -.parallax-banner .img-container { - position: absolute; - top: 0; - right: 0; - display: flex; - align-items: center; - height: 300px; - -webkit-transition: all 1s ease-in-out; - transition: all 1s ease-in-out; -} -.parallax-banner:hover .img-container { - -webkit-transform: scale(1.1); - transform: scale(1.1); -} -.parallax-banner a.btn, -.parallax-banner .content .title, -.parallax-banner .content .promo { - -webkit-transition: all .4s ease-in-out; - transition: all .4s ease-in-out -} -.generalLeftSide.show + .generalSideRight .parallax-banner .content .title, -.generalLeftSide.show + .generalSideRight .parallax-banner .content .promo, -.generalLeftSide.show + .generalSideRight .parallax-banner a.btn { - -webkit-transform: scale(.9); - transform: scale(.9); -} -.generalLeftSide.show + .generalSideRight .parallax-banner::before { - width: 55%; -} - -/* filter button */ - -.filter-button { - background-color: transparent; - border: 0; - padding: 0; - margin-right: auto; - font-weight: 700; - order: 1; -} -.filter-button span { - margin: 0 5px 0 0; -} - -/* vendor tabs */ - -.vendor-page .nav-tabs { - position: relative; - justify-content: center; - border: 0; -} -.vendor-page .nav-tabs li:first-child::before { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 50%; - height: 1px; - background: rgba(0,0,0,.1); - z-index: 0; -} -.vendor-page .nav-tabs li:first-child::after { - content: ""; - position: absolute; - bottom: 0; - right: 0; - width: 50%; - height: 1px; - background: rgba(0,0,0,.1); - z-index: 0; -} -.vendor-page .nav-tabs .nav-link { - position: relative; - margin: 0 -1px 2px 0; - padding: 16px 26px; - font-size: 14px; - text-transform: uppercase; - font-weight: 700; - font-family: Poppins, sans-serif; - color: #000; - background-color: transparent; - border: 1px solid rgba(0,0,0,.1); - border-bottom: 0; - outline: none; - border-radius: 0; - -webkit-transition: all .2s ease; - transition: all .2s ease; -} -.vendor-page .nav-tabs .nav-link.active { - color: #fff; - background-color: #25b232; -} -.vendor-page .nav-tabs .nav-link:hover:not(.active) { - color: #fff; - background-color: #000; -} -.vendor-page .nav-tabs .nav-link::before { - content: ""; - position: absolute; - bottom: 0px; - left: 50%; - right: 50%; - width: 0; - height: 100%; - background: #333; - -webkit-transition: all 0.2s ease-out; - transition: all 0.2s ease-out; - z-index: -1; -} -.vendor-page .nav-tabs .nav-link:hover::before { - left: 0; - right: 0; - width: 100%; -} -.vendor-page .nav-tabs .nav-link.active::before { - background: #25b232; - left: 0; - right: 0; - width: 100%; -} - -/* catalog right side */ - -.sort-container { - position: relative; - display: inline-flex; - align-items: flex-end; - margin: 0 0 5px 0; -} -.sort-container.column { - flex-direction: column; - align-items: flex-start; -} -.sort-container:not(:last-child) { - margin: 0 3px 0 0; -} -.sort-container .col-form-label { - position: absolute; - top: -10px; - left: 4px; - padding: 0 4px; - background-color: #eaf0fa; - font-size: 12px; - font-weight: 400; - white-space: nowrap; -} -.item-statistics { - display: flex; - align-items: flex-end; - font-size: 16px; - font-weight: 500; -} -.item-statistics .items-per-page span, -.item-statistics .items-separator { - margin: 0 5px 0 0; -} -.search-page .change-view { - display: none !important; -} -.b-avatar .b-avatar-img img { - max-width: 100%; - width: auto; - height: auto; - border-radius: 0; -} - -/* category container */ - -.category-item { - position: relative; -} -.category-item .content { - bottom: 0; - left: 0; - right: 0; - display: flex; - flex-direction: column; - align-items: flex-start; - padding: 10px 0; -} -.category-item .content .title { - font-size: 16px; - font-weight: 700; - letter-spacing: 1px; -} -.category-item .content span { - color: #aaa; - font-size: 16px; - font-weight: 400; -} - -/* selectors */ - -.catalog-selectors { - background: #eaf0fa; - padding: 10px; -} -.grid-icon { - display: flex; - align-items: center; - justify-content: space-between; - position: relative; -} -.grid-icon.cols-2 { - width: 16px; -} -.grid-icon.cols-3 { - width: 25px; -} -.grid-icon.cols-4 { - width: 34px; -} -.grid-icon.cols-5 { - width: 43px; -} -.grid-icon.cols-6 { - width: 52px; -} -.grid-icon span::before { - content: ''; - position: relative; - top: 0; - left: 0; - width: 7px; - display: flex; - height: 7px; - background: #aaa; -} -.grid-icon span::after { - content: ''; - position: relative; - top: 0; - left: 0; - width: 7px; - display: flex; - height: 7px; - margin: 2px 0 0 0; - background: #aaa; -} -.list-icon { - display: flex; - flex-direction: column; - justify-content: space-between; - width: 25px; - height: 17px; -} -.list-icon span { - display: flex; - width: 100%; - height: 3px; - background: #aaa; -} -.viewmode-icon.selected .grid-icon span::before, -.viewmode-icon.selected .grid-icon span::after, -.viewmode-icon.selected .list-icon span { - background: #25b232; -} -.viewmode-icon:hover .grid-icon span::before, -.viewmode-icon:hover .grid-icon span::after, -.viewmode-icon:hover .list-icon span { - background: #25b232; -} - -/* pagintaion */ - -.vue-pager { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - margin: 0 0 10px 0; -} -.vue-pager div { - color: #fff; - font-size: 16px; - font-weight: 700; - margin: 0 5px 5px 0; - padding: 8px 16px; - border: 1px solid #1c1b1b; - background-color: #1c1b1b; - cursor: pointer; -} -.vue-pager div.current { - color: #fff; - background-color: #25b232; - border-color: #25b232; - cursor: not-allowed; - pointer-events: none; -} - -/* block-recently-viewed-products */ - -.block-recently-viewed-products .card a.picture { - display: block; - position: relative; - height: 100%; - overflow: hidden; - background-color: #eaf0fa; -} -.block-recently-viewed-products .card a.picture::before { - content: ""; - display: block; - padding-top: 100%; -} -.block-recently-viewed-products .card a.picture img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - max-width: 100%; - margin: auto; - background-color: #eaf0fa; -} - -/* new cols */ - -.new-col-2 { - -ms-flex: 0 0 50%; - flex: 0 0 50%; - max-width: 50%; -} -@media (min-width: 768px) { - .new-col-3 { - -ms-flex: 0 0 33.333333%; - flex: 0 0 33.333333%; - max-width: 33.333333%; - } - .viewmode-icon[data-cols='3'] { - display: flex; - } -} -@media (min-width: 768px) and (max-width: 1199px) { - .generalLeftSide + .generalSideRight .viewmode-icon[data-cols='6'].selected ~ .viewmode-icon[data-cols='3'] .grid-icon span::before, .generalLeftSide + .generalSideRight .viewmode-icon[data-cols='6'].selected ~ .viewmode-icon[data-cols='3'] .grid-icon span::after { - background: #25b232; - } -} -@media (min-width: 1200px) { - .new-col-4 { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } - .viewmode-icon[data-cols='4'] { - display: flex; - } - .generalLeftSide:not(.show) + .generalSideRight .product-box.list .card-body { - flex: 4; - } -} -@media (min-width: 1260px) { - .new-col-6, .new-col-5 { - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; - } - .generalLeftSide.show + .generalSideRight .new-col-6, .generalLeftSide.show + .generalSideRight .new-col-5 { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } -} -@media (min-width: 1400px) { - .new-col-5 { - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; - } - .generalLeftSide.show + .generalSideRight .new-col-5 { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } -} -@media (min-width: 1680px) { - .new-col-6 { - -ms-flex: 0 0 16.666667%; - flex: 0 0 16.666667%; - max-width: 16.666667%; - } - .generalLeftSide.show + .generalSideRight .new-col-6 { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } - .generalLeftSide.show + .generalSideRight .col-for-5-xl { - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; - } -} -@media (min-width: 1920px) { - .generalLeftSide.show + .generalSideRight .new-col-5, .generalLeftSide.show + .generalSideRight .new-col-6 { - -ms-flex: 0 0 20%; - flex: 0 0 20%; - max-width: 20%; - } -} -@media (max-width: 1600px) { - .parallax-banner .content .title .up { - font-size: 42px; - } - .parallax-banner .content .title .down { - font-size: 18px; - } - .parallax-banner .content { - max-width: 300px; - } -} -@media (max-width: 1199px) { - .generalSideRight { - max-width: 100%; - flex: 0 0 100%; - margin: 0; - } - .parallax-banner { - display: none; - } -} -@media (max-width: 991px) { - .catalog-page .generalTitle span { - font-size: 16px; - } - .sort-container .custom-select { - max-width: 135px; - } - .categoryGrid > .item-row { - flex-wrap: nowrap; - overflow-x: auto; - overflow-y: hidden; - } - .generalLeftSide { - position: fixed; - top: 101px; - left: 0; - max-height: calc(100% - 185px); - } - .generalSideRight { - position: static; - margin: 0; - overflow: visible; - } -} -@media (max-width: 575px) { - .sort-container, .sort-container:not(:last-child) { - width: 100%; - margin: 10px 0; - } - .sort-container .custom-select { - max-width: 100%; - } - .sort-container.change-view { - margin: 0; - justify-content: flex-end; - align-items: center; - } - .category-item .content .title { - font-size: 14px; - } - .category-item .content span { - font-size: 12px; - } -} -@media (max-width: 420px) { - .page:not(.account-page) .left-side-toggler .text { - display: none; - } -} -@media (max-width: 370px) { - .sort-container:not(:last-child), a.left-side-toggler { - margin: 5px 0; - } -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/common/common.css b/src/Plugins/Theme.Modern/Content/css/common/common.css deleted file mode 100644 index fac1603177..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/common/common.css +++ /dev/null @@ -1,3888 +0,0 @@ -.store-selector-container { - position: sticky; - position: -webkit-sticky; - width: 100%; - height: 88px; - padding: 15px; - background: #000; - border-bottom: 1px solid #333; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - z-index: 1037; -} -.store-selector-container:hover { - z-index: 1041; -} -.store-selector-container.hide { - transform: translateY(-100%); - margin: -88px 0 0 0; -} -.store-selector-container .inside-container { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - max-width: 1270px; - margin: 0 auto; -} -.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; - font-size: 16px; - font-weight: 700; - padding: 1rem 2rem; - background-color: #fff; - border-color: #fff; - border-radius: .25rem; -} -.store-selector-container .inside-container .dropdown-toggle span { - margin: 0 5px 0 0; -} -.store-selector-container .inside-container .close-add { - cursor: pointer; -} -.store-selector-container .inside-container .close-add .text { - color: #fff; - font-size: 16px; - font-weight: 700; - margin: 0 10px 0 0; - text-transform: uppercase; -} -.store-selector-container .inside-container .close-add .icon { - position: relative; - width: 38px; - height: 38px; -} -.store-selector-container .inside-container .close-add .icon::before, -.store-selector-container .inside-container .close-add .icon::after { - content: ""; - position: absolute; - top: 9px; - left: 0; - width: 28px; - height: 2px; - background: #fff; -} -.store-selector-container .inside-container .close-add .icon::before { - transform: rotate(45deg); -} -.store-selector-container .inside-container .close-add .icon::after { - transform: rotate(-45deg); -} - -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url(../fonts/Poppins-Light.ttf) format('truetype'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url(../fonts/Poppins-Light.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 300; - font-display: swap; - src: url(../fonts/Poppins-Light.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../fonts/Poppins-Regular.ttf) format('truetype'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../fonts/Poppins-Regular.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(../fonts/Poppins-Regular.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../fonts/Poppins-Medium.ttf) format('truetype'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../fonts/Poppins-Medium.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 500; - font-display: swap; - src: url(../fonts/Poppins-Medium.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../fonts/Poppins-SemiBold.ttf) format('truetype'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../fonts/Poppins-SemiBold.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 600; - font-display: swap; - src: url(../fonts/Poppins-SemiBold.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(https://fonts.gstatic.com/s/poppins/v15/pxiByp8kv8JHgFVrLCz7Z11lFc-K.woff2) format('woff2'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(../fonts/Poppins-Bold.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 700; - font-display: swap; - src: url(../fonts/Poppins-Bold.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} -/* devanagari */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: url(../fonts/Poppins-ExtraBold.ttf) format('truetype'); - unicode-range: U+0900-097F, U+1CD0-1CF6, U+1CF8-1CF9, U+200C-200D, U+20A8, U+20B9, U+25CC, U+A830-A839, U+A8E0-A8FB; -} -/* latin-ext */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: url(../fonts/Poppins-ExtraBold.ttf) format('truetype'); - unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; -} -/* latin */ -@font-face { - font-family: 'Poppins'; - font-style: normal; - font-weight: 900; - font-display: swap; - src: url(../fonts/Poppins-ExtraBold.ttf) format('truetype'); - unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; -} - -/* c-picker */ - -.picker-container .toggle-button { - position: fixed; - bottom: 135px; - right: 15px; - display: flex; - justify-content: center; - align-items: center; - width: 70px; - height: 70px; - font-size: 32px; - color: #fff; - background: #4faacd; - border: 5px solid #fff; - border-radius: 100%; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - cursor: pointer; - z-index: 1041; -} -.picker-container .toggle-button:hover { - -webkit-transform: scale(1.1); - transform: scale(1.1); -} -.picker-container .toggle-button::before { - content: ''; - position: absolute; - top: -5px; - right: -5px; - bottom: -5px; - left: -5px; - border: 1px solid #d7d7d7; - border-radius: 100%; -} -.c-picker { - position: fixed; - bottom: 15px; - right: 15px; - -webkit-transform: translateX(0); - transform: translateX(0); - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; - z-index: 1039; -} -.c-picker.hide { - -webkit-transform: translateX(calc(100% + 15px)); - transform: translateX(calc(100% + 15px)); - right: 0; -} -.c-picker .content { - display: flex; - flex-direction: column; - margin: 15px 0 0 0; - padding: 15px; - background-color: #fff; - 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; - font-size: 16px; - font-weight: 500; - margin: 0 0 15px 0; - padding: 5px; - border: 1px solid #d7d7d7; -} -.c-picker .content .buttons { - display: inline-flex; - align-items: center; - justify-content: space-between; -} -.c-picker .content .buttons .accept-button { - display: flex; - justify-content: center; - align-items: center; - text-align: center; - color: #fff; - background-color: #b57e10; - border-radius: 4px; - font-size: 18px; - font-weight: 700; - padding: 12px 18px; - cursor: pointer; -} -.c-picker .content .buttons .circle { - position: relative; - display: flex; - justify-content: center; - align-items: center; - width: calc(100% - 130px); - height: 51px; - border-radius: 4px; - overflow: hidden; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.c-picker .content .buttons .circle span { - opacity: 0; - visibility: hidden; - font-size: 32px; - color: #fff; - -webkit-transition: opacity .2s ease-in-out; - transition: opacity .2s ease-in-out; -} -.c-picker .content .buttons .circle span.show { - opacity: 1; - visibility: visible; -} - -/* 404 */ - -.page-not-found { - display: flex; - justify-content: center; - align-items: center; - flex-direction: column; - min-height: 550px; -} -.not-found-container { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - margin: 30px 0; -} -.not-found-container h1 { - margin: 0; - font-size: 120px; - font-weight: 900; - line-height: 0.7; -} -.not-found-container .bottom { - color: #25b232; - margin: 0 0 0 auto; - font-size: 23px; - font-weight: 700; - text-transform: uppercase; -} -.not-found-container .content { - font-size: 28px; - font-weight: 300; - text-transform: uppercase; -} -.page-not-found ul { - margin: 0; - padding: 0; -} - -/* backdrop menu */ - -.backdrop-menu { - display: none; - background: rgba(0,0,0,.5); - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 1000; -} -.backdrop-menu.show { - display: block; - animation: fadeBD .4s ease-in-out -} -.backdrop-menu.hide { - display: none; - animation: fadeOutBD .4s ease-in-out -} -@keyframes fadeBD { - from { - display: none; - opacity: 0; - visibility: hidden; - } - to { - display: block; - opacity: 1; - visibility: visible; - } -} -@keyframes fadeOutBD { - from { - display: block; - opacity: 1; - visibility: visible; - } - to { - display: none; - opacity: 0; - visibility: hidden; - } -} - -/* commons */ - -a { - color: #000; -} -a:hover { - color: #25b232; - text-decoration: none; -} -.date-text { - color: #4C5357; -} -.dropdown-menu { - min-width: unset; -} -.required:not(.form-control) { - color: #dc3545; - position: absolute; -} -.col-form-label { - /*position: relative; - top: 10px; - left: 6px;*/ - padding: 0 4px; - font-size: 12px; - font-weight: 400; - background-color: #fff; - z-index: 1; -} -.field-validation-error { - color: #dc3545; - font-weight: 500; -} -.message-error ul { - padding: 0; - margin-bottom: 5px; - color: #dc3545; - font-weight: 500; -} -.custom-control { - cursor: pointer; -} -.disabled { - pointer-events: none; -} -.navbar-brand { - margin: 0; - padding: 0 15px 0 0; - text-align: center; -} -.navbar-brand img { - width: auto; - height: auto; - max-width: 150px; - max-height: 50px; - backface-visibility: hidden; -} -.cookie-bar-notification { - position: fixed; - bottom: 0; - left: 0; - right: 0; - z-index: 1050; -} -body { - font-family: 'Poppins', sans-serif; -} -body:not(.modal-open) { - overflow-x: hidden; -} -.form-group { - min-height: 38px; -} -.badge { - 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"; -} -.modal { - -webkit-transition: none; - transition: none; -} -.modal + .modal-backdrop { - opacity: 0; - -webkit-transition: opacity .15s linear; - transition: opacity .15s linear; -} -.modal.fade + .modal-backdrop { - opacity: .5; -} -[v-cloak] .page-loader-container::before { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: #fff; - overflow: hidden; - z-index: 1041; -} -[v-cloak] .page-loader, -[v-cloak] .page-loader:before, -[v-cloak] .page-loader:after { - border-radius: 50%; -} -[v-cloak] .page-loader { - color: #25b232; - margin: 22.5px auto; - position: fixed; - top: calc(50% - 22.5px); - left: calc(50% - 22.5px); - width: 53px; - height: 53px; - box-shadow: inset 0 0 0 5px; - -webkit-transform: translateZ(0); - -ms-transform: translateZ(0); - transform: translateZ(0); - z-index: 1041; -} -[v-cloak] .page-loader:before, -[v-cloak] .page-loader:after { - position: absolute; - content: ''; -} -[v-cloak] .page-loader:before { - width: 27.5px; - height: 55px; - background: #25b232; - background-color: #fff; - border-radius: 55px 0 0 55px; - top: -1px; - left: -1px; - -webkit-transform-origin: 27.5px 27.5px; - transform-origin: 27.5px 27.5px; - -webkit-animation: load2 2s infinite ease 1.5s; - animation: load2 2s infinite ease 1.5s; -} -[v-cloak] .page-loader:after { - width: 27.5px; - height: 55px; - background: #25b232; - background-color: #fff; - border-radius: 0 27.5px 27.5px 0; - top: -1px; - left: 27.5px; - -webkit-transform-origin: -1px 27.5px; - transform-origin: -1px 27.5px; - -webkit-animation: load2 2s infinite ease; - animation: load2 2s infinite ease; -} -@-webkit-keyframes load2 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} -@keyframes load2 { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - - 100% { - -webkit-transform: rotate(360deg); - transform: rotate(360deg); - } -} -.up-btn { - position: fixed; - right: 15px; - bottom: 15px; - display: flex; - align-items: center; - justify-content: center; - width: 46px; - height: 46px; - font-size: 24px; - background: #25b232; - border-radius: 4px; - z-index: 1039; - cursor: pointer; - transition: all .2s ease; - -webkit-transition: all .2s ease; -} -.up-btn.up-btn__hide { - width: 0; - height: 0; - transform: translateX(calc(100% + 15px)); -} -.up-btn:not(.up-btn__hide) div { - position: relative; - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - overflow: hidden; -} -.up-btn:not(.up-btn__hide) div::before { - content: ""; - position: absolute; - left: 20px; - width: 15px; - height: 2px; - background: #fff; - transform: rotate(45deg); -} -.up-btn:not(.up-btn__hide) div::after { - content: ""; - position: absolute; - right: 20px; - width: 15px; - height: 2px; - background: #fff; - transform: rotate(-45deg); -} -.item-row { - display: -ms-flexbox; - display: flex; - -ms-flex-wrap: wrap; - flex-wrap: wrap; - margin-right: -7.5px; - margin-left: -7.5px; -} -.item-row>.col, .item-row>[class*=col-] { - padding-right: 0; - padding-left: 0; -} - -/* checkbox & radio */ - -.custom-control-input:checked ~ .custom-control-label::before { - border-color: #25b232; - background-color: #25b232; -} - -/* modal */ - -.modal-content { - border: 0; - border-radius: 0; -} -button.close { - color: #fff; - text-shadow: none; - opacity: 1; -} -button.close:hover { - color: #25b232; -} - -/* quickview */ - -#ModalQuickView .modal-body { - padding: 30px; -} -.qv-title { - margin: 0; - font-size: 28px; - font-weight: 700; -} -.quick-view .accordion { - margin: 15px 0 0 0; -} -.quick-view .review-count { - margin: 0 0 0 5px; - color: #777; - font-size: 12px; - font-weight: 400; -} -.quick-view-overview .prices { - margin: 0 0 15px 0; -} -.quick-view-overview .actual-price { - color: #000; - font-size: 28px; - font-weight: 900; - line-height: 1; -} -.quick-view-overview .bid { - color: #000; - font-size: 22px; -} -.quick-view-overview .old-price { - color: #999; - font-size: 18px; - font-weight: 400; - text-decoration: line-through; - line-height: 1; -} -.quick-view-overview .qty-input, -.quick-view-overview .qty-dropdown { - max-width: 65px; - min-width: 65px; - height: 60px; - border: 1px solid #bebebe; -} -.quick-view-overview .bid-input { - height: 45px; -} -.quick-view .add-to-cart-button { - font-size: 20px; - font-weight: 500; - height: 45px; - justify-content: center; - align-items: center; - background-color: #25b232; - border: 1px solid #25b232; -} -.quick-view .add-cart-text-view -{ - font-size: 14px; -} -.quick-view .spec-container tr { - /* width: 100%; - border-bottom: 1px solid #dee2e6;*/ -} -.quick-view .spec-container tr td { - /*display: inline-flex;*/ - /*border: 0; - color: #000;*/ -} -.quick-view .spec-container .spec-name { - font-size: 16px; - font-weight: 500; -} -.quick-view .attributes { - margin: 0 0 15px 0; - padding: 15px; - border: 2px solid #333; - border-radius: .25rem; -} -.quick-view .attributes table { - margin: 0; - border: 0; -} -.quick-view .attributes table tr:first-child td { - border-top: 0; -} -.quick-view .attributes .attribute-name { - font-size: 16px; - font-weight: 500; -} -.quick-view .attributes .text-prompt { - color: #555; - font-size: 14px; - font-weight: 400; - width: 100%; - display: flex; -} -.modal-open .image-square-tooltip { - display: flex; - align-items: flex-start; - justify-content: center; - flex-direction: column; -} -.modal-open .image-square-tooltip img { - max-width: 165px; -} -.quick-view-overview .accordion .btn.collapse-button { - display: inline-flex; - justify-content: space-between; - align-items: center; - width: 100%; - margin: 0 0 8px 0; - padding: 8px 12px; - font-size: 18px; - font-weight: 700; - border: 0; - background-color: #fff; - border: 2px solid #25b232; - color: #000; -} -.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 { - -webkit-transform: rotate( 180deg ); - transform: rotate( 180deg ); -} -.quick-view-overview .collapse-panel { - padding: 7px 0 15px 0; -} -.quick-view .product-gallery img { - max-width: 100%; - background-color: #eaf0fa; - border: 1px solid #ddd; -} -.quick-view .product-gallery { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - flex-wrap: wrap; - margin: 0 0 10px 0; -} -.quick-view .product-gallery .swiper-container { - width: 100%; -} -.quick-view .product-gallery .thumbs { - margin: 10px 0 0 0; -} -.quick-view .product-gallery .thumbs .swiper-slide { - border: 2px solid transparent; - cursor: pointer; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.quick-view .thumbs .swiper-slide-active, .quick-view .thumbs .swiper-slide:hover { - border: 2px solid #000; -} -.quick-view .linked-product-grid { - border-top: 1px solid #ddd; - margin: 7.5px 0; - padding: 7.5px 0; -} -.quick-view .qty-controls { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 60px; - background: #fff; -} -.quick-view .qty-controls div { - display: flex; - justify-content: center; - align-items: center; - position: relative; - width: 30px; - height: 30px; - border: 1px solid #bebebe; - border-right: 0; - cursor: pointer; -} -.quick-view .qty-controls div.plus::before, .quick-view .qty-controls div.plus::after, .quick-view .qty-controls div.minus::before { - content: ""; - position: absolute; - top: 13px; - left: 9px; - width: 12px; - height: 2px; - display: flex; - justify-content: center; - background: #000; -} -.quick-view .qty-controls div.plus:hover::before, .quick-view .qty-controls div.plus:hover::after, .quick-view .qty-controls div.minus:hover::before { - background: #111; -} -.quick-view .qty-controls div.plus::after { - transform: rotate( 90deg ); - -webkit-transform: rotate( 90deg ); -} -.quick-view .qty-controls div:last-child { - border-top: 0; -} - -/* ModalBanner */ - -#ModalBanner .modal-dialog { - max-width: 800px; -} -#ModalBanner .modal-dialog .modal-body { - padding: 0; -} -#ModalBanner .modal-content { - background-color: #000; - border: 1px solid #25b232; -} -#ModalBanner img { - max-width: 100%; -} -#ModalBanner .content { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - padding: 30px; -} -#ModalBanner .content .title .top { - font-size: 38px; - font-weight: 700; -} -#ModalBanner .content .title .bottom { - font-size: 18px; - font-weight: 300; -} -#ModalBanner .content input.form-control { - margin: 10px 0; - max-width: 40%; - background-color: rgba(255,255,255,.05); - border-color: #efefef; - z-index: 1; -} -#ModalBanner .input-placeholder { - display: block; - position: relative; - width: 100%; - height: 0; - -webkit-transition: all .1s ease-in-out; - transition: all .1s ease-in-out; -} -#ModalBanner .content input.form-control:focus + .input-placeholder { - opacity: 0; - visibility: hidden; -} -#ModalBanner .input-placeholder::before { - content: "Leave us your email..."; - position: absolute; - top: -39px; - bottom: 0; - left: 12px; - display: flex; - justify-content: flex-start; - color: #aaa; - font-size: 14px; - font-weight: 300; - z-index: 0; -} -#ModalBanner .content #send-interactive-form.btn-success { - background-color: transparent; - border: 1px solid #25b232; - font-weight: 700; - text-transform: uppercase; -} -#ModalBanner .flying-content { - position: absolute; - bottom: 30px; - left: 30px; -} -#ModalBanner .flying-content ul { - display: inline-flex; - margin: 0; - padding: 0; -} -#ModalBanner .flying-content ul li { - margin: 0 12px 0 0; -} -#ModalBanner .flying-content ul li img { - width: 22px; - height: 22px; - filter: invert(1); -} - -/* Parallax Banner */ - -.wrapper { - position: relative; - display: flex; - overflow: hidden; - text-align: left; - align-items: center; - flex-direction: column; - justify-content: center; - margin: 0 -15px 30px -15px; - min-height: 400px; - z-index: 1; -} -.wrapper img { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: calc(100% + 11rem); - object-fit: cover; - pointer-events: none; - z-index: 10; -} -.wrapper .content { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background-image: linear-gradient(to right, rgba(0,0,0,.9), rgba(0,0,0,.2)50%, rgba(0,0,0,.9) 100%); - padding: 30px; - text-align: center; - z-index: 10; -} -.wrapper .content .gold-title { - color: #25b232; - font-size: 22px; - font-weight: 700; -} -.wrapper .content .top-title { - font-size: 46px; - font-weight: 700; - margin: 0 0 15px 0; - line-height: 1; -} -.wrapper .content .bottom-title { - font-size: 22px; - font-weight: 300; - margin: 0 0 30px 0; - line-height: 1; -} -.wrapper .content .button .btn { - background-color: rgba(0,0,0,.2); - border: 2px solid #fff; -} -.wrapper .content .button .btn:hover { - background-color: #000; - border-color: #25b232; -} - -/* toast */ - -.b-toast-info .toast, .b-toast-info .toast .toast-header { - color: #fff; - background-color: rgba(28, 27, 27, .9); - border-color: #25b232; - border-radius: 4px; -} -.b-toast-info .toast { - color: #ccc; - background-color: rgba(28, 27, 27, .9); -} -.b-toast-info.b-toast-solid .toast { - background-color: rgba(28, 27, 27, .9); -} -.b-toast-info .toast .toast-header { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} -.b-toaster.b-toaster-top-right { - top: 62px; - right: 15px; -} -.b-toast-info .toast a { - color: #fff; -} -/* card */ -.card { - background-color: #fff; - border: 1px solid rgba(0,0,0,.1); -} -.card-header { - font-size: 18px; - font-weight: 700; - background-color: #eaf0fa; - border-bottom: 1px solid rgba(0,0,0,.1); -} - -/* forms */ - -.form-control { - color: #1c1b1b; - background-color: #fff; - border: 1px solid rgba(0,0,0,.2); - border-radius: 0; -} -.form-control:focus, -.form-control:hover { - border-color: #25b232; - box-shadow: none; -} -.form-control:disabled, .form-control[readonly], .form-control:hover:disabled, .form-control[readonly]:hover { - color: #aaa; - background-color: #fff; - border-color: #333; -} -.input-group-append .btn, .input-group-prepend .btn { - height: 100%; - padding: .375rem .75rem; -} -.custom-select { - font-size: 14px; - font-weight: 500; - color: #1c1b1b; - 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; -} - -/* page */ - -.page { - min-height: 450px; - margin: 15px 0 0 0; -} - -/* no data */ - -.no-data.alert.alert-info { - display: table; - margin: 0 auto; -} - -/* backgrounds */ - -.bg-black, .flag-label .black { - background-color: #1c1b1b; -} -.bg-dark { - background-color: #1c1b1b !important; -} -.bg-brown, .flag-label .brown { - background: #795548; -} -.bg-pink, .flag-label .pink { - background: #E91E63; -} -.bg-red, .flag-label .red, .flag-label .danger { - background: #F44336; -} -.bg-blue, .flag-label .blue { - background: var(--light-blue); -} -.bg-cyan, .flag-label .cyan { - background: Cyan; -} -.bg-teal, .flag-label .teal { - background: #009688; -} -.bg-lime, .flag-label .lime { - background: #CDDC39; -} -.bg-orange, .flag-label .orange { - background: #FF5722; -} -.bg-green, .flag-label .green { - background: #4CAF50; -} -.bg-purple, .flag-label .purple { - background: #673AB7; -} -.bg-gray, .flag-label .gray { - background: #777; -} - -/* texts */ - -.text-gold { - color: #25b232; -} - -/* shadows */ - -.bottom-shadow { - box-shadow: 0px 15px 10px -10px #1c1b1b; -} - -/* dropdowns */ - -.btn.dropdown-toggle { - padding: 2px 14px; - color: #efefef; -} -.btn.dropdown-toggle + .dropdown-menu { - background-color: #242424; - border: 1px solid #333; - border-radius: 0; -} -.btn.dropdown-toggle + .dropdown-menu .dropdown-item { - padding: 3px 10px; - color: #aaa; - background-color: transparent; -} -.btn.dropdown-toggle + .dropdown-menu li.active .dropdown-item { - color: #efefef; -} - -/* auctions */ - -.countdown-box.alert-info { - color: #25b232; - font-weight: 500; - background-color: transparent; - border: 2px solid #333; -} - -/* sitemap */ - -.sitemap-page .list-group-item { - color: #fff; - background-color: #1c1b1b; - border: 1px solid #333; -} - -/* preloader */ - -.preloader { - position: absolute; - width: 4rem; - height: 4rem; - border: 0.2rem solid #25b232; - border-radius: 100%; -} -.preloader:before, -.preloader:after { - content: ''; - position: absolute; - top: 50%; - left: 50%; - margin: -0.1rem 0 0 -0.1rem; - border-bottom: 0.2rem solid #25b232; - border-radius: 0.3rem; - -webkit-transform-origin: 0.1rem center; -} -.preloader:before { - width: 30%; - -webkit-animation: rotate 10s linear infinite; -} -.preloader:after { - width: 40%; - background-color: #25b232; - -webkit-animation: rotate 1s linear infinite; -} -@-webkit-keyframes rotate { - 100% { - -webkit-transform: rotate(360deg); - } -} -.preloader { - top: 0; - bottom: 0; - left: 0; - right: 0; - margin: auto; -} - -/* swiper customs */ - -.swiper-container { - position: relative; - overflow: hidden; -} -.swiper-button-next, .swiper-button-prev { - display: flex; - justify-content: center; - align-items: center; - width: 50px; - height: 80px; - opacity: 0; - -webkit-transition: all.4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - transition: all .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); -} -.swiper-button-prev, .swiper-container-rtl .swiper-button-next { - left: -1px; - -webkit-transform: translateX(-100%); - transform: translateX(-100%); -} -.swiper-button-next, .swiper-container-rtl .swiper-button-prev { - right: -1px; - -webkit-transform: translateX(100%); - transform: translateX(100%); -} -.swiper-container:hover .swiper-button-prev, .swiper-container:hover .swiper-button-next { - -webkit-transform: translateX(0); - transform: translateX(0); - opacity: 1; -} -.swiper-button-next:after, .swiper-button-prev:after { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - color: #fff; - font-size: 24px; - background: #1c1b1b; - border: 1px solid #333; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.swiper-button-next:hover:after, .swiper-button-prev:hover:after { - background-color: #25b232; -} -.swiper-container-horizontal > .swiper-pagination-bullets, .swiper-pagination-custom, .swiper-pagination-fraction { - display: inline-flex; - justify-content: center; - align-items: center; - height: 22px; - bottom: 5%; -} -.swiper-pagination-bullet { - background: #000; - width: 25px; - height: 10px; - border-radius: 0; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.swiper-pagination-bullet:not(.swiper-pagination-bullet-active):hover { - opacity: 1; - background: #25b232; - width: 40px; -} -.swiper-pagination-bullet-active { - background: #1c1b1b; - width: 40px; - height: 10px; -} -.swiper-container-horizontal > .swiper-scrollbar { - bottom: unset; - bottom: 0; - left: 0; - width: 100%; -} -.swiper-scrollbar.progressAnimation .swiper-scrollbar-drag { - animation-name: mymove; - animation-timing-function: linear; -} -.swiper-scrollbar-drag { - height: 100%; - width: 100% !important; - transform: none !important; - position: relative; - background: #25b232; - border-radius: 0; - left: 0; - top: 0; -} -.swiper-lazy { - transition: all .2s ease-in-out; - -webkit-transition: all .2s ease-in-out; -} -.swiper-lazy-loading, .swiper-lazy { - opacity: 0; - visibility: hidden; -} -.swiper-lazy-loaded.swiper-lazy { - opacity: 1; - visibility: visible; -} -.swiper-container.o-visible { - overflow: visible; -} -@-webkit-keyframes mymove { - from { - transform: scaleX(0); - } - to { - transform: scaleX(1); - } -} - -/* aniamtion delays */ - -.animate__delay-05s { - animation-delay: .5s; -} -.animate__delay-15s { - animation-delay: 1.5s; -} -.animate__delay-25s { - animation-delay: 2.5s; -} -.animate__delay-35s { - animation-delay: 3.5s; -} - -/* left side container */ - -.left-side-container { - position: -webkit-sticky; - position: sticky; - top: 0; - display: flex; - justify-content: flex-end; - flex-direction: column; - width: 100%; - height: 195px; - margin: 0 0 15px 0; - padding: 15px 0 0 0; - background-color: #fff; - border-bottom: 1px solid rgba(0,0,0,.1); - direction: rtl; - z-index: 1034; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.left-side-container * { - direction: ltr; -} -.left-side-container::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,0.3); - border-radius: 4px; - background-color: #232323; -} -.left-side-container::-webkit-scrollbar { - width: 8px; - background-color: #232323; -} -.left-side-container::-webkit-scrollbar-thumb { - border-radius: 4px; - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,.3); - background-color: #5C5959; -} - -/* sidebar common */ - -.b-sidebar { - height: 100vh; - min-height: 100vh; -} - -/* Menu */ - - .Menu { - display: none; - position: relative; -} -/* Three basic menu layouts */ -/* This also enforces that menu is a ul, and that * layout modifier is specified */ -ul.Menu.-vertical { - position: relative; - display: flex; - flex-direction: column; - width: 100%; - height: 100%; - margin: 20px 0 0 0; - padding: 0; -} - ul.Menu.-floating { - display: block; - position: absolute; -} -/* Menu and menu-item layout */ - .Menu, .Menu li, .Menu li > ul { - list-style: none; - padding: 0px; - margin: 0px; -} - .Menu li { - position: relative; - word-break: break-word; - cursor: pointer; - min-width: 100%; -} - .Menu > li { - padding: 0 15px 0 15px; - } - .Menu > li.category > a { - color: #000; - 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; - text-transform: uppercase; - padding: 12px 5px; - margin: 0 0 5px 0; - line-height: 1; - } - .Menu li.category img { - margin: 0 0 10px 0; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - } - .Menu > li.other-links:first-of-type { - margin: 15px 0 0 0; - } - .Menu li .badge { - position: absolute; - top: -8px; - left: 5px; - padding: 4px 5px; - font-size: 10px; - font-weight: 500; -} - .Menu li.sub-cat-name-container a { - font-weight: 500; -} - .Menu li .back { - display: flex; - align-items: center; - width: 100%; - padding: 10px; - font-weight: 500; -} - .Menu li .back .b-icon.bi { - font-size: 16px; - margin: 0 15px 0 0; -} - .Menu li > * { - display: block; - position: relative; -} - .Menu li.home-link { - width: calc(100% - 20px); - margin: 0 0 10px 0; - padding: 0 0 10px 0; - border-bottom: 1px solid rgba(0,0,0,.2); - } - .Menu li > ul { - position: absolute; - min-width: 200px; - top: 0; - left: 100%; - display: none; - padding: 20px; - opacity: 0; - -webkit-transform: rotateX(90deg); - transform: rotateX(90deg); - visibility: hidden; - border: 1px solid rgba(0,0,0,.1); - -webkit-transition: transform .4s cubic-bezier(.19,1,.22,1); - transition: transform .4s cubic-bezier(.19,1,.22,1); -} - .Menu li.other-links > ul { - display: block; - max-width: calc(100% - 20px); - min-width: calc(100% - 20px); - position: static; - opacity: 1; - visibility: visible; - padding: 10px 0 0 0; - margin: 10px 0 20px 0; - border: 0; - border-top: 1px solid rgba(0,0,0,.2); - } - .Menu li > ul.gallery > li { - display: flex; - flex-wrap: wrap; - min-width: unset; - height: 100%; - margin: 0 10px 0 0; - } - .Menu li > ul.gallery > li.-hasSubmenu > .next-level { - display: flex; - flex-direction: column; - position: static; - width: 100%; - background-color: transparent; - -webkit-transform: none; - transform: none; - padding: 0 0 15px 0; - border: 0; - opacity: 1; - visibility: visible; - } - .Menu li.-hasSubmenu > ul.gallery > li.category > a { - color: #000; - font-size: 16px; - font-weight: 900; - text-transform: uppercase; - padding: 0 0 10px 0; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - } - .Menu li > ul.gallery > li.-hasSubmenu > a:after { - display: none; - } - .Menu li ul.-visible { - animation: fadeInMenu .3s ease-in-out; - } - @keyframes fadeInMenu { - from { - display: none; - opacity: 0; - -webkit-transform: rotateX(25deg); - transform: rotateX(25deg); - visibility: hidden; - } - to { - opacity: 1; - visibility: visible; - -webkit-transform: rotateX(0); - transform: rotateX(0); - display: flex; - } - } - @keyframes fadeOutMenu { - from { - opacity: 1; - visibility: visible; - -webkit-transform: rotateX(0); - transform: rotateX(0); - display: flex; - } - to { - display: none; - opacity: 0; - -webkit-transform: rotateX(25deg); - transform: rotateX(25deg); - visibility: hidden; - } - } -/* Menu behaviour */ - .Menu li > ul.-visible, ul.Menu.-floating.-visible { - display: flex; - flex-direction: column; - opacity: 1; - visibility: visible; - transform: rotateX(0); - z-index: 2; -} - .Menu li > ul.-visible.-animating { - animation: fadeOutMenu .3s ease-out; - } - .Menu li > ul.-visible.gallery { - flex-direction: row; - flex-wrap: wrap; - justify-content: flex-start; - overflow-x: hidden; - overflow-y: auto; - max-height: calc(100vh - 200px); - } -.Menu li.-hasSubmenu > .go-forward { - display: none; -} -/* Menu animation */ -.Menu li > ul, .Menu.-floating { - opacity: 1; - transform: scale(1) translateY(0px); - transform-origin: left top; -} - .Menu.-alignRight li > ul, .Menu.-floating.-alignRight { - transform-origin: right top; -} -/* Menu item icons */ - .Menu *[data-icon]:before { - position: absolute; - left: 0px; - top: 0px; - bottom: 0px; - margin: auto 0px; -} - .Menu .Icon, .Menu *[data-icon]:before { - line-height: inherit; -} - .Menu .Icon { - padding: 0px; -} - .Menu *:empty[data-icon] { - padding-left: 0px !important; - padding-right: 0px !important; -} -/* Submenu chevrons */ - .Menu li.-hasSubmenu > a:after, .Menu li.-hasSubmenu > div:after { - display: block; - position: absolute; - width: 8px; - height: 8px; - right: 8px; - bottom: 0px; - top: 0px; - margin: auto 0px; - transform: rotate(45deg); - border-width: 1px; - border-color: black; - border-style: solid solid none none; - content: ""; -} - .Menu li.-hasSubmenu.-noChevron > a:after, .Menu li.-hasSubmenu.-noChevron > div:after { - display: none; -} -.Menu { - font-family: 'Poppins', sans-serif; - line-height: 28px; -} - .Menu *[data-icon]:before, .Menu .Icon { - width: 40px; - height: 40px; -} - .Menu *[data-icon] { - min-width: 40px; - min-height: 40px; - padding-left: 40px; -} -/* Default font settings for menu */ - .Menu { - font-size: 16px; -} -/* Icon font sizes */ - .Menu *[data-icon]:before, .Menu .Icon { - font-size: 24px; -} -/* For submenus */ -.Menu > li ul .Icon, .Menu.-floating .Icon, .Menu > li ul *[data-icon]:before, .Menu.-floating *[data-icon]:before { - font-size: 18px; -} -/* Colouring of menus */ -/* Foreground */ - .Menu, .Menu li.-hasSubmenu > a:after, .Menu li.-hasSubmenu > div:after { - color: #aaa; - border-color: #aaa; - /* chevron colour */ -} -/* Background */ - .Menu ul, .Menu.-floating { - background-color: #fff; -} - .Menu ul.opacity { - background-color: rgba(28, 27, 27, .95); - } -/* Padding for each menu item */ - .Menu li > * { - padding: 0px 12px; -} -/* Indent of chevron */ - .Menu li li.-hasSubmenu > a:after, .Menu > li.-hasSubmenu > a:after { - right: 0; -} - .Menu li li.-hasSubmenu > a { - padding-right: 28px; -} -/* Minimum width of dropdown menus */ - .Menu.-floating { - min-width: 200px; -} -/* Styling of hyperlink text */ - .Menu li > a, .Menu li > div { - display: flex; - align-items: center; - width: 100%; - padding: 5px 10px; - font-size: 14px; - font-weight: 400; - text-decoration: none; - color: #fff; -} - .Menu > li > a { - /* text-transform: uppercase; */ - } - .Menu li .category-icon { - margin: 0 6px 0 0; - } - .Menu li .category-icon.bi-blank { - display: none; - } - .Menu li.-hasSubmenu ul li a { - color: #000; - font-size: 14px; - font-weight: 400; - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-items: flex-start; - padding: 0; - } -/* Animation speed of :hover shading */ - .Menu li > a:first-child { - transition: background-color 0.2s; -} -/* Colour of :hover shading */ - .Menu li > a:hover, - .Menu li > ul.gallery > li.-hasSubmenu > a:hover, - .Menu li.-hasSubmenu .next-level > li > a:hover, - .Menu li.-hasSubmenu > ul.gallery > li > a:hover, .Menu li.-hasSubmenu ul:not(.gallery) > li a:hover { - color: #25b232; -} - .Menu li > ul.gallery > li.-hasSubmenu.category > a:hover img, - .Menu li.-hasSubmenu .next-level > li.category > a:hover img, - .Menu li.-hasSubmenu > ul.gallery > li.category > a:hover img { - border-color: #25b232; -} - -/* bootstrap slider */ - -#GrandCarousel .link { - cursor: pointer; -} - -/* buttons */ - -.btn { - padding: .8rem 2.5rem; - font-size: 14px; - font-weight: 700; - border-radius: 0; -} -.btn-sm { - padding: .5rem 1rem; -} -.btn-secondary { - background-color: #1c1b1b; - border-color: #1c1b1b; -} -.btn-secondary:hover, .btn-secondary.focus, .btn-secondary:focus, .btn-secondary:not(:disabled):not(.disabled).active, .btn-secondary:not(:disabled):not(.disabled):active, .show>.btn-secondary.dropdown-toggle { - background-color: #353030; - border-color: #353030; - box-shadow: none; -} -.btn-outline-secondary { - color: #1c1b1b; - border-color: rgba(0,0,0,.2); -} -.btn-outline-secondary:hover { - background-color: #111; - border-color: #aaa; -} -.btn.disabled, .btn:disabled { - opacity: 0.85; -} -.btn-outline-info { - color: #000; - background-color: #fff; - 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 { - color: #fff; - background-color: #25b232; - border-color: #25b232; -} -.btn-info { - color: #fff; - background-color: #25b232; - border-color: #25b232; -} -.btn-info:hover, .btn-info .focus, .btn-info:focus, .btn-info:not(:disabled):not(.disabled).active, .btn-info:not(:disabled):not(.disabled):active, .show > .btn-info .dropdown-toggle, .btn-info:not(:disabled):not(.disabled).active, .btn-info:not(:disabled):not(.disabled):active, .show > .btn-info.dropdown-toggle { - color: #fff; - background-color: #000; - border-color: #000; -} - -/* headings */ - -.generalTitle, .page-page .page-title > h1 { - position: relative; - font-family: 'Poppins', sans-serif; - background-color: #fff; - display: table; - font-size: 38px; - font-weight: 700; - text-transform: uppercase; - text-align: left; - margin: 10px auto; - line-height: 1; -} - -h5.generalTitle { - position: relative; - font-family: 'Poppins', sans-serif; - background-color: #fff; - display: table; - font-size: 22px; - font-weight: 700; - text-transform: uppercase; - text-align: left; - margin: 10px auto; - line-height: 1; -} - -.generalTitle > span { - background-color: #fff; - display: table; - font-size: 27px; - font-weight: 700; - text-transform: uppercase; - text-align: left; - margin: 0 auto 0 0; -} -.generalTitle > span::before { - content: ""; - position: absolute; - top: 50%; - right: 0; - width: 100%; - height: 1px; - background: linear-gradient(to right, rgba(0,0,0,.2), transparent); - z-index: -1; -} -.generalTitle div { - padding: 0 10px 0 0; - background-color: #fff; - display: table; - font-size: 38px; - font-weight: 700; - text-transform: uppercase; - text-align: left; - margin: 30px auto; - position: relative; - line-height: 1; -} -.generalTitle div::before { - content: ""; - position: absolute; - bottom: 5px; - right: 0px; - width: 8px; - height: 8px; - background: #25b232; -} -.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 { - line-height: 1.3; -} -dl, ol, ul { - list-style-type: none; -} - -/* utilities */ - -.generalMarginSupporter { - margin: 15px 0; -} -.share-info { - overflow-wrap: break-word; - word-wrap: break-word; - -ms-word-break: break-all; - word-break: break-all; - word-break: break-word; - -ms-hyphens: auto; - -moz-hyphens: auto; - -webkit-hyphens: auto; - hyphens: auto; -} - -/* modals */ - -.modal-close { - position: absolute; - top: 4px; - right: 4px; - display: flex; - align-items: center; - justify-content: center; - width: 45px; - height: 45px; - padding: 0; - background-color: #1c1b1b; - border: 1px solid #333; - border-radius: 0; - color: #fff; - font-size: 28px; - cursor: pointer; - z-index: 3; -} -.modal-close:hover, .modal-close:focus, .modal-close:not(:disabled):not(.disabled):active { - color: #fff; - background-color: #1c1b1b; - border-color: #aaa; - box-shadow: none !important; -} -.modal-place .b-overlay-wrap { - position: fixed !important; - width: 100%; - height: 100%; - z-index: 1050; -} -#ModalAddToCart img { - background-color: #eaf0fa; -} -#ModalAddToCart .card { - border: 1px solid #25b232; -} - -/* color squares */ - -.color-squares input { - position: absolute; - visibility: hidden; -} -input ~ .color-container .color { - position: relative; - cursor: pointer; -} -input:checked ~ .color-container .color::before { - content: ""; - position: absolute; - top: -8px; - left: 12px; - width: 18px; - height: 18px; - display: flex; - color: #fff; - background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"); - background-size: 50% 50%; - background-repeat: no-repeat; - background-position: center center; - border-radius: 100%; - background-color: #25b232; - z-index: 5; -} - -/* voice navigator */ - -.voice-page-button { - position: fixed; - bottom: 70px; - right: 15px; - display: flex; - justify-content: center; - align-items: center; - width: 45px; - height: 45px; - font-size: 22px; - background-color: #fff; - 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); - transition: all .2s ease-in-out; - -webkit-transition: all .2s ease-in-out; - z-index: 1033; -} -.voice-page-button.focus, .voice-page-button:focus { - box-shadow: 0px 12px 28px rgba(0,123,255,.25), 0 -4px 14px rgba(0,123,255,.25); -} -.voice-page-button.focus { - animation: boxShadow 2s infinite; -} -.voice-alert { - -webkit-user-select: none; - -moz-user-select: none; - user-select: none; - pointer-events: none; - z-index: 1035; -} -.recording-aniamtion { - animation: fadeInOut 2s infinite; -} -.recording-window { - display: inline-flex; - align-items: center; - flex-wrap: nowrap; - width: 100%; -} -.recording-window .b-icon { - font-size: 28px; -} -.recording-window input.form-control { - width: 100%; - background-color: transparent; - border: 0; - box-shadow: none; - color: #212529; - font-size: 28px; - font-weight: 500; - text-overflow: ellipsis; -} - -@keyframes boxShadow { - 0% { - box-shadow: 0px 12px 28px #2196F3, 0 -4px 14px #2196F3; - } - 25% { - box-shadow: 0px 12px 28px #4A148C, 0 -4px 14px #4A148C; - } - 50% { - box-shadow: 0px 12px 28px #9C27B0, 0 -4px 14px #9C27B0; - } - 75% { - box-shadow: 0px 12px 28px #4A148C, 0 -4px 14px #4A148C; - } - 100% { - box-shadow: 0px 12px 28px #2196F3, 0 -4px 14px #2196F3; - } -} -@keyframes fadeInOut { - 0% { - opacity: 0; - } - 50% { - opacity: 1; - } - 100% { - opacity: 0; - } -} - -/* search box */ - -#voiceSearchButton { - position: relative; - color: #1c1b1b; - margin: 0 3px; - border-radius: 0; - box-shadow: none; - transition: all .2s ease-in-out; - -webkit-transition: all .2s ease-in-out; - z-index: 4; -} -#voiceSearchButton.focus { - animation: boxShadow 2s infinite; -} -@keyframes boxShadow { - 0% { - box-shadow: 0px 12px 28px #2196F3, 0 -4px 14px #2196F3; - } - 25% { - box-shadow: 0px 12px 28px #4A148C, 0 -4px 14px #4A148C; - } - 50% { - box-shadow: 0px 12px 28px #9C27B0, 0 -4px 14px #9C27B0; - } - 75% { - box-shadow: 0px 12px 28px #4A148C, 0 -4px 14px #4A148C; - } - 100% { - box-shadow: 0px 12px 28px #2196F3, 0 -4px 14px #2196F3; - } -} -.annotation { - padding: 15px; - position: absolute; - min-height: 58px; - bottom: calc(-100% - 29px); - left: 0; - background-color: #1c1b1b; - border: 1px solid #333; -} -.search-item { - position: relative; - padding: 0; - margin: 0; - width: 100%; -} -#small-searchterms { - margin: 0 -1px 0 0; - height: 50px; - color: #000; - background-color: #fff; - border: 2px solid #d7d7d7d7; -} -#small-searchterms:hover, #small-searchterms:hover + .input-group-append .btn, #small-searchterms + .input-group-append .btn:hover { - border-color: #25b232; -} -#small-searchterms::placeholder { - color: #777; - font-size: 14px; - font-weight: 400; -} -#small-searchterms + .input-group-append .btn { - padding: 0 20px; - font-size: 22px; - border: 2px solid #d7d7d7; - color: #000; - background-color: #fff; -} -.search-box-select { - max-width: 100px; - height: 50px; - background-color: #fff; - border: 2px solid #d7d7d7d7; -} -.search-box-select + .input-group-append { - margin-left: -2px; - background-color: #fff; -} -.search-button { - border: 2px solid #d7d7d7d7; -} -.advanced-search-results { - position: absolute; - top: 100%; - left: 0; - width: 100%; - max-height: 100vh; - overflow-x: hidden; - overflow-y: auto; - padding: 15px; - background: #fff; - border: 1px solid rgba(0,0,0,.1); - border-top: 0; - z-index: 3; -} -.advanced-search-results .title { - color: #000; - font-size: 16px; - font-weight: 700; -} -.advanced-search-results .list h6 a { - color: #000; - font-size: 14px; - font-weight: 500; -} -.advanced-search-results .title:first-child { - margin: 0 0 10px 0; - line-height: 1; -} -.advanced-search-results .title:not(:first-child) { - margin: 10px 0 10px 0; - padding: 10px 0 0 0; - border-top: 1px solid rgba(0,0,0,.1); - line-height: 1; -} -.advanced-search-results .alert { - font-size: 14px; - font-weight: 400; -} -.advanced-search-results .list { - margin: 0 0 10px 0; -} -.advanced-search-results .products .list .media .media-aside { - background-color: #eaf0fa; -} -.advanced-search-results .products .list .media:not(:last-child) { - margin: 0 0 10px 0; - padding: 0 0 10px 0; - border-bottom: 1px solid rgba(0,0,0,.1); -} -.advanced-search-results p { - color: #555; - font-size: 14px; - font-weight: 400; - max-height: 45px; - overflow: hidden; -} -.advanced-search-results .price { - width: 100%; - color: #000; - font-size: 16px; - font-weight: 700; - line-height: 1; -} -.advanced-search-results .b-rating { - margin: 0 0 10px 0; - height: 20px; - background-color: transparent; -} -.advanced-search-results .b-rating .b-rating-value { - font-size: 12px; - color: #efefef; -} -.advanced-search-results .b-rating .b-rating-star { - padding: 0 3px 0 0; -} -.header-nav .advanced-search-results .b-rating .b-rating-star .b-icon.bi { - font-size: 14px; -} - -/* rating */ - -.b-rating { - background-color: transparent; -} - -/* product box */ - -.product-box { - padding: 7.5px; - border: 0; - text-align: left; - background-color: #fff; -} -.product-box .attr-container { - position: absolute; - bottom: 50px; - left: 0; - right: 55px; - text-align: left; - opacity: 0; - visibility: hidden; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.product-box:hover .attr-container { - opacity: 1; - visibility: visible; -} -.attr-container .image-container img, -.attr-container .image-container { - width: auto; - height: auto; - padding: 0; - background-color: #fff; - border: 0; -} -.attr-container .image-container { - border: 1px solid #333; -} -.attr-container .image-container:hover { - border-color: #fff; -} -.product-box:hover { - z-index: 4; -} -.product-box *, -.product-box .card-footer * { - z-index: 2; -} -.product-box::before { - content: ""; - position: absolute; - top: -1px; - right: -1px; - bottom: 0; - left: 0; - visibility: hidden; - opacity: 0; - background: #fff; - border: 1px solid transparent; - border-bottom: 0; - border-radius: 0; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; -} -.product-box:hover::before { - border-color: rgba(0,0,0,.1); - visibility: visible; - opacity: 1; -} -.product-box .sale-label, -.product-box .flag-label, -.product-details-page .product-label { - position: absolute; - top: 11px; - right: 11px; - width: 28px; - writing-mode: vertical-lr; - z-index: 3; -} -.product-box .sale-label + .flag-label { - right: 46px; -} -.product-box .sale-label span, -.product-box .flag-label span, -.product-details-page .product-label span { - position: relative; - width: 100%; - height: 100%; - padding: 8px 0; - display: flex; - align-items: center; - justify-content: center; - background: #25b232; - color: #fff; - font-size: 14px; - font-weight: 500; - border-radius: 0; -} -.product-box .sale-label span::after, -.product-box .flag-label span::after, -.product-details-page .product-label span::after { - content: ""; - position: absolute; - top: 100%; - right: 0; - border-top: 0; - border-left: 14px solid #25b232; - border-right: 14px solid #25b232; - border-bottom: 8px solid transparent; -} -.product-box .flag-label span.danger, -.product-box .flag-label span.bg-danger, -.product-box-slide .flag-label span.danger, -.product-box-slide .flag-label span.bg-danger, -.product-details-page .product-label span.danger { - background: #dc3545; -} -.product-box .flag-label span.red, -.product-box .flag-label span.bg-red, -.product-box-slide .flag-label span.red, -.product-box-slide .flag-label span.bg-red, -.product-details-page .product-label span.red { - background: #F44336; -} -.product-box .flag-label span.pink, -.product-box .flag-label span.bg-pink, -.product-box-slide .flag-label span.pink, -.product-box-slide .flag-label span.bg-pink, -.product-details-page .product-label span.pink { - background: #E91E63; -} -.product-box .flag-label span.indigo, -.product-box-slide .flag-label span.indigo, -.product-box-slide .flag-label span.bg-indigo, -.product-box .flag-label span.bg-indigo, -.product-details-page .product-label span.indigo { - background: #3F51B5; -} -.product-box .flag-label span.blue, -.product-box-slide .flag-label span.blue, -.product-box .flag-label span.bg-blue, -.product-box-slide .flag-label span.bg-blue, -.product-details-page .product-label span.blue { - background: #2196F3; -} -.product-box .flag-label span.cyan, -.product-box-slide .flag-label span.cyan, -.product-box-slide .flag-label span.bg-cyan, -.product-box .flag-label span.bg-cyan, -.product-details-page .product-label span.cyan { - background: #00BCD4; -} -.product-box .flag-label span.teal, -.product-box-slide .flag-label span.teal, -.product-box-slide .flag-label span.bg-teal, -.product-box .flag-label span.bg-teal, -.product-details-page .product-label span.teal { - background: #009688; -} -.product-box .flag-label span.lime, -.product-box-slide .flag-label span.lime, -.product-box .flag-label span.bg-lime, -.product-box-slide .flag-label span.bg-lime, -.product-details-page .product-label span.lime { - background: #CDDC39; -} -.product-box .flag-label span.orange, -.product-box-slide .flag-label span.orange, -.product-box-slide .flag-label span.bg-orange, -.product-box .flag-label span.bg-orange, -.product-details-page .product-label span.orange { - background: #FF9800; -} -.product-box .flag-label span.brown, -.product-box-slide .flag-label span.brown, -.product-box-slide .flag-label span.bg-brown, -.product-box .flag-label span.bg-brown, -.product-details-page .product-label span.brown { - background: #795548; -} -.product-box .flag-label span.success, -.product-box-slide .flag-label span.success, -.product-box-slide .flag-label span.bg-success, -.product-box .flag-label span.bg-success, -.product-details-page .product-label span.success { - background: rgb(169, 204, 0); -} -.product-box .flag-label span.green, -.product-box-slide .flag-label span.green, -.product-box .flag-label span.bg-green, -.product-box-slide .flag-label span.bg-green, -.product-details-page .product-label span.green { - background: #8BC34A; -} -.product-box .flag-label span.purple, -.product-box-slide .flag-label span.purple, -.product-box-slide .flag-label span.bg-purple, -.product-box .flag-label span.bg-purple, -.product-details-page .product-label span.purple { - background: #673AB7; -} -.product-box .flag-label span.warning, -.product-box-slide .flag-label span.warning, -.product-box-slide .flag-label span.bg-warning, -.product-box .flag-label span.bg-warning, -.product-details-page .product-label span.warning { - color: #212529; - background: #ffc107; -} -.product-box .flag-label span.dark, -.product-box-slide .flag-label span.dark, -.product-box-slide .flag-label span.bg-dark, -.product-box .flag-label span.bg-dark, -.product-details-page .product-label span.dark { - background: #212529; -} -.product-box .sale-label span::after, -.product-box .flag-label span::after, -.product-details-page .product-label span::after { - content: ""; - position: absolute; - top: 100%; - right: 0; - border-top: 0; - border-left: 14px solid #25b232; - border-right: 14px solid #25b232; - border-bottom: 8px solid transparent; -} -.product-box .flag-label span.brown::after, -.product-box-slide .flag-label span.brown::after, -.product-box-slide .flag-label span.bg-brown::after, -.product-box .flag-label span.bg-brown::after, -.product-details-page .product-label span.brown::after { - border-left-color: #795548; - border-right-color: #795548; -} -.product-box .flag-label span.pink::after, -.product-box-slide .flag-label span.pink::after, -.product-box-slide .flag-label span.bg-pink::after, -.product-box .flag-label span.bg-pink::after, -.product-details-page .product-label span.pink::after { - border-left-color: #E91E63; - border-right-color: #E91E63; -} -.product-box .flag-label span.red::after, -.product-box-slide .flag-label span.red::after, -.product-box-slide .flag-label span.bg-red::after, -.product-box .flag-label span.bg-red::after, -.product-details-page .product-label span.red::after { - border-left-color: #F44336; - border-right-color: #F44336; -} -.product-box .flag-label span.orange::after, -.product-box-slide .flag-label span.orange::after, -.product-box-slide .flag-label span.bg-orange::after, -.product-box .flag-label span.bg-orange::after, -.product-details-page .product-label span.orange::after { - border-left-color: #FF9800; - border-right-color: #FF9800; -} -.product-box .flag-label span.lime::after, -.product-box-slide .flag-label span.lime::after, -.product-box .flag-label span.bg-lime::after, -.product-box-slide .flag-label span.bg-lime::after, -.product-details-page .product-label span.lime::after { - border-left-color: #CDDC39; - border-right-color: #CDDC39; -} -.product-box .flag-label span.cyan::after, -.product-box-slide .flag-label span.cyan::after, -.product-box-slide .flag-label span.bg-cyan::after, -.product-box .flag-label span.bg-cyan::after, -.product-details-page .product-label span.cyan::after { - border-left-color: #00BCD4; - border-right-color: #00BCD4; -} -.product-box .flag-label span.blue::after, -.product-box-slide .flag-label span.blue::after, -.product-box-slide .flag-label span.bg-blue::after, -.product-box .flag-label span.bg-blue::after, -.product-details-page .product-label span.blue::after { - border-left-color: #2196F3; - border-right-color: #2196F3; -} -.product-box .flag-label span.indigo::after, -.product-box-slide .flag-label span.indigo::after, -.product-box-slide .flag-label span.bg-indigo::after, -.product-box .flag-label span.bg-indigo::after, -.product-details-page .product-label span.indigo::after { - border-left-color: #3F51B5; - border-right-color: #3F51B5; -} -.product-box .flag-label span.danger::after, -.product-box-slide .flag-label span.danger::after, -.product-box-slide .flag-label span.bg-danger::after, -.product-box .flag-label span.bg-danger::after, -.product-details-page .product-label span.danger::after { - border-left-color: #dc3545; - border-right-color: #dc3545; -} -.product-box .flag-label span.purple::after, -.product-box-slide .flag-label span.purple::after, -.product-box-slide .flag-label span.bg-purple::after, -.product-box .flag-label span.bg-purple::after, -.product-details-page .product-label span.purple::after { - border-left-color: #673AB7; - border-right-color: #673AB7; -} -.product-box .flag-label span.green::after, -.product-box-slide .flag-label span.green::after, -.product-box-slide .flag-label span.bg-green::after, -.product-box .flag-label span.bg-green::after, -.product-details-page .product-label span.green::after { - border-left-color: #8BC34A; - border-right-color: #8BC34A; -} -.product-box .flag-label span.warning::after, -.product-box-slide .flag-label span.warning::after, -.product-box-slide .flag-label span.bg-warning::after, -.product-box .flag-label span.bg-warning::after, -.product-details-page .product-label span.warning::after { - border-left-color: #ffc107; - border-right-color: #ffc107; -} -.product-box .flag-label span.success::after, -.product-box-slide .flag-label span.success::after, -.product-box-slide .flag-label span.bg-success::after, -.product-box .flag-label span.bg-success::after, -.product-details-page .product-label span.success::after { - border-left-color: rgb(169, 204, 0); - border-right-color: rgb(169, 204, 0); -} -.product-box .flag-label span.dark::after, -.product-box-slide .flag-label span.dark::after, -.product-box-slide .flag-label span.bg-dark::after, -.product-box .flag-label span.bg-dark::after, -.product-details-page .product-label span.dark::after { - border-left-color: #212529; - border-right-color: #212529; -} -.product-box .picture-container { - position: relative; - overflow: hidden; - background-color: #eaf0fa; -} -.product-box .picture-container .img-container { - display: block; - position: relative; - height: 100%; -} -.product-box .picture-container .img-container::before { - content: ""; - display: block; - padding-top: 125%; -} -.product-box .picture-container .img-container img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - max-width: 100%; - width: auto; - height: auto; - margin: auto; - background-color: #eaf0fa; - -webkit-transition: all .4s ease-in-out; - transition: all .4s ease-in-out; -} -.product-box .picture-container .img-container:hover img { - transform: scale(1.1) rotate(5deg); - -webkit-transform: scale(1.1) rotate(5deg); -} -.product-box .hover-img { - position: absolute; - top: 0; - right: 0; - opacity: 0; - visibility: hidden; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.product-box .picture-container .img-container:hover .hover-img { - opacity: 1; - visibility: visible; -} -.product-box .hover-img.img-second { - opacity: 1; - visibility: visible; - z-index: 2; -} -.product-box .card-body { - padding: 15px 0 5px 0; -} -.product-box .qvb-container { - position: absolute; - bottom: 0; - left: 0; - right: 0; - width: 100%; - -webkit-transform: translateY(100%); - transform: translateY(100%); - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; -} -.product-box:hover .qvb-container { - -webkit-transform: translateY(0); - transform: translateY(0); -} -.product-box .qvb-container .btn { - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(38,34,34,.85); - border: 0; - font-size: 16px; - text-transform: uppercase; - font-weight: 500; - opacity: 0; - visibility: hidden; - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; -} -.product-box .qvb-container .btn:hover { - background-color: rgba(0,0,0,.85); -} -.product-box .qvb-container .btn .icons { - margin: 0 5px 0 0; -} -.product-box:hover .qvb-container .btn { - opacity: 1; - visibility: visible; -} -.product-box .other-buttons { - position: absolute; - top: 10px; - left: 10px; - display: flex; - flex-direction: row; - -webkit-transform: translateX(calc(-100% - 10px)); - transform: translateX(calc(-100% - 10px)); - -webkit-transition: transform .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - transition: transform .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); -} -.product-box:hover .other-buttons { - -webkit-transform: translateX(0); - transform: translateX(0); -} -.product-box .other-buttons .btn.btn-outline-secondary:not(:last-child) { - margin: 0 5px 0 0; -} -.product-box .other-buttons .btn.btn-outline-secondary { - display: flex; - align-items: center; - justify-content: center; - width: 38px; - height: 38px; - border: 1px solid #ccc; - border-radius: 100%; - padding: 0; - color: #000; - background-color: #fff; -} -.product-box .other-buttons .btn.btn-outline-secondary .icons, .product-box .qvb-container .btn .icons { - font-size: 16px; -} -.product-box .other-buttons .btn.btn-outline-secondary:hover { - border-color: #25b232; -} -.product-box .card-title { - position: relative; - line-height: 16px; - margin: 0; -} -.product-box .card-title a { - position: relative; - display: flex; - justify-content: flex-start; - width: 100%; - color: #1c1b1b; - 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; - font-weight: 500; -} -.product-box .prices::before { - /* content: ""; */ - position: absolute; - top: -10px; - left: 0; - width: 50%; - height: 1px; - background: linear-gradient(to right, transparent, #555); -} -.product-box .prices::after { - /* content: ""; */ - position: absolute; - top: -10px; - right: 0; - width: 50%; - height: 1px; - background: linear-gradient(to right, #555, transparent); -} -.product-box .rating { - display: inline-flex; - align-items: center; - justify-content: flex-start; - white-space: nowrap; - width: 100%; - margin: 0; -} -.product-box .b-rating .b-rating-star { - padding: 0 2px; -} -.product-box .b-rating .b-rating-star.b-rating-star-full, .product-box .b-rating .b-rating-star.b-rating-star-half { - color: #ffb300; -} -.product-box .b-rating .b-rating-star.b-rating-star-empty { - color: #cbd7ed; -} -.product-box .rating .b-rating { - background-color: transparent; -} -.product-box .rating .b-rating + a::before { - content: "/"; - margin: 0 3px; -} -.product-box .prices { - display: inline-flex; - align-items: flex-end; - flex-wrap: wrap; - position: relative; - margin: 0; - padding: 0; - width: 100%; - justify-content: flex-start; -} -.product-box .actual-price { - margin: 0 5px 0 0; - color: #1c1b1b; - font-size: 20px; - font-weight: 700; - line-height: 1; -} -.product-box .old-price { - color: #999; - font-size: 14px; - font-weight: 400; - line-height: 1; - text-decoration: line-through; -} -.product-box .card-footer { - position: absolute; - bottom: 10px; - left: 0; - right: 0; - opacity: 0; - visibility: hidden; - transform: translateY(100%); - -webkit-transform: translateY(100%); - transition: all .3s ease-in-out; - -webkit-transition: all .3s ease-in-out; - background-color: #232323; - border: 0; - padding: 0 10px 10px 10px; - z-index: -1; -} -.product-box:hover .card-footer { - opacity: 1; - visibility: visible; - z-index: 2; -} -.product-box .card-footer::before { - content: ""; - position: absolute; - top: -1px; - right: -1px; - bottom: 0; - left: 0; - background-color: #fff; - border: 1px solid transparent; - border-top: 0; - border-radius: 0; - transition: all .3s ease-in-out; - -webkit-transition: all .3s ease-in-out; - z-index: -1; -} -.product-box:hover .card-footer::before { - border-color: rgba(0,0,0,.1); -} -.product-box .attr-name { - color: #000; - font-size: 14px; - font-weight: 700; - padding: 5px; -} -.product-box .attr-value { - margin: 0 0 0 5px; -} -.product-box .attr-container table, .product-box .attr-container { - width: 100%; -} -.product-box .attr-container table td { - vertical-align: top; -} -.product-box .attr-value .btn { - margin: 0 3px 3px 0; - padding: 3px 6px; - font-size: 13px; - font-weight: 500; - border-radius: 4px; -} -.product-box .attr-value .btn.color-container { - padding: 0; -} -.product-box .attr-value .btn img { - max-width: 30px; -} -.product-box .desc { - position: relative; - color: #777; - font-size: 14px; - max-height: 85px; - margin: 5px 0 0 0; - overflow: hidden; -} -.product-box .add-to-cart-button.btn.btn-outline-secondary { - display: flex; - align-items: center; - justify-content: center; - margin: 10px 0 0 0; - padding: 12px 14px; - width: 100%; - color: #fff; - border-color: #1c1b1b; - background-color: #1c1b1b; - text-transform: uppercase; -} -.product-box .add-to-cart-button.btn.btn-outline-secondary:hover { - border-color: #25b232; - background-color: #25b232; -} -.product-box .catalog-quantity { - max-width: 60px; -} -.product-box .spec-container { - /* margin: 10px 0 0 0; */ -} -.product-box .spec-container table, -.product-box.grid-box .spec-container table td { - width: 100%; -} -.product-box .spec-container table td { - padding: 0 0 5px 0; -} -.product-box.grid-box .spec-container table td:nth-child(2) { - display: inline-flex; - justify-content: flex-end; -} -.product-box .spec { - display: inline-flex; - width: 100%; - line-height: 1; -} -.product-box .spec:not(:last-child) { - border-bottom: 1px dashed #dfdfdf; -} -.product-box .spec-container table { - min-width: 100%; -} -.product-box .spec-container table tr { - display: flex; - flex-wrap: wrap; - width: 100%; -} -.product-box .spec-container table tr td { - padding: 5px; - border: 1px dashed #333; -} -.product-box .spec-container table tr td .spec-name { - color: #aaa; - font-size: 14px; - font-weight: 300; -} -.product-box .spec-container table tr td .spec-value { - color: #ddd; - font-size: 14px; - font-weight: 500; -} -.product-box .countdown-box.alert { - padding: 5px; -} - -/* product box list */ - -.product-box.list { - flex-direction: row; - width: 100%; - text-align: left; - border-radius: 0; -} -.product-box.list .picture-container { - flex: 1 -} -.product-box.list .card-body { - flex: 3; - padding: 0 0 0 10px; - display: flex; - flex-direction: column; -} -.product-box.list .card-title a { - display: inline; -} -.product-box.list .rating, .product-box.list .prices { - justify-content: flex-start; -} -.product-box.list .prices { - width: auto; -} -.product-box.list::before { - border: 1px solid rgba(0,0,0,.1); - opacity: 1; - visibility: visible; -} -.product-box.list .attr-container { - position: static; - margin: 10px 0; - visibility: visible; - opacity: 1; -} -.product-box.list .attr-value { - margin: 0; -} -.product-box.mobile::before { - display: none; -} -.product-box.list .list-button-container { - display: inline-flex; - align-items: flex-end; - height: 100%; -} -.product-box.list .add-to-cart-button.btn.btn-outline-secondary, .product-box.list .other-buttons .btn.btn-outline-secondary:not(:last-child) { - margin: 0 5px 0 0; -} -.product-box.list .add-to-cart-button.btn.btn-outline-secondary, .product-box .other-buttons .btn.btn-outline-secondary { - height: 38px; -} -.product-box.list .other-buttons { - position: static; - -webkit-transform: none; - transform: none; - flex-direction: row; - align-items: flex-end; -} -.product-box.mobile:hover .card-footer { - opacity: 0; - visibility: hidden; -} -.product-box.list .spec-container table { - width: auto; - max-width: unset; -} -.countdown-box .col-form-label { - position: static; -} -.countdown-box > span { - display: inline-flex; - align-items: center; -} -.countdown-box > span span:not(:last-child)::after { - content: ":"; - position: relative; - margin: 0 3px; -} - -/* category grid */ - -.categoryGrid a { - color: #333; - text-decoration: none; -} -.categoryGrid .card-img { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} - -/* rating commons */ - -.rating .b-rating + a { - display: none; - color: #000; - position: relative; - font-size: 12px; - font-weight: 500; -} -.b-rating .b-rating-value { - color: #000; - font-size: 12px; - display: none; -} - -/* container wide */ - -.main-container { - min-height: 350px; -} - -/* color container */ - -.btn.color-container { - margin: 0 3px 3px 0; - padding: 0; - border: 0; - background-color: transparent; - border-radius: 100%; -} -.color-container .color { - display: flex; - width: 25px; - height: 25px; - box-shadow: 0 19px 38px rgba(0,0,0,0.30), 0 15px 12px rgba(0,0,0,0.22); - border-radius: 100%; -} - -/* autocomplete */ - -.vbt-autcomplete-list[data-v-48792d67] { - max-height: 350px; - overflow-y: auto; - padding-top: 5px; - position: absolute; - z-index: 999 -} - -/* breadcrumb */ - -.breadcrumb { - padding: 15px 0; - font-size: 14px; - background-color: transparent; -} -.breadcrumb-item.active { - color: #000; -} - -/* newsletter */ -.newsletter { - margin: 0 0 30px 0; - padding: 10px 20px 0 0; -} -.newsletter .newsletter-title { - color: #fff; - text-transform: uppercase; - font-size: 16px; - font-weight: 700; -} -.newsletter .newsletter-desc { - margin: 0 0 15px 0; - color: #888; - font-size: 16px; - font-weight: 300; -} -.newsletter-email { - border: 1px solid #333; - background-color: transparent; - color: #fff; - margin: 0 5px 0 0; - padding: 12px 16px; - height: auto; -} -.newsletter-email::placeholder { - color: #aaa; - font-size: 14px; -} -.newsletter-email-button { - padding: 0 10px; - border: 1px solid #333; - background-color: transparent; - color: #fff; -} -.newsletter-result, .newsletter-validation { - position: relative; -} -.newsletter-result .alert, .newsletter-validation .please-wait { - position: absolute; - top: 7px; - margin: 0; -} - -/* footer */ - -.footer { - background-color: #1c1b1b; - color: #aaa; - font-size: 16px; - font-weight: 400; - border-top: 1px solid #333 -} -.footer .section { - padding: 50px 0; -} -.footer a { - color: #888; - font-size: 16px; - font-weight: 300; -} -.footer a:hover, -.footer ul.social-icons li .icons:hover { - color: #25b232; -} -.footer .block { - display: flex; - flex-direction: column; - align-items: flex-start; -} -.footer ul{ - padding: 0; - margin: 0; -} -.footer li { - margin: 0 0 10px 0; -} -.footer ul.social-icons { - display: inline-flex; - align-items: center; - margin: 0 0 30px 0; -} -.footer ul.social-icons li { - filter: invert(1); -} -.footer ul.social-icons li:not(:last-child) { - margin: 0 15px 0 0; -} -.footer ul.social-icons li:last-child { - margin: 0; -} -.footer ul li .bi { - margin: 0 5px 0 0; -} -.footer .about-us .logo img { - max-height: 35px; -} -.footer .inside-section { - margin: 0 0 30px 0; -} -.footer .title { - color: #fff; - font-size: 16px; - font-weight: 700; - text-transform: uppercase; - margin: 0 0 30px 0; -} -.footer .footer-bottom { - border-top: 1px solid #333; -} - -/* forum */ - -.quote { - margin: 5px 0; - padding: 10px; - border: 1px dashed #ddd; -} - -/* key frames */ - -@keyframes galleryBar { - from { - width: 0; - } - to { - width: 100%; - } -} -@keyframes showMenu { - from { - display: none; - opacity: 0; - visibility: hidden; - transform: scale(.9); - } - to { - display: flex; - opacity: 1; - visibility: visible; - transform: scale(1); - } -} - -/* media */ - -@media (min-width: 992px) and (max-width: 1399px) { - .contact-header .content .number, .contact-header .content .title { - font-size: 12px; - } - .product-box .countdown-box.alert-info { - font-size: 14px; - } -} -@media (min-width: 992px) { - .Menu li.-hasSubmenu ul:not(.gallery) > li a { - font-size: 14px; - font-weight: 400; - } - .Menu li.-hasSubmenu ul.gallery > li a { - font-size: 14px; - font-weight: 400; - } - .h-lg-100 { - height: 100% !important; - } - .Menu li > ul.gallery.columns- > li.menuBg { - position: absolute; - bottom: 0; - right: 0; - display: block; - width: 300px; - height: 100%; - background-size: contain; - background-position: bottom; - background-repeat: no-repeat; - } - body.scroll-down:not(.onTop) .left-side-container { - -webkit-transform: translateY(-100%); - transform: translateY(-100%); - box-shadow: none; - } - body.scroll-down:not(.onTop) .b-sidebar, body.scroll-down:not(.onTop) .b-sidebar-backdrop { - top: 100%; - } - body.scroll-down:not(.onTop) .top-menu-container ul.Menu.-vertical, body.scroll-down:not(.onTop) .top-menu-container .menu-button.show + ul.Menu.-vertical { - display: none; - } - body.scroll-up .header-nav-container, body.onTop .left-side-container { - -webkit-transform: translateY(0); - transform: translateY(0); - } - .other-links { - display: flex; - align-items: center; - height: 100%; - } - .other-links ul { - display: inline-flex; - align-items: center; - padding: 0 15px; - } - .other-links ul li { - margin: 0 20px 0 0; - } - .other-links ul li a { - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - } - .Menu li > ul.gallery { - min-width: calc(960px - 100%); - max-width: calc(960px - 100%); - } - .Menu li > ul.gallery::before { - content: ""; - position: absolute; - top: 0; - left: 0; - right: 0; - width: 0; - height: 3px; - background: #25b232; - animation: galleryBar .8s cubic-bezier(0.645, 0.045, 0.355, 1.000); - } - .Menu li > ul.gallery.-visible::before { - width: 100%; - } - .top-menu-container .menu-button { - position: relative; - height: 50px; - width: 25%; - padding: 0 15px; - display: flex; - align-items: center; - justify-content: flex-start; - border: 1px solid rgba(0,0,0,.1); - border-bottom: 1px solid transparent; - cursor: pointer; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; - } - .top-menu-container .menu-button.show { - margin: 0; - } - .top-menu-container .menu-button::before, .top-menu-container .menu-button::after { - content: ""; - position: absolute; - top: 22px; - right: 10px; - width: 10px; - height: 2px; - background: #000; - transform: rotate(-45deg); - transition: all .2s ease-in-out; - -webkit-transition: all .2s ease-in-out; - } - .top-menu-container .menu-button::after { - transform: rotate(-135deg); - right: 16px; - } - .top-menu-container .menu-button:not(.show):before { - -transform: rotate(45deg); - -} - .top-menu-container .menu-button:not(.show):after { - -transform: rotate( --45deg -); - -} - .top-menu-container .menu-button span.icon { - display: flex; - flex-direction: column; - justify-content: space-between; - width: 30px; - height: 15px; - margin: 0 15px 0 0; - } - .top-menu-container .menu-button span.icon span { - width: 100%; - height: 3px; - background: #000; - } - .top-menu-container .menu-button span.title { - font-size: 16px; - font-weight: 900; - line-height: 1; - } - .top-menu-container { - position: relative; - display: flex; - flex-wrap: nowrap; - width: 100%; - margin: 15px 0 0 0; - } - .top-menu-container ul.Menu.-vertical { - display: none; - position: absolute; - top: 100%; - left: 0; - width: 25%; - height: auto; - margin: 0; - padding: 15px 0; - background-color: #fff; - border: 1px solid rgba(0,0,0,.1); - } - .top-menu-container .menu-button.show + ul.Menu.-vertical { - display: flex; - animation: showMenu .4s ease-in-out; - } - .Menu li > ul.gallery.columns-5 > li { - width: calc(20% - 10px); - } - .Menu li > ul.gallery.columns-4 > li { - width: calc(25% - 10px); - } - .Menu li > ul.gallery.columns-3 > li { - width: calc(33.3333333% - 10px); - } - .Menu li > ul.gallery.columns-2 > li { - width: calc(50% - 10px); - } - .Menu li > ul.gallery.columns- > li { - width: calc(33.3333333% - 10px); - } - .Menu li > ul { - overflow-x: hidden; - overflow-y: auto; - } - .Menu li > ul:not(.gallery) { - overflow: visible; - } - .Menu li:hover > ul.next-level { - display: flex; - top: -1px; - left: calc(100% - 20px); - visibility: visible - } -} -@media (min-width: 1200px) { - .Menu li > ul.gallery { - min-width: calc(1140px - 100%); - max-width: calc(1140px - 100%); - } -} -@media (min-width: 1260px) { - .col-for-5-xl { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } -} -@media (min-width: 1399px) { - .Menu li > ul.gallery { - min-width: calc(1340px - 100%); - max-width: calc(1340px - 100%); - } - .container, .container-lg, .container-md, .container-sm, .container-xl { - max-width: 1340px; - } -} -@media (min-width: 1680px) { - .Menu li > ul.gallery { - min-width: calc(1440px - 100%); - max-width: calc(1440px - 100%); - } - .container, .container-lg, .container-md, .container-sm, .container-xl { - max-width: 1440px; - } - .col-for-5-xl { - -ms-flex: 0 0 16.666667%; - flex: 0 0 16.666667%; - max-width: 16.666667%; - } - .modal-content .col-for-5-xl { - -ms-flex: 0 0 25%; - flex: 0 0 25%; - max-width: 25%; - } - .product-box.list .card-body { - flex: 4; - } - .Menu li > ul.gallery.columns- > li { - width: calc(25% - 10px); - } -} -@media (max-width: 1399px) { - .product-box .qvb-container .btn { - font-size: 14px; - } - .left-side-container { - padding: 10px 0 10px 10px; - } - .Menu li.home-link { - width: calc(100% - 10px); - } - .Menu li.other-links > ul { - max-width: calc(100% - 10px); - min-width: calc(100% - 10px); - } - .navbar-brand { - margin: 0 10px 0 0; - } - .product-box .rating .b-rating + a { - display: none; - } -} -@media (max-width: 1199px) { - .parallax-banner { - display: none !important; - } - .container, .container-lg, .container-md, .container-sm { - max-width: 100%; - } -} -@media (max-width: 991px) { - .picker-container .toggle-button { - bottom: 180px; - } - .store-selector-container { - display: none; - } - .Menu li > ul.gallery.columns- > li.menuBg { - display: none; - } - .generalTitle div { - font-size: 22px; - } - .main-container { - margin: 108px 0 0 0; - } - .product-box .qvb-container .btn { - display: none; - } - .search-item { - position: fixed; - top: 68px; - padding: 0; - left: 0; - right: 0; - background-color: #fff; - } - .generalTitle span { - padding: 0 15px 0 0; - font-size: 26px; - } - .footer { - margin: 15px 0 0 0; - } - .footer .title { - margin: 15px 0; - } - .footer .block { - align-items: center; - } - .footer ul, .footer .title, footer .section { - text-align: center; - } - .navbar-nav.selectors { - width: 100%; - flex-wrap: wrap; - } - .advanced-search-results { - top: 50px; - max-height: calc(100vh - 196px); - } - .Menu li.category img { - display: none; - } - .Menu li.other-links > ul { - max-width: 100%; - min-width: 100%; - } - .Menu li.-hasSubmenu > .go-forward { - display: flex; - } - .Menu li.-hasSubmenu > .go-back.-visible + .go-forward { - display: none; - } - .Menu li.-hasSubmenu > .go-back { - display: none; - position: relative; - } - .Menu li.-hasSubmenu > .go-back::before { - position: absolute; - width: 14px; - height: 2px; - right: 17px; - bottom: 0px; - top: 0px; - margin: auto 0px; - transform: rotate(0); - background: #000; - content: ""; - } - .Menu li.-hasSubmenu > .go-back.-visible { - display: flex; - } - .Menu li.-hasSubmenu > ul.gallery > li > .go-forward::after, .Menu li.-hasSubmenu > ul.gallery > li > .go-forward::before { - display: none; - } - .footer .footer-bottom { - padding: 0 0 115px 0; - } - .header-nav .search-item { - position: absolute; - top: -48px; - width: calc(100% + 20px); - left: -10px; - box-shadow: 0px -10px 9px rgba(0, 0, 0, .2); - } - .footer .section { - padding: 30px; - } - .navbar-brand { - margin: 0 auto; - padding: 0 0 0 30px; - } - .product-box .sale-label, .product-box .flag-label { - left: 11px; - right: unset; - } - .product-box .sale-label + .flag-label { - left: 46px; - right: unset; - } - .product-box .add-to-cart-button.btn.btn-outline-secondary { - padding: 0 12px; - font-size: 12px; - } - .product-box .other-buttons .btn.btn-outline-secondary.quick-view-button { - display: none; - } - .product-box .other-buttons .btn.btn-outline-secondary { - width: 32px; - height: 32px; - } - .menu-toggler { - position: absolute; - top: calc(50% - 11px); - left: 30px; - flex-direction: column; - justify-content: space-between; - align-items: center; - width: 30px; - height: 19px; - cursor: pointer; - } - .menu-toggler span { - position: relative; - width: 100%; - height: 3px; - background: #000; - transition: all .2s ease-in-out; - } - .menu-toggler.show span:first-child { - transform: rotate(45deg); - bottom: -6px; - } - .menu-toggler.show span:last-child { - position: relative; - bottom: 10px; - transform: rotate(-45deg); - } - .menu-toggler.show span:nth-child(2) { - opacity: 0; - visibility: hidden; - } - .Menu li.-hasSubmenu > .go-forward, - .Menu li.-hasSubmenu > .go-back { - position: absolute; - top: 0; - right: 20px; - width: 38px; - height: 38px; - z-index: 2; - } - .Menu li.-hasSubmenu > .go-forward::before { - display: block; - position: absolute; - width: 14px; - height: 2px; - right: 17px; - bottom: 0px; - top: 0px; - margin: auto 0px; - transform: rotate( 0deg); - background: #000; - content: ""; - } - .Menu li.-hasSubmenu > .go-forward::after { - display: block; - position: absolute; - width: 14px; - height: 2px; - right: 17px; - bottom: 0px; - top: 0px; - margin: auto 0; - transform: rotate( 90deg ); - background: #000; - content: ""; - } - .Menu li.home-link { - width: 100%; - padding: 10px 0; - } - .Menu li > ul.gallery > li { - width: 100%; - } - .Menu li > ul { - position: absolute; - top: 34px; - left: 0; - width: 100%; - border-left: 0; - border-right: 0; - z-index: 2; - } - .Menu li.-hasSubmenu ul li a { - font-size: 14px; - } - .Menu li.-hasSubmenu > ul.gallery > li > a { - font-size: 16px; - } - .Menu li > ul.-visible, ul.Menu.-floating.-visible { - z-index: 3; - } - .newsletter, .Menu li.-hasSubmenu > a:after { - display: none; - } - ul.Menu.-vertical { - position: absolute; - top: 100%; - overflow-x: hidden; - overflow-y: auto; - height: calc(100vh - 145px); - left: 0; - margin: 1px 0 0 0; - padding: 15px 10px; - background-color: #fff; - -webkit-transform: translateX(-100%); - transform: translateX(-100%); - -webkit-transition: transform .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - transition: transform .4s cubic-bezier(0.645, 0.045, 0.355, 1.000); - } - ul.Menu.-vertical.show { - -webkit-transform: translateX(0); - transform: translateX(0); - } - .left-side-container { - position: fixed; - top: 0; - left: 0; - width: 100%; - padding: 15px; - height: 70px; - justify-content: center; - overflow: visible; - border: 0; - border-bottom: 1px solid #333; - } - footer .list-group-item:not(.list-group-item-action) { - display: inline-flex; - justify-content: space-between; - width: 100%; - text-align: right; - } - .up-btn { - bottom: 125px; - } - .Menu .other-links li > a { - color: #000; - font-weight: 700; - } -} -@media (max-width: 768px) { - .swiper-info .top-header-info { - font-size: 10px; - } - #ModalBanner .modal-dialog { - max-width: 95%; - } - #ModalBanner .content .title .top { - font-size: 30px; - } - #ModalBanner .content .title .bottom { - font-size: 16px; - } - #ModalBanner .content input.form-control { - font-size: 14px; - } - .product-box.list .picture-container { - flex: 2; - } - .categoryGrid .card .card-body { - padding: 12px 10px; - } - .categoryGrid .card .card-body h5 { - font-size: 18px; - margin: 0; - } -} -@media (max-width: 575px) { - - .generalTitle span { - font-size: 16px; - text-align: center; - } - .navbar-nav.selectors .btn.dropdown-toggle { - font-size: 12px; - } - #ModalBanner .content .title .top { - font-size: 26px; - } - #ModalBanner .content { - padding: 15px; - } - #ModalBanner .flying-content { - top: unset; - left: unset; - right: 15px; - bottom: 15px; - } - .product-box .countdown-box .col-form-label { - width: 100%; - display: flex; - justify-content: center; - padding: 0; - line-height: 1; - } - .breadcrumb { - padding: 10px 0; - font-size: 12px; - font-weight: 300; - } - .breadcrumb-item + .breadcrumb-item { - padding-left: 2px; - } - .breadcrumb-item+.breadcrumb-item::before { - padding-right: 2px; - } - .swiper-container.s2, .swiper-container.s3 { - max-width: 100%; - overflow: hidden; - } - .product-box .rating .b-rating-value, - .product-box .rating output + span { - display: none; - } - .product-box .quick-view-button { - font-size: 14px; - } - .b-sidebar > .b-sidebar-header { - font-size: 16px; - } - .product-box .rating .b-rating + a, - .product-box .desc, - .product-box:not(.list) .attr-container, - .product-box:not(.list) .other-buttons { - display: none; - } -} -@media (max-width: 420px) { - .menu-toggler { - left: 15px; - } - #ModalBanner .content .title .top { - font-size: 20px; - } - #ModalBanner .content .title .bottom { - font-size: 12px; - } - #ModalBanner .content input.form-control { - max-width: 55%; - font-size: 12px; - } - #ModalBanner .content #send-interactive-form.btn-success { - padding: 8px 16px; - } -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/content.css b/src/Plugins/Theme.Modern/Content/css/content.css new file mode 100644 index 0000000000..199b22069f --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/content.css @@ -0,0 +1,49 @@ +/* Modern - content: blog, news, pages, knowledge base, search, 404. */ + +.news-list-page .card, +.blog-page .card, +[dark-theme="true"] .news-list-page .card, +[dark-theme="true"] .blog-page .card { overflow: hidden; background: var(--mdn-surface); border-radius: var(--mdn-r-card); } +.news-list-page .card-img-top, +.blog-page .card-img-top { aspect-ratio: 16 / 9; object-fit: cover; border-radius: 0; } +.news-list-page .card-header, +.blog-page .card-header { padding: 16px 20px 0; border: 0; background: transparent; } +.news-list-page .card-header a, +.blog-page .card-header a { color: var(--mdn-ink); font-weight: 600; letter-spacing: -.015em; } +.news-list-page .card-body, +.blog-page .card-body { padding: 8px 20px 16px; color: var(--mdn-muted-text); } + +.news-item-page, +.blogpost-page { max-width: 760px; margin-inline: auto; } +.news-item-page .generalTitle, +.blogpost-page .generalTitle { font-size: clamp(30px, 3.6vw, 46px); letter-spacing: -.035em; } +.news-item-page .date, +.blogpost-page .date, +[dark-theme="true"] .news-item-page .date, +[dark-theme="true"] .blogpost-page .date { color: var(--mdn-muted-text); font-family: var(--mdn-mono); font-size: 13px; } +.news-item-page [v-pre], +.blogpost-page [v-pre] { font-size: 17px; line-height: 1.7; } +.news-item-page .img-fluid, +.blogpost-page .img-fluid { border-radius: var(--mdn-r-block); } +.blogpost-page .tags a { display: inline-block; margin: 0 6px 6px 0; padding: 4px 12px; background: var(--mdn-surface); border-radius: var(--mdn-r-pill); font-size: 13px; } + +/* links inside running text: base.css strips decoration from every link, which leaves an + inline link indistinguishable from the words around it */ +.page-body a, +.blogpost-page [v-pre] a, +.news-item-page [v-pre] a, +.product-details-page .full-description a, +.product-details-page .short-description a, +.checkout-page .terms-of-service a, +.shopping-cart-page .terms-of-service a { + text-decoration: underline; + text-decoration-thickness: 1px; + text-underline-offset: .2em; +} + +/* CMS pages only: .page-body also wraps the whole shopping cart */ +.page-page .page-body { max-width: 760px; font-size: 16px; line-height: 1.7; } + +.search-page .basic-search .form-control { min-height: 52px; padding-inline: 20px; border-radius: var(--mdn-r-pill); } + +.not-found-page .generalTitle { font-size: clamp(40px, 6vw, 72px); letter-spacing: -.04em; } diff --git a/src/Plugins/Theme.Modern/Content/css/customer/customer.css b/src/Plugins/Theme.Modern/Content/css/customer/customer.css deleted file mode 100644 index 9b79748366..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/customer/customer.css +++ /dev/null @@ -1,153 +0,0 @@ -.block-account-navigation ul li a { - display: inline-flex; - align-items: center; - width: 100%; - padding: 10px; - border: 1px solid #ddd; - border-radius: .25rem; - margin: 0 0 5px 0; -} -.block-account-navigation ul li a.active, .block-account-navigation ul li a:hover { - color: #25b232; - border-color: #25b232; -} -.block-account-navigation ul li svg { - font-size: 28px; - margin: 0 10px 0 0; -} -.block-account-navigation ul li span { - font-size: 16px; -} -.return-request-page .result .btn { - display: table; - margin: 0 auto; - width: auto !important; -} - -/* merchandise table */ - -.merchandise-table { - display: flex; - flex-direction: column; - padding: 0 0 25px 0; - position: relative; -} -.merchandise-table thead tr { - display: flex; - width: 100%; - padding: 0 0 0 50px; - border: 0; -} -.merchandise-table thead th { - flex-basis: 0; - flex-grow: 1; - max-width: 100%; - min-width: 125px; - display: flex; - align-items: center; - border-bottom: 0; - border-top: 0; -} -.merchandise-table thead th:first-child { - display: flex; - align-items: center; -} -.merchandise-table tbody { - display: inline-flex; - flex-direction: row; - flex-wrap: nowrap; - width: 100%; - min-width: 100%; -} -.merchandise-table tbody tr:nth-child(1) { - display: flex; - flex-direction: column; - min-width: 50px; -} -.merchandise-table tbody tr:nth-child(1) td { - height: 100%; - padding: 0; - border: 0; -} -.merchandise-table tbody tr:nth-child(1) td > div { - height: 100%; - min-height: 100%; - display: flex; - flex-direction: column; -} -.merchandise-table tbody tr:nth-child(1) td > div > div { - min-height: 110px; - display: flex; - justify-content: center; - align-items: center; -} -.merchandise-table tbody tr:nth-child(2) { - width: calc(100% - 50px); -} -.merchandise-table tbody tr:nth-child(2) td { - display: inline-flex; - width: 100%; - padding: .75rem 0; - height: 110px; -} -.merchandise-table tbody tr:nth-child(2) td > div { - flex-basis: 0; - flex-grow: 1; - max-width: 100%; - padding: 0 0.75rem; - display: flex; - flex-direction: column; - justify-content: center; - min-width: 125px; -} -.merchandise-table .product a { - white-space: nowrap; - text-overflow: ellipsis; - overflow: hidden; -} -.merchandise-table .field-validation-error { - position: absolute; - bottom: 0; - left: 0; - right: 0; - margin: 0; -} - -@media (max-width: 768px) { - .merchandise-table tbody tr:nth-child(2) td > div { - max-width: 125px; - } - .merchandise-table { - padding: 0 0 45px 0; - } - .merchandise-table .field-validation-error { - font-size: 14px; - } -} - -/* status */ - -table .status.badge { - font-size: 16px; - padding: 6px 8px; -} -table .status.pending { - background-color: #d1ecf1; -} -table .status.processing { - background-color: #fff3cd; -} -table .status.complete { - background-color: #d4edda; -} -table .status.cancelled { - background-color: #f8d7da; -} - -/* pagination */ - -.pagination .current-page.page-item .page-link { - color: #fff; - background-color: #0E606C; - border-color: #0E606C; -} diff --git a/src/Plugins/Theme.Modern/Content/css/fonts.css b/src/Plugins/Theme.Modern/Content/css/fonts.css new file mode 100644 index 0000000000..21afd8e1dc --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/fonts.css @@ -0,0 +1,37 @@ +/* Modern - self-hosted fonts (SIL OFL 1.1, see ../fonts/README.md). latin + latin-ext subsets. */ + +@font-face { + font-family: "Geist"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("../fonts/geist-latin-var.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Geist"; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url("../fonts/geist-latin-ext-var.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url("../fonts/geist-mono-latin-var.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Geist Mono"; + font-style: normal; + font-weight: 400 600; + font-display: swap; + src: url("../fonts/geist-mono-latin-ext-var.woff2") format("woff2"); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Bold.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Bold.ttf deleted file mode 100644 index b94d47f3af..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Bold.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-ExtraBold.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-ExtraBold.ttf deleted file mode 100644 index 8f008c3684..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-ExtraBold.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Light.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Light.ttf deleted file mode 100644 index 2ab022196b..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Light.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Medium.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Medium.ttf deleted file mode 100644 index e90e87ed69..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Medium.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Regular.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Regular.ttf deleted file mode 100644 index be06e7fdca..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-Regular.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-SemiBold.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-SemiBold.ttf deleted file mode 100644 index dabf7c242e..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Poppins-SemiBold.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/Simple-Line-Icons.ttf b/src/Plugins/Theme.Modern/Content/css/fonts/Simple-Line-Icons.ttf deleted file mode 100644 index 6ecb686834..0000000000 Binary files a/src/Plugins/Theme.Modern/Content/css/fonts/Simple-Line-Icons.ttf and /dev/null differ diff --git a/src/Plugins/Theme.Modern/Content/css/fonts/simple-line-icons.min.css b/src/Plugins/Theme.Modern/Content/css/fonts/simple-line-icons.min.css deleted file mode 100644 index abd402a26b..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/fonts/simple-line-icons.min.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:simple-line-icons;src:url(../fonts/Simple-Line-Icons.ttf) format('truetype');font-display:swap;font-weight:400;font-style:normal}.icon-action-redo,.icon-action-undo,.icon-anchor,.icon-arrow-down,.icon-arrow-down-circle,.icon-arrow-left,.icon-arrow-left-circle,.icon-arrow-right,.icon-arrow-right-circle,.icon-arrow-up,.icon-arrow-up-circle,.icon-badge,.icon-bag,.icon-ban,.icon-basket,.icon-basket-loaded,.icon-bell,.icon-book-open,.icon-briefcase,.icon-bubble,.icon-bubbles,.icon-bulb,.icon-calculator,.icon-calendar,.icon-call-end,.icon-call-in,.icon-call-out,.icon-camera,.icon-camrecorder,.icon-chart,.icon-check,.icon-chemistry,.icon-clock,.icon-close,.icon-cloud-download,.icon-cloud-upload,.icon-compass,.icon-control-end,.icon-control-forward,.icon-control-pause,.icon-control-play,.icon-control-rewind,.icon-control-start,.icon-credit-card,.icon-crop,.icon-cup,.icon-cursor,.icon-cursor-move,.icon-diamond,.icon-direction,.icon-directions,.icon-disc,.icon-dislike,.icon-doc,.icon-docs,.icon-drawer,.icon-drop,.icon-earphones,.icon-earphones-alt,.icon-emotsmile,.icon-energy,.icon-envelope,.icon-envelope-letter,.icon-envelope-open,.icon-equalizer,.icon-event,.icon-exclamation,.icon-eye,.icon-eyeglass,.icon-feed,.icon-film,.icon-fire,.icon-flag,.icon-folder,.icon-folder-alt,.icon-frame,.icon-game-controller,.icon-ghost,.icon-globe,.icon-globe-alt,.icon-graduation,.icon-graph,.icon-grid,.icon-handbag,.icon-heart,.icon-home,.icon-hourglass,.icon-info,.icon-key,.icon-layers,.icon-like,.icon-link,.icon-list,.icon-location-pin,.icon-lock,.icon-lock-open,.icon-login,.icon-logout,.icon-loop,.icon-magic-wand,.icon-magnet,.icon-magnifier,.icon-magnifier-add,.icon-magnifier-remove,.icon-map,.icon-menu,.icon-microphone,.icon-minus,.icon-mouse,.icon-music-tone,.icon-music-tone-alt,.icon-mustache,.icon-note,.icon-notebook,.icon-options,.icon-options-vertical,.icon-organization,.icon-paper-clip,.icon-paper-plane,.icon-paypal,.icon-pencil,.icon-people,.icon-phone,.icon-picture,.icon-pie-chart,.icon-pin,.icon-plane,.icon-playlist,.icon-plus,.icon-power,.icon-present,.icon-printer,.icon-puzzle,.icon-question,.icon-refresh,.icon-reload,.icon-rocket,.icon-screen-desktop,.icon-screen-smartphone,.icon-screen-tablet,.icon-settings,.icon-share,.icon-share-alt,.icon-shield,.icon-shuffle,.icon-size-actual,.icon-size-fullscreen,.icon-social-behance,.icon-social-dribbble,.icon-social-dropbox,.icon-social-facebook,.icon-social-foursqare,.icon-social-github,.icon-social-google,.icon-social-instagram,.icon-social-linkedin,.icon-social-pinterest,.icon-social-reddit,.icon-social-skype,.icon-social-soundcloud,.icon-social-spotify,.icon-social-steam,.icon-social-stumbleupon,.icon-social-tumblr,.icon-social-twitter,.icon-social-vkontakte,.icon-social-youtube,.icon-speech,.icon-speedometer,.icon-star,.icon-support,.icon-symbol-female,.icon-symbol-male,.icon-tag,.icon-target,.icon-trash,.icon-trophy,.icon-umbrella,.icon-user,.icon-user-female,.icon-user-follow,.icon-user-following,.icon-user-unfollow,.icon-vector,.icon-volume-1,.icon-volume-2,.icon-volume-off,.icon-wallet,.icon-wrench{font-family:simple-line-icons;speak:none;font-style:normal;font-weight:400;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.icon-user:before{content:"\e005"}.icon-people:before{content:"\e001"}.icon-user-female:before{content:"\e000"}.icon-user-follow:before{content:"\e002"}.icon-user-following:before{content:"\e003"}.icon-user-unfollow:before{content:"\e004"}.icon-login:before{content:"\e066"}.icon-logout:before{content:"\e065"}.icon-emotsmile:before{content:"\e021"}.icon-phone:before{content:"\e600"}.icon-call-end:before{content:"\e048"}.icon-call-in:before{content:"\e047"}.icon-call-out:before{content:"\e046"}.icon-map:before{content:"\e033"}.icon-location-pin:before{content:"\e096"}.icon-direction:before{content:"\e042"}.icon-directions:before{content:"\e041"}.icon-compass:before{content:"\e045"}.icon-layers:before{content:"\e034"}.icon-menu:before{content:"\e601"}.icon-list:before{content:"\e067"}.icon-options-vertical:before{content:"\e602"}.icon-options:before{content:"\e603"}.icon-arrow-down:before{content:"\e604"}.icon-arrow-left:before{content:"\e605"}.icon-arrow-right:before{content:"\e606"}.icon-arrow-up:before{content:"\e607"}.icon-arrow-up-circle:before{content:"\e078"}.icon-arrow-left-circle:before{content:"\e07a"}.icon-arrow-right-circle:before{content:"\e079"}.icon-arrow-down-circle:before{content:"\e07b"}.icon-check:before{content:"\e080"}.icon-clock:before{content:"\e081"}.icon-plus:before{content:"\e095"}.icon-minus:before{content:"\e615"}.icon-close:before{content:"\e082"}.icon-event:before{content:"\e619"}.icon-exclamation:before{content:"\e617"}.icon-organization:before{content:"\e616"}.icon-trophy:before{content:"\e006"}.icon-screen-smartphone:before{content:"\e010"}.icon-screen-desktop:before{content:"\e011"}.icon-plane:before{content:"\e012"}.icon-notebook:before{content:"\e013"}.icon-mustache:before{content:"\e014"}.icon-mouse:before{content:"\e015"}.icon-magnet:before{content:"\e016"}.icon-energy:before{content:"\e020"}.icon-disc:before{content:"\e022"}.icon-cursor:before{content:"\e06e"}.icon-cursor-move:before{content:"\e023"}.icon-crop:before{content:"\e024"}.icon-chemistry:before{content:"\e026"}.icon-speedometer:before{content:"\e007"}.icon-shield:before{content:"\e00e"}.icon-screen-tablet:before{content:"\e00f"}.icon-magic-wand:before{content:"\e017"}.icon-hourglass:before{content:"\e018"}.icon-graduation:before{content:"\e019"}.icon-ghost:before{content:"\e01a"}.icon-game-controller:before{content:"\e01b"}.icon-fire:before{content:"\e01c"}.icon-eyeglass:before{content:"\e01d"}.icon-envelope-open:before{content:"\e01e"}.icon-envelope-letter:before{content:"\e01f"}.icon-bell:before{content:"\e027"}.icon-badge:before{content:"\e028"}.icon-anchor:before{content:"\e029"}.icon-wallet:before{content:"\e02a"}.icon-vector:before{content:"\e02b"}.icon-speech:before{content:"\e02c"}.icon-puzzle:before{content:"\e02d"}.icon-printer:before{content:"\e02e"}.icon-present:before{content:"\e02f"}.icon-playlist:before{content:"\e030"}.icon-pin:before{content:"\e031"}.icon-picture:before{content:"\e032"}.icon-handbag:before{content:"\e035"}.icon-globe-alt:before{content:"\e036"}.icon-globe:before{content:"\e037"}.icon-folder-alt:before{content:"\e039"}.icon-folder:before{content:"\e089"}.icon-film:before{content:"\e03a"}.icon-feed:before{content:"\e03b"}.icon-drop:before{content:"\e03e"}.icon-drawer:before{content:"\e03f"}.icon-docs:before{content:"\e040"}.icon-doc:before{content:"\e085"}.icon-diamond:before{content:"\e043"}.icon-cup:before{content:"\e044"}.icon-calculator:before{content:"\e049"}.icon-bubbles:before{content:"\e04a"}.icon-briefcase:before{content:"\e04b"}.icon-book-open:before{content:"\e04c"}.icon-basket-loaded:before{content:"\e04d"}.icon-basket:before{content:"\e04e"}.icon-bag:before{content:"\e04f"}.icon-action-undo:before{content:"\e050"}.icon-action-redo:before{content:"\e051"}.icon-wrench:before{content:"\e052"}.icon-umbrella:before{content:"\e053"}.icon-trash:before{content:"\e054"}.icon-tag:before{content:"\e055"}.icon-support:before{content:"\e056"}.icon-frame:before{content:"\e038"}.icon-size-fullscreen:before{content:"\e057"}.icon-size-actual:before{content:"\e058"}.icon-shuffle:before{content:"\e059"}.icon-share-alt:before{content:"\e05a"}.icon-share:before{content:"\e05b"}.icon-rocket:before{content:"\e05c"}.icon-question:before{content:"\e05d"}.icon-pie-chart:before{content:"\e05e"}.icon-pencil:before{content:"\e05f"}.icon-note:before{content:"\e060"}.icon-loop:before{content:"\e064"}.icon-home:before{content:"\e069"}.icon-grid:before{content:"\e06a"}.icon-graph:before{content:"\e06b"}.icon-microphone:before{content:"\e063"}.icon-music-tone-alt:before{content:"\e061"}.icon-music-tone:before{content:"\e062"}.icon-earphones-alt:before{content:"\e03c"}.icon-earphones:before{content:"\e03d"}.icon-equalizer:before{content:"\e06c"}.icon-like:before{content:"\e068"}.icon-dislike:before{content:"\e06d"}.icon-control-start:before{content:"\e06f"}.icon-control-rewind:before{content:"\e070"}.icon-control-play:before{content:"\e071"}.icon-control-pause:before{content:"\e072"}.icon-control-forward:before{content:"\e073"}.icon-control-end:before{content:"\e074"}.icon-volume-1:before{content:"\e09f"}.icon-volume-2:before{content:"\e0a0"}.icon-volume-off:before{content:"\e0a1"}.icon-calendar:before{content:"\e075"}.icon-bulb:before{content:"\e076"}.icon-chart:before{content:"\e077"}.icon-ban:before{content:"\e07c"}.icon-bubble:before{content:"\e07d"}.icon-camrecorder:before{content:"\e07e"}.icon-camera:before{content:"\e07f"}.icon-cloud-download:before{content:"\e083"}.icon-cloud-upload:before{content:"\e084"}.icon-envelope:before{content:"\e086"}.icon-eye:before{content:"\e087"}.icon-flag:before{content:"\e088"}.icon-heart:before{content:"\e08a"}.icon-info:before{content:"\e08b"}.icon-key:before{content:"\e08c"}.icon-link:before{content:"\e08d"}.icon-lock:before{content:"\e08e"}.icon-lock-open:before{content:"\e08f"}.icon-magnifier:before{content:"\e090"}.icon-magnifier-add:before{content:"\e091"}.icon-magnifier-remove:before{content:"\e092"}.icon-paper-clip:before{content:"\e093"}.icon-paper-plane:before{content:"\e094"}.icon-power:before{content:"\e097"}.icon-refresh:before{content:"\e098"}.icon-reload:before{content:"\e099"}.icon-settings:before{content:"\e09a"}.icon-star:before{content:"\e09b"}.icon-symbol-female:before{content:"\e09c"}.icon-symbol-male:before{content:"\e09d"}.icon-target:before{content:"\e09e"}.icon-credit-card:before{content:"\e025"}.icon-paypal:before{content:"\e608"}.icon-social-tumblr:before{content:"\e00a"}.icon-social-twitter:before{content:"\e009"}.icon-social-facebook:before{content:"\e00b"}.icon-social-instagram:before{content:"\e609"}.icon-social-linkedin:before{content:"\e60a"}.icon-social-pinterest:before{content:"\e60b"}.icon-social-github:before{content:"\e60c"}.icon-social-google:before{content:"\e60d"}.icon-social-reddit:before{content:"\e60e"}.icon-social-skype:before{content:"\e60f"}.icon-social-dribbble:before{content:"\e00d"}.icon-social-behance:before{content:"\e610"}.icon-social-foursqare:before{content:"\e611"}.icon-social-soundcloud:before{content:"\e612"}.icon-social-spotify:before{content:"\e613"}.icon-social-stumbleupon:before{content:"\e614"}.icon-social-youtube:before{content:"\e008"}.icon-social-dropbox:before{content:"\e00c"}.icon-social-vkontakte:before{content:"\e618"}.icon-social-steam:before{content:"\e620"} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/header.css b/src/Plugins/Theme.Modern/Content/css/header.css new file mode 100644 index 0000000000..92332068eb --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/header.css @@ -0,0 +1,388 @@ +/* Modern - header: promo bar, sticky blurred bar, inline menu, search pill, drawer menu. */ + +/* ---------- promo bar ---------- */ + +.mdn-promo { + background: var(--mdn-ink); + color: var(--mdn-on-ink); + font-size: 13px; + text-align: center; + padding: 8px 16px; +} +/* in dark mode an inverted (white) strip glares; a raised surface reads as the same accent */ +[data-bs-theme=dark] .mdn-promo { background: var(--mdn-surface-2); color: var(--mdn-ink); } +/* PageBlock wraps the text in .generalMarginSupporter > .page-block-body.pt-3 */ +.mdn-promo .generalMarginSupporter, +.mdn-promo .page-block-body { margin: 0; padding: 0 !important; } /* beats the .pt-3 utility */ +.mdn-promo p { margin: 0; } +/* PageBlock renders the page title as an

- ink on ink here, and a second h1 */ +.mdn-promo .generalTitle { display: none; } +.mdn-promo a { color: inherit; text-decoration: underline; text-underline-offset: 3px; } + +/* ---------- sticky blurred bar ---------- */ + +.mdn-header, +[dark-theme="true"] .mdn-header { + position: sticky; + top: 0; + z-index: 1034; + background: color-mix(in srgb, var(--mdn-bg) 78%, transparent); + -webkit-backdrop-filter: saturate(180%) blur(12px); + backdrop-filter: saturate(180%) blur(12px); + border-bottom: 1px solid var(--mdn-line); +} + +/* the bottom line fades in once the page scrolls; without scroll-driven animations it just stays */ +@keyframes mdn-header-line { + from { border-bottom-color: transparent; } + to { border-bottom-color: var(--mdn-line); } +} +@supports (animation-timeline: scroll()) { + .mdn-header { + animation: mdn-header-line linear both; + animation-timeline: scroll(root); + animation-range: 0 64px; + } +} + +.mdn-header .navbar { padding: 10px 0 0; } +/* Default fixes .header-nav > .navbar at 58px on phones; the search row needs it to grow */ +.mdn-header > .navbar { height: auto; } + +/* two rows in one sticky bar: logo | search | icons, then the category row. A store's menu + (categories + menu pages + brands) rarely fits beside the search, and a second row can + wrap, so no dropdown ever gets clipped by a scroll container. */ +.mdn-header #header-container { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: 24px; + row-gap: 6px; +} + +.mdn-header .store-logo { + flex: none; + margin: 0; + padding: 0; + order: 1; +} +.mdn-header .store-logo img { width: auto; max-width: 220px; max-height: 48px; } + +.mdn-header .mdn-search { order: 2; } +.mdn-header .top-header-items { order: 3; margin-left: auto; } +.mdn-header .mdn-menu-row { order: 4; flex: 0 0 100%; margin-inline: -12px; padding-bottom: 6px; } + +.mdn-header .b-icon.bi, +.mdn-header .nav-link { color: var(--mdn-ink); } +.mdn-header .top-header-items { gap: 2px; align-items: center; } +.mdn-header .top-header-items .b-icon.bi { font-size: 20px; } + +@media (min-width: 768px) { + .mdn-header .top-header-items .nav-link { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + padding: 0; + border-radius: var(--mdn-r-pill); + transition: background-color var(--mdn-dur) var(--mdn-ease); + } + .mdn-header .top-header-items .nav-link:hover { background: var(--mdn-surface); } + .mdn-header .top-header-items .qty-indicator { top: 2px; right: 0; } +} + +/* counters on cart / wishlist / compare (Default positions them) */ +.mdn-header .qty-indicator, +[dark-theme="true"] .mdn-header .qty-indicator { + min-width: 17px; + width: auto; + height: 17px; + padding: 0 4px; + background: var(--mdn-accent); + border-radius: var(--mdn-r-pill); + color: var(--mdn-on-accent); + font-family: var(--mdn-mono); + font-size: 10px; + font-weight: 600; +} + +/* dark-mode switch as a plain icon button */ +.mdn-header .contrast-switch { display: inline-flex; align-items: center; margin: 0; padding: 0; min-height: 0; } +.mdn-header .contrast-switch .form-check-input { position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0; } +.mdn-header .contrast-switch .form-check-label { cursor: pointer; line-height: 1; } +.mdn-header .contrast-switch .dark { display: none; } +[data-bs-theme=dark] .mdn-header .contrast-switch .dark { display: inline; } +[data-bs-theme=dark] .mdn-header .contrast-switch .light { display: none; } +.mdn-header .contrast-switch .form-check-input:focus-visible + .form-check-label { + outline: 2px solid var(--mdn-accent); + outline-offset: 6px; + border-radius: var(--mdn-r-pill); +} + +/* ---------- search box, typed into in the bar (Components/SearchBox, no modal) ---------- */ + +.mdn-search { + position: relative; + flex: 1 1 auto; + max-width: 560px; + margin-inline: auto; +} +.mdn-search .modal-close { display: none; } +.mdn-search .input-group, +[dark-theme="true"] .mdn-search .input-group { + flex-wrap: nowrap; + align-items: center; + height: 44px; + padding: 0 4px 0 18px; + background: var(--mdn-surface); + border: 1px solid transparent; + border-radius: var(--mdn-r-pill); + transition: border-color var(--mdn-dur) var(--mdn-ease), background-color var(--mdn-dur) var(--mdn-ease), box-shadow var(--mdn-dur) var(--mdn-ease); +} +.mdn-search .input-group:focus-within { + background: var(--mdn-bg); + border-color: var(--mdn-accent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--mdn-accent) 18%, transparent); +} +.mdn-search .search-box-text, +[dark-theme="true"] .mdn-search .search-box-text, +.mdn-search .search-box-text:focus { + min-height: 0; + height: 100%; + padding: 0; + background: transparent; + border: 0; + box-shadow: none; + color: var(--mdn-ink); + font-size: 15px; +} +.mdn-search .search-box-text::placeholder { color: var(--mdn-muted-text); } +.mdn-search .search-box-select, +[dark-theme="true"] .mdn-search .search-box-select { + flex: 0 0 auto; + width: auto; + max-width: 150px; + min-height: 0; + height: 32px; + margin: 0 4px; + padding-block: 0; + background-color: var(--mdn-bg); + border: 0; + border-radius: var(--mdn-r-pill); + font-size: 13px; +} +.mdn-search .input-group-append { display: flex; align-items: center; } +.mdn-search .search-box-button, +.mdn-search #voiceSearchButton { + width: 36px; + height: 36px; + padding: 0; + justify-content: center; + border-radius: var(--mdn-r-pill); + color: var(--mdn-ink); + font-size: 17px; +} +/* Default sizes every header icon at 24px (.header-nav .b-icon.bi) */ +.mdn-search .search-box-button .b-icon.bi, +.mdn-search #voiceSearchButton .b-icon.bi { font-size: 17px; } +.mdn-search .search-box-button:hover { background: var(--mdn-surface-2); } +.mdn-search #small-searchterms + .field-validation-error { top: 50px; left: 18px; } + +/* suggestions: a card under the box. search-box.js clears them only when the text is emptied + (the modal used to hide them), so they close when the box has neither focus nor the pointer. + :hover as well as :focus-within, because Safari does not focus a clicked link. */ +.mdn-search .advanced-search-results, +[dark-theme="true"] .mdn-search .advanced-search-results { + top: calc(100% + 8px); + left: 0; + right: auto; + z-index: 1040; + width: min(760px, calc(100vw - 24px)); + max-height: min(70vh, 640px); + padding: 16px; + background: var(--mdn-bg); + border: 1px solid var(--mdn-line); + border-radius: var(--mdn-r-card); + box-shadow: var(--mdn-shadow-lift); + color: var(--mdn-ink); +} +.mdn-search:not(:focus-within):not(:hover) .advanced-search-results { display: none; } +.mdn-search .advanced-search-results .title { color: var(--mdn-muted-text); font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; } +.mdn-search .advanced-search-results .title:not(:first-child), +.mdn-search .advanced-search-results .products .list .media:not(:last-child) { border-color: var(--mdn-line); } +.mdn-search .advanced-search-results .media { display: flex; gap: 12px; } +.mdn-search .advanced-search-results .media-aside { flex: 0 0 56px; } +.mdn-search .advanced-search-results .media img { width: 56px; height: 56px; object-fit: cover; background: var(--mdn-surface); border-radius: var(--mdn-r-input); } +.mdn-search .advanced-search-results .price { color: var(--mdn-ink); font-family: var(--mdn-mono); } +.mdn-search .advanced-search-results p { color: var(--mdn-muted-text); } + +/* ---------- inline menu (desktop) ---------- */ + +.mdn-menu { + display: flex; + flex-wrap: wrap; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} +.mdn-menu > li { position: relative; flex: none; } + +.mdn-menu > li > a, +.mdn-menu > li > div { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-radius: var(--mdn-r-pill); + color: var(--mdn-ink); + font-size: 14px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; + cursor: pointer; + transition: background-color var(--mdn-dur) var(--mdn-ease); +} +.mdn-menu > li > a:hover, +.mdn-menu > li:hover > div, +.mdn-menu > li:focus-within > div { background: var(--mdn-surface); color: var(--mdn-ink); } + +.mdn-menu > li.-hasSubmenu > a::after, +.mdn-menu > li.-hasSubmenu > div::after { + content: ""; + width: 6px; + height: 6px; + margin-top: -3px; + border-right: 1.5px solid currentColor; + border-bottom: 1.5px solid currentColor; + transform: rotate(45deg); + opacity: .6; +} + +.mdn-menu .category-icon { display: none; } +.mdn-menu .badge { font-size: 10px; } + +/* dropdowns: hidden by opacity rather than display, so the links stay reachable with the + keyboard and :focus-within can open the panel */ +.mdn-menu > li > ul { + position: absolute; + top: 100%; + left: 0; + z-index: 1036; + min-width: 240px; + /* subcategories with children expand in place, and brands list every brand: the panel + hangs off a sticky header, so anything below the viewport would be out of reach */ + max-height: calc(100vh - 160px); + overflow-y: auto; + overscroll-behavior: contain; + margin: 0; + padding: 8px; + list-style: none; + background: var(--mdn-bg); + border: 1px solid var(--mdn-line); + border-radius: var(--mdn-r-card); + box-shadow: var(--mdn-shadow-lift); + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity var(--mdn-dur) var(--mdn-ease), transform var(--mdn-dur) var(--mdn-ease); +} +/* the right-most panels open leftwards so they stay inside the viewport */ +.mdn-menu > li:nth-last-child(-n+2) > ul { left: auto; right: 0; } +.mdn-menu > li:hover > ul, +.mdn-menu > li:focus-within > ul { opacity: 1; pointer-events: auto; transform: translateY(0); } + +.mdn-menu ul ul { margin: 0; padding: 0 0 4px 12px; list-style: none; } +.mdn-menu li.back { display: none; } +.mdn-menu ul a, +.mdn-menu ul li > div { + display: block; + padding: 8px 12px; + border-radius: var(--mdn-r-input); + color: var(--mdn-ink); + font-size: 14px; + text-decoration: none; + white-space: nowrap; +} +.mdn-menu ul li > div { color: var(--mdn-muted-text); font-size: 12px; font-weight: 600; } +.mdn-menu ul a:hover, +.mdn-menu ul a:focus-visible { background: var(--mdn-surface); color: var(--mdn-ink); } +.mdn-menu .sub-cat-name-container a, +.mdn-menu .sub-cat-name-container a.text-info { color: var(--mdn-muted-text) !important; font-weight: 600; } /* beats the !important .text-info utility */ + +/* the "Show " link Default paints with the !important .bg-info utility */ +.b-sidebar .Menu a.bg-info, +.mdn-menu a.bg-info { + background-color: var(--mdn-surface) !important; + color: var(--mdn-ink); + font-weight: 600; +} +.b-sidebar .Menu a.bg-info:hover, +.mdn-menu a.bg-info:hover { + background-color: var(--mdn-ink) !important; + color: var(--mdn-on-ink); +} + +/* ---------- drawer menu (phones, tablets) ---------- */ + +.b-sidebar .Menu li > a, +.b-sidebar .Menu li > div, +[dark-theme="true"] .Menu li > a, +[dark-theme="true"] .Menu li > div { color: var(--mdn-ink); } + +.b-sidebar .Menu li, +.b-sidebar .Menu ul, +[dark-theme="true"] .Menu li:not(.category), +[dark-theme="true"] .Menu li.category, +[dark-theme="true"] .Menu li .back, +[dark-theme="true"] .Menu ul { background-color: var(--mdn-bg); } + +.b-sidebar .Menu li:hover, +[dark-theme="true"] .Menu li.category:hover, +[dark-theme="true"] .Menu li:not(.category):hover { background-color: var(--mdn-surface); } + +/* ---------- responsive ---------- */ + +@media (min-width: 992px) { + .mdn-header .menu-container { display: none; } +} + +@media (max-width: 991.98px) { + .mdn-header .navbar { padding-bottom: 10px; } + .mdn-header .mdn-menu-row { display: none; } + /* phones and tablets: hamburger | logo | icons, then the search box full width */ + .mdn-header #header-container { flex-wrap: wrap; column-gap: 8px; row-gap: 10px; } + .mdn-header .menu-container { order: 0; } + .mdn-header .store-logo { order: 1; margin-inline: auto; } + .mdn-header .store-logo img { max-height: 36px; } + .mdn-header .top-header-items { order: 2; margin-left: 0; } + .mdn-header .mdn-search { order: 3; flex: 0 0 100%; max-width: none; margin: 0; } +} + +@media (max-width: 767.98px) { + /* backdrop-filter makes the header the containing block of its position:fixed + descendants, which would pull the Default bottom tab bar up into the header */ + .mdn-header, + [dark-theme="true"] .mdn-header { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--mdn-bg); + } + /* the Default phone tab bar, in the palette */ + .mdn-header .top-header-items.navbar-nav, + [dark-theme="true"] .mdn-header .top-header-items.navbar-nav { + background: color-mix(in srgb, var(--mdn-bg) 88%, transparent); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + border-top: 1px solid var(--mdn-line); + box-shadow: none; + } + .mdn-header .top-header-items .b-icon.bi { font-size: 22px; } +} + +/* anchor jumps and scrollIntoView land below the sticky header instead of under it */ +html { scroll-padding-top: 150px; } +@media (max-width: 991.98px) { + html { scroll-padding-top: 72px; } +} diff --git a/src/Plugins/Theme.Modern/Content/css/header/header.css b/src/Plugins/Theme.Modern/Content/css/header/header.css deleted file mode 100644 index 3958dfd0cf..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/header/header.css +++ /dev/null @@ -1,373 +0,0 @@ -.b-icon { - outline: none; -} -.header-nav { - padding: 0 20px 0 0; - width: 100%; - background-color: #fff; - z-index: 1040; -} -.header-nav-container::before { - content: ""; - position: relative; - margin-bottom: 57px; -} -.header-nav .navbar { - padding: 0; -} -.header-nav .b-icon.bi { - font-size: 20px; -} -.dropdown-toggle::after { - display: none; -} -.navbar-nav { - align-items: center; -} -.navbar-nav.top-header-items .nav-link { - position: relative; - display: flex; - justify-content: center; - align-items: center; - color: #000; - cursor: pointer; -} -.navbar-nav.top-header-items .nav-link:hover { - color: #25b232; -} -.navbar-nav.top-header-items .nav-link { - position: relative; - display: flex; - justify-content: center; - align-items: center; - color: #000; - cursor: pointer; - height: 56px; - padding: 0 15px 0 0; - font-size: 28px; - border-right: 0; -} -.qty-indicator { - position: absolute; - top: 0px; - right: 0; - display: flex; - justify-content: center; - align-items: center; - margin: 0; - width: 24px; - height: 24px; - color: #fff; - font-size: 12px; - font-weight: 500; - background: #25b232; - border-radius: 100%; -} -.shopping-item .subtotal { - position: relative; - display: inline-flex; - align-items: center; - color: #fff; - font-size: 16px; - font-weight: 700; -} -.shopping-item .subtotal::before { - content: ""; - display: flex; - width: 1px; - height: 20px; - margin: 0 6px; - background: #333; -} -.navbar-nav.top-header-items .search-item .nav-link { - position: static; -} -.b-sidebar > .b-sidebar-header { - font-size: 16px; - text-align: right; - background: #1c1b1b; - color: #fff; -} -.b-sidebar-header strong { - font-weight: 300; -} -.b-sidebar-header strong a { - color: #fff; - font-weight: 500; -} -.b-sidebar.slide { - -webkit-transition: transform 1s cubic-bezier(.19,1,.22,1); - transition: transform 1s cubic-bezier(.19,1,.22,1); -} -.b-sidebar-body { - padding: 15px; -} -.b-sidebar-body::-webkit-scrollbar-track { - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,0.3); - border-radius: 4px; - background-color: #232323; -} -.b-sidebar-body::-webkit-scrollbar { - position: absolute; - width: 8px; - background-color: #232323; -} -.b-sidebar-body::-webkit-scrollbar-thumb { - border-radius: 4px; - -webkit-box-shadow: inset 0 0 4px rgba(0,0,0,.3); - background-color: #111; -} -.b-sidebar>.b-sidebar-footer { - border-top: 1px solid rgba(0,0,0,.2); - background: #1c1b1b; - color: #fff; -} - -/* top header info */ - -.swiper-info { - width: calc(100% - 200px); -} -.swiper-info .top-header-info { - width: 100%; - padding: 5px 20px; - text-align: center; -} -.swiper-info .swiper-slide { - width: 100%; -} -.swiper-info .top-header-info strong { - color: #000; -} -.swiper-info .top-header-info span { - color: #25b232; - font-weight: 500; -} - -/* header-nav-bottom */ - -.header-nav-bottom { - display: inline-flex; - align-items: center; - width: 100%; - margin: 10px 0; - justify-content: flex-end; -} - -/* selectors */ - -.navbar-nav.selectors { - flex-wrap: nowrap; -} -.navbar-nav.selectors .btn.dropdown-toggle { - margin: 0 0 3px 0; - padding: 3px 6px; - background-color: #fff; - color: #000; - border: 1px solid rgba(0,0,0,.2); - font-size: 12px; - font-weight: 400; -} - -/* sidebar-cart */ - -.sidebar-cart { - padding-left: 0; -} - -.sidebar-cart .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 { - margin: 0; -} -.sidebar-cart .item .attributes { - font-size: 12px; - color: #000; - background: #eaf0fa; - display: table; - margin: 5px 0; -} -.sidebar-cart .item .deletecartitem { - position: absolute; - top: 5px; - right: 5px; - cursor: pointer; -} -.sidebar-cart .item .col a { - color: #000; - font-family: Poppins, sans-serif; - font-size: 14px; - font-weight: 400; - text-decoration: none; -} -.sidebar-cart .item .col .price, .sidebar-cart .item .col .quantity { - font-size: 14px; - font-weight: 500; -} -.sidebar-cart .item .col .price strong, .sidebar-cart .item .col .quantity strong { - margin: 0 0 0 5px; - font-size: 18px; - font-weight: 700; -} -.sidebar-cart .b-sidebar-footer .h5 { - font-weight: 300; -} -.sidebar-cart .b-sidebar-footer .h4 { - font-weight: 700; -} -.sidebar-cart .sub-tot-top { - font-weight: 300; -} -.sidebar-cart .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 { - padding: 12px; - font-size: 16px; - font-weight: 700; - background-color: #111; - border-color: #111; - border-width: 2px; -} -.sidebar-cart .cart-button.btn-secondary:hover { - background-color: #000; -} -.sidebar-cart .no-items-icon { - font-size: 80px; -} - -/* header promo */ - -.header-promo { - margin: 0 auto; -} -.header-promo li a { - font-size: 16px; - font-weight: 700; - padding: 6px 20px; - border-bottom: 1px solid rgba(0,0,0,.2); -} - -/* menu toogler */ - -#sidebar-menu .b-sidebar-body { - padding: 0; -} -@media (min-width: 992px) and (max-width: 1399px) { - .contact-header .content .number, .contact-header .content .title { - font-size: 12px; - } -} -@media (min-width: 768px) { - .top-header-items { - display: inline-flex; - flex-direction: row; - margin-left: 15px; - } -} -@media (min-width: 576px) { - .b-sidebar { - width: 530px; - } -} -@media (max-width: 1399px) { - .contact-header { - max-width: calc(100% - 10px); - } -} -@media (max-width: 1199px) { - .header-nav { - width: 75%; - } -} -@media (max-width: 991px) { - .swiper-info { - display: none; - } - .navbar-nav { - flex-direction: row; - } - .Menu li > div.contact-header { - width: auto; - margin: 10px; - } - .header-nav-container { - border-bottom: 1px solid #333; - } - .header-nav-container .header-bottom { - 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; - } - .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 { - max-width: 75px; - } - .sidebar-cart .item .col a { - display: table; - max-width: calc(100% - 15px); - font-size: 14px; - line-height: 1; - } - .sidebar-cart .item .price, - .sidebar-cart .item .quantity { - font-size: 14px; - } - .sidebar-cart .item .attributes { - font-size: 12px; - } - .sidebar-cart .b-sidebar-footer .h4 { - font-size: 20px; - } - .sidebar-cart .b-sidebar-footer .h5 { - font-size: 16px; - } - .shopping-item .subtotal { - display: none; - } -} -@media (max-width: 380px) { - .header-nav .navbar-brand img { - max-width: 120px; - } -} diff --git a/src/Plugins/Theme.Modern/Content/css/home.css b/src/Plugins/Theme.Modern/Content/css/home.css new file mode 100644 index 0000000000..f769073af5 --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/home.css @@ -0,0 +1,166 @@ +/* Modern - home: hero carousel, section heads, category tiles, product rows, brands, blog and news. */ + +.home-page { gap: 64px; padding-top: 16px; } + +/* ---------- hero (Widgets.Slider: a Bootstrap carousel with its own scrim and white copy) ---------- */ + +.home-page #GrandCarousel { + overflow: hidden; + background: #16161a; /* behind the slide photo while it loads */ + border-radius: var(--mdn-r-block); +} +.home-page #GrandCarousel .carousel-item img { width: 100%; height: clamp(360px, 38vw, 540px); object-fit: cover; } + +/* the slide copy sits in .slideRow > .col-lg-6, centred by Bootstrap utilities; left-align it */ +.home-page #GrandCarousel .carousel-caption .slideRow > [class*="col-"] { + align-items: flex-start !important; /* beats .align-items-center */ + padding-left: clamp(24px, 6vw, 88px); + text-align: left; +} +.home-page #GrandCarousel .carousel-item .animate-top { + padding: 0 0 12px; + font-size: 13px; + font-weight: 500; + letter-spacing: .08em; + text-transform: uppercase; + opacity: .85; +} +.home-page #GrandCarousel .carousel-item .animate-center-title { + max-width: 12ch; + font-size: clamp(34px, 4.6vw, 64px); + font-weight: 600; + letter-spacing: -.035em; + line-height: 1.02; +} +.home-page #GrandCarousel .carousel-item .animate-center-content { + max-width: 36ch; + padding: 14px 0 0; + font-size: clamp(15px, 1.3vw, 18px); + font-weight: 400; + line-height: 1.5; + opacity: .9; +} +.home-page #GrandCarousel .carousel-item .animate-bottom.btn { + width: auto; + margin: 24px 0 0; + padding: 12px 24px; + font-size: 15px; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.home-page #GrandCarousel .carousel-control-prev, +.home-page #GrandCarousel .carousel-control-next { display: none; } + +.home-page #GrandCarousel .carousel-indicators { + left: clamp(24px, 6vw, 88px); + right: auto; + bottom: 24px; + margin: 0; + gap: 6px; + justify-content: flex-start; +} +.home-page #GrandCarousel .carousel-indicators [data-bs-slide-to] { + width: 24px; + height: 3px; + margin: 0; + border: 0; + border-radius: var(--mdn-r-pill); + background-color: #fff; + opacity: .35; + transition: width var(--mdn-dur) var(--mdn-ease), opacity var(--mdn-dur) var(--mdn-ease); +} +.home-page #GrandCarousel .carousel-indicators .active { width: 40px; opacity: 1; } + +/* ---------- section heads ---------- */ + +.home-page .generalTitle { + margin: 0 0 20px !important; /* beats .mb-3 */ + font-size: clamp(22px, 2.2vw, 28px); + text-align: left !important; /* beats Default's .text-center / .text-sm-center utilities */ +} + +/* HomePageText page block */ +.home-page > .generalMarginSupporter .page-block-body { max-width: 720px; padding-top: 0 !important; color: var(--mdn-muted-text); font-size: 17px; } /* beats .pt-3 */ + +/* ---------- category, brand and collection tiles (Default .categoryGrid .picture-card) ---------- */ + +.home-page .categoryGrid .row { row-gap: 16px; --bs-gutter-x: 16px; } +.home-page .categoryGrid .picture-card, +[dark-theme="true"] .home-page .categoryGrid .picture-card { + position: relative; + height: 100%; + margin: 0 !important; /* beats Default's .mb-2 */ + overflow: hidden; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); + transition: transform var(--mdn-dur) var(--mdn-ease), box-shadow var(--mdn-dur) var(--mdn-ease); +} +.home-page .categoryGrid .picture-card:hover { transform: translateY(-2px); box-shadow: var(--mdn-shadow-lift); } +.home-page .categoryGrid .picture-card > a::before { padding-top: 75%; } +.home-page .categoryGrid .picture-card a img { transition: transform .4s var(--mdn-ease); } +.home-page .categoryGrid .picture-card:hover a img { transform: scale(1.03); } +.home-page .categoryGrid .picture-card .title { + margin: 0; + padding: 14px 16px 16px; + font-size: 16px; + font-weight: 600; + letter-spacing: -.015em; +} +.home-page .categoryGrid .picture-card .title a, +[dark-theme="true"] .home-page .categoryGrid .picture-card .title a { color: var(--mdn-ink); } + +/* ---------- product rows: 4-up grid on desktop, a scroll-snap row on phones ---------- */ + +@media (min-width: 992px) { + .home-page .product-grid > .col-12 > .row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 32px 20px; + margin: 0; + } + .home-page .product-grid > .col-12 > .row > [class*="col-"] { width: auto; max-width: none; padding: 0; } +} + +@media (max-width: 767.98px) { + .home-page .product-grid > .col-12 > .row { + flex-wrap: nowrap; + overflow-x: auto; + scroll-snap-type: x mandatory; + scroll-padding-inline: 12px; + margin-inline: -12px; + padding: 0 12px 8px; + gap: 12px; + scrollbar-width: none; + } + .home-page .product-grid > .col-12 > .row::-webkit-scrollbar { display: none; } + .home-page .product-grid > .col-12 > .row > [class*="col-"] { flex: 0 0 68%; max-width: 68%; padding: 0; scroll-snap-align: start; } +} + +/* ---------- blog and news cards ---------- */ + +.home-page .blog-list-homepage .card, +.home-page .news-list-homepage .card, +[dark-theme="true"] .home-page .blog-list-homepage .card, +[dark-theme="true"] .home-page .news-list-homepage .card { + height: 100%; + overflow: hidden; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); +} +.home-page .blog-list-homepage .card-img-top, +.home-page .news-list-homepage .card-img-top { aspect-ratio: 16 / 10; object-fit: cover; border-radius: 0; } +.home-page .blog-list-homepage .card-header, +.home-page .news-list-homepage .card-header { padding: 16px 16px 0; border: 0; background: transparent; } +.home-page .blog-list-homepage .card-header .h5, +.home-page .news-list-homepage .card-header .h5 { font-size: 17px; font-weight: 600; letter-spacing: -.015em; } +.home-page .blog-list-homepage .card-body, +.home-page .news-list-homepage .card-body { padding: 8px 16px; color: var(--mdn-muted-text); } +.home-page .blog-list-homepage .card-footer, +.home-page .news-list-homepage .card-footer { padding: 0 16px 16px; border: 0; background: transparent; } +.home-page .date-text, +[dark-theme="true"] .home-page .date-text { color: var(--mdn-muted-text); font-family: var(--mdn-mono); font-size: 12px; } +.home-page .view-all { text-align: left !important; } /* beats .text-center */ diff --git a/src/Plugins/Theme.Modern/Content/css/home/home.css b/src/Plugins/Theme.Modern/Content/css/home/home.css deleted file mode 100644 index 6cb9b4feba..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/home/home.css +++ /dev/null @@ -1,858 +0,0 @@ -/* home sections */ - -.generalTitle { - margin: 15px 0; -} -[v-cloak] .products-tabs { - display: none; -} -.home-page .home-page-section { - margin: 30px 0; -} - -/* slide product */ - -.main-slider.row { - position: relative; - height: calc(100vh - 195px); - min-height: 550px; - background-color: #eaf0fa; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.main-slider.row .swiper-container { - height: 100%; -} -.main-slider .swiper-slide.swiper-slide-active { - z-index: 2; -} -.slider-background { - position: absolute; - top: 0; - right: 0; - left: 0; - bottom: 0; - background-size: contain; - background-repeat: no-repeat; - opacity: 0.05; -} -.product-box-slide { - display: inline-flex; - align-items: center; - justify-content: space-between; - flex-wrap: nowrap; - width: 100%; - height: 100%; - padding: 0 5%; - -webkit-transition: all .4s ease-in-out; - transition: all .4s ease-in-out; -} -.product-box-slide .content { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - height: 100%; - position: relative; -} -.product-box-slide .attr-container tr { - display: flex; - flex-wrap: wrap; - justify-content: flex-start; -} -.product-box-slide .attr-container tr td .attr-name { - color: #aaa; - font-size: 16px; - font-weight: 300; -} -.product-box-slide .attr-container tr td:first-child { - width: 100%; - margin: 0 0 5px 0; -} -.product-box-slide .attr-container tr td .btn-light.btn { - border: 1px solid #eee; - color: #000; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.product-box-slide .attr-container tr td .btn-light.btn:hover { - -webkit-transform: scale(1.1); - transform: scale(1.1); -} -.product-box-slide .sale-label, .product-box-slide .flag-label { - position: absolute; - top: 0; - left: 0; - width: 34px; - writing-mode: vertical-lr; - z-index: 3; -} -.product-box-slide .sale-label + .flag-label { - left: 45px; -} -.product-box-slide .sale-label span, .product-box-slide .flag-label span { - position: relative; - width: 100%; - height: 100%; - padding: 15px 0; - display: flex; - align-items: center; - justify-content: center; - background: #25b232; - color: #fff; - font-size: 22px; - font-weight: 300; -} -.product-box-slide .sale-label span::after, .product-box-slide .flag-label span::after { - content: ""; - position: absolute; - top: 100%; - right: 0; - border-top: 0; - border-left: 17px solid #25b232; - border-right: 17px solid #25b232; - border-bottom: 8px solid transparent; -} -.product-box-slide .flag-label span.danger { - background: #dc3545; -} -.product-box-slide .flag-label span.danger::after { - border-left-color: #dc3545; - border-right-color: #dc3545; -} -.product-box-slide .flag-label span.purple { - background: #563d7c; -} -.product-box-slide .flag-label span.purple::after { - border-left-color: #563d7c; - border-right-color: #563d7c; -} -.product-box-slide .content .title { - z-index: 3; -} -.product-box-slide .content .title a { - color: #1c1b1b; - font-size: 48px; - line-height: 1; - font-weight: 900; -} -.product-box-slide .content .desc { - color: #555; - font-size: 14px; - font-weight: 400; - letter-spacing: 1px; - margin: 15px 0 15px 0; - max-height: 60px; - overflow: hidden; -} -.product-box-slide .spec-container table { - min-width: 220px; -} -.product-box-slide .spec-container table tr td { - padding: 10px; - border: 1px dashed #333; -} -.product-box-slide .spec-container table tr td .spec-name { - color: #aaa; - font-size: 16px; - font-weight: 300; -} -.product-box-slide .spec-container table tr td .spec-value { - color: #ddd; - font-size: 16px; - font-weight: 500; -} -.product-box-slide .color-container .color { - width: 32px; - height: 32px; -} -.product-box-slide .prices { - display: inline-flex; - align-items: flex-end; - flex-wrap: wrap; - margin: 30px 0; - font-family: 'Poppins', sans-serif; -} -.product-box-slide .prices .actual-price { - color: #25b232; - font-size: 48px; - font-weight: 700; - line-height: 1; -} -.product-box-slide .prices .old-price { - color: #999; - font-size: 20px; - text-decoration: line-through; -} -.product-box-slide .button .add-to-cart-button.btn.btn-outline-secondary { - padding: 15px 35px; - border-width: 2px; - text-transform: uppercase; - font-weight: 700; - color: #fff; - background-color: #1c1b1b; - border-color: #1c1b1b; -} -.product-box-slide .button .add-to-cart-button.btn.btn-outline-secondary:hover { - background-color: #25b232; - border: 2px solid #25b232; -} -.product-box-slide .picture-outer { - position: relative; - height: 100%; - display: flex; - align-items: center; - justify-content: center; -} -.product-box-slide .picture-outer img { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - width: auto; - height: auto; - max-width: 100%; - background-color: transparent; - visibility: visible; - opacity: 1; - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; -} -.product-box-slide .picture-outer .hover-img { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - margin: auto; - opacity: 0; - visibility: hidden; -} -.product-box-slide .picture-outer:hover .hover-img { - opacity: 1; - visibility: visible; -} -.product-box-slide .picture-outer:hover .hover-img + .main-product-img { - opacity: 0; - visibility: hidden; -} -.product-box-slide .img-bg { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - background-size: cover; - background-repeat: no-repeat; - background-position: center center; - /* opacity: .2; */ - z-index: -1; -} -.product-box-slide .more-circle-container { - position: absolute; - bottom: 5%; - right: 30px; - z-index: 3; -} -.product-box-slide .more-circle { - position: absolute; - bottom: 0; - right: 0; - display: flex; - justify-content: center; - align-items: center; - width: 90px; - height: 90px; - background-color: #25b232; - border-radius: 100%; -} -.product-box-slide .more-circle::before { - content: ""; - position: absolute; - top: -3px; - left: -3px; - right: -3px; - bottom: -3px; - box-shadow: 0 0 0 3px #000; - border-radius: 100%; - z-index: -1; - -webkit-transition: all .3s ease-in-out; - transition: all .3s ease-in-out; -} -.product-box-slide .more-circle:hover::before { - box-shadow: 0 0 0 8px #000; -} -.product-box-slide .more-circle a { - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; - color: #fff; - backface-visibility: hidden; - line-height: 1; -} -.product-box-slide .more-circle a span:first-child { - font-size: 22px; - font-weight: 700; -} -.product-box-slide .more-circle a span:last-child { - font-size: 16px; - font-weight: 300; -} - -/* products tabs */ - -.products-tabs .nav-tabs { - position: relative; - justify-content: flex-start; - border: 0; -} -.products-tabs .nav-tabs li:first-child::before { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 1px; - background: rgba(0,0,0,.1); - z-index: -1; -} -.products-tabs .nav-tabs li:first-child::after { - /* content: ""; */ - position: absolute; - bottom: 0; - right: 0px; - width: 50%; - height: 1px; - background: linear-gradient(to right, #555, transparent); - z-index: -1; -} -.products-tabs .nav-tabs .nav-link { - position: relative; - margin: 0 -1px 1px 0; - padding: 16px 26px; - font-family: 'Poppins', sans-serif; - color: #000; - background-color: transparent; - font-size: 14px; - font-weight: 700; - text-transform: uppercase; - border: 1px solid rgba(0,0,0,.1); - border-radius: 0; - border-bottom: 0; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.products-tabs .nav-tabs .nav-link.active { - color: #fff; - background-color: transparent; - border-color: #25b232; -} -.products-tabs .nav-tabs .nav-link::after { - content: ""; - position: absolute; - bottom: 0; - left: -1px; - width: calc(100% + 2px); - height: 100%; - -webkit-transition: transform .3s cubic-bezier(.8,0,.2,1); - transition: transform .3s cubic-bezier(.8,0,.2,1); - -webkit-transform: scale3d(0,1,1); - transform: scale3d(0,1,1); - transform-origin: 100% 50%; - border-top: 0; - background-color: #25b232; - z-index: -1; -} -.products-tabs .nav-tabs .nav-link.active::after { - -webkit-transform: scale3d(1,1,1); - transform: scale3d(1,1,1); - transform-origin: 0% 50%; -} -.products-tabs-content { - margin: 15px 0; -} -.products-tabs-content .section { - padding: 0; -} - -/* brands */ - - .brands-container { - padding: 0 10%; - margin: 0 -15px; - background-color: #fff; -} - .brands-container .swiper-wrapper { - align-items: center; - padding: 0 15%; -} - .brands-container .swiper-slide { - padding: 30px; - text-align: center; -} - .brands-container .swiper-slide a img { - width: auto; - height: auto; - max-width: 100%; - backface-visibility: hidden; - filter: opacity(0.5); - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} - .brands-container .swiper-slide a:hover img { - filter: opacity(1); -} - -/* parallax banner */ - -.parallax-banner { - position: relative; - width: calc(100% + 30px); - margin: 30px -15px; - height: 400px; - display: inline-flex; - align-items: center; - justify-content: space-between; - padding: 10%; - background: #eaf0fa; - overflow: hidden; -} -.parallax-banner .content { - justify-content: center; - align-items: center; -} -.parallax-banner::before { - content: ""; - position: absolute; - top: -50%; - left: -10%; - width: 50%; - height: 200%; - border: 15px solid #000; - background: #fff; - border-radius: 50%; - z-index: -1; -} -.parallax-banner .content, .parallax-banner .content .title { - display: flex; - flex-direction: column; -} -.parallax-banner .content .title { - margin: 0 0 15px 0; -} -.parallax-banner .content .title .up { - font-size: 60px; - font-weight: 900; - line-height: 1; -} -.parallax-banner .content .title .down { - color: #25b232; - font-size: 20px; - font-weight: 700; -} -.parallax-banner .content .promo { - display: inline-flex; - align-items: center; - 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"; -} -.parallax-banner .content .promo .number { - font-size: 80px; - font-weight: 700; - line-height: 1; -} -.parallax-banner .content .promo .off { - display: flex; - flex-direction: column; - line-height: 1; -} -.parallax-banner .content .promo .off div:first-child { - font-size: 31px; - font-weight: 700; - line-height: 1; -} -.parallax-banner .content .promo .off div:last-child { - font-size: 36px; - font-weight: 300; -} -.parallax-banner img { - position: absolute; - right: 0; - left: unset !important; - top: unset !important; - bottom: unset; - max-width: 600px; -} -.parallax-banner .img-container { - position: absolute; - top: 0; - right: 0; - display: flex; - align-items: center; - height: 400px; - -webkit-transition: all 1s ease-in-out; - transition: all 1s ease-in-out; -} -.parallax-banner:hover .img-container { - -webkit-transform: scale(1.1); - transform: scale(1.1); -} - -/* banners */ - -.home-page-banner { - position: relative; - top: -118px; - padding: 15px 7.5px; - background: #fff; - z-index: 15; -} -.home-page-banner + * { - margin-top: -118px; -} -.banner-col { - padding: 0 7.5px; -} -.banner-content { - display: flex; - position: relative; - cursor: pointer; -} -.banner-content .text { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - display: flex; - flex-direction: column; - justify-content: center; - align-items: flex-start; - padding: 20px; - transition: 0.5s; - -webkit-transition: 0.5s; -} -.banner-content .text .title { - font-size: 18px; - font-weight: 500; - line-height: 20px; - margin: 0 0 5px 0; -} -.banner-content .text .content { - color: #a2a2a2; - font-size: 14px; - font-weight: 500; -} -.banner-content img { - max-width: 100%; - width: auto; - height: auto; -} -.banner-content .text::before, .banner-content .text::after { - width: calc(100% - 20px); - height: calc(100% - 20px); - z-index: 3; - content: ''; - position: absolute; - top: 10px; - left: 10px; - box-sizing: border-box; - -webkit-transform: scale(0); - transition: 0.5s; - -webkit-transition: 0.5s; -} -.banner-content .text::before { - border-bottom: 2px solid #ddd; - border-left: 2px solid #ddd; - -webkit-transform-origin: 0 100%; -} -.banner-content .text::after { - border-top: 2px solid #ddd; - border-right: 2px solid #ddd; - -webkit-transform-origin: 100% 0%; -} -.banner-content .text:hover::after, .banner-content .text:hover::before { - -webkit-transform: scale(1); - transform: scale(1); -} - -/* home page section */ - -.home-page-section:first-of-type { - margin: 30px 0 30px 0; -} -.home-page-section .b-skeleton { - border-radius: 4px; -} - -/* home page categories */ - -.cat-gallery { - display: grid; - grid-template-columns: 40% 30% 30%; - grid-gap: 10px; - width: 100%; - overflow: hidden; -} -.cat-gallery .gallery-img { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} -.cat-gallery .gallery-item { - position: relative; -} -.cat-gallery .gallery-item h3 { - position: absolute; - bottom: 10px; - left: 10px; - margin: 0; - z-index: 2; -} -.cat-gallery .gallery-item:nth-child(4n-3) { - grid-column: 1; - grid-row: 1/3; -} -.cat-gallery .gallery-item:nth-child(4n-2) { - grid-column: 2; - grid-row: 1; -} -.cat-gallery .gallery-item:nth-child(4n-1) { - grid-column: 2; - grid-row: 2; -} -.cat-gallery .gallery-item:nth-child(4n) { - grid-column: 3; - grid-row: 1/3; -} - -/* category item */ - -.category-item .title { - display: block; - position: relative; - height: 100%; - margin: 10px 0 0 0; - font-size: 16px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.5px; -} -.category-item::before { - content: ""; - display: block; - padding-top: 70%; -} -.category-item img { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - max-width: 100%; - margin: auto 0 40px 0; -} - - -/* category manufacturer featured products */ - -.featured-product-grid { - position: relative; -} -@media (min-width: 992px) { - .main-slider.row { - margin-top: -15px; - margin-left: calc((-100vw + 950px) / 2); - margin-right: calc((-100vw + 950px) / 2); - } - .product-box-slide { - padding-left: calc((100% - 950px) / 2 + 237.5px); - padding-right: 0; - } - .home-page.toggled .product-box-slide { - padding-left: calc((100% - 985px) / 2); - padding-right: calc((100% - 985px) / 2); - } -} -@media (min-width: 1200px) { - .main-slider.row { - margin-top: -15px; - margin-left: calc((-100vw + 1130px) / 2); - margin-right: calc((-100vw + 1130px) / 2); - } - .product-box-slide { - padding-left: calc((100% - 1130px) / 2 + 282.5px); - padding-right: 0; - } - .home-page.toggled .product-box-slide { - padding-left: calc((100% - 1165px) / 2); - padding-right: calc((100% - 1165px) / 2); - } -} -@media (min-width: 1399px) { - .main-slider.row { - margin-top: -15px; - margin-left: calc((-100vw + 1330px) / 2); - margin-right: calc((-100vw + 1330px) / 2); - } - .product-box-slide { - padding-left: calc((100% - 1330px) / 2 + 332.5px); - padding-right: 0; - } - .home-page.toggled .product-box-slide { - padding-left: calc((100% - 1365px) / 2); - padding-right: calc((100% - 1365px) / 2); - } -} -@media (min-width: 1680px) { - .main-slider.row { - margin-top: -15px; - margin-left: calc((-100vw + 1430px) / 2); - margin-right: calc((-100vw + 1430px) / 2); - } - .product-box-slide { - padding-left: calc((100% - 1430px) / 2 + 357.5px); - padding-right: 0; - } - .home-page.toggled .product-box-slide { - padding-left: calc((100% - 1465px) / 2); - padding-right: calc((100% - 1465px) / 2); - } -} -@media (max-width: 1399px) { - .product-box-slide .spec-container table tr td { - padding: 5px; - } - .product-box-slide .content .title a { - font-size: 36px; - } - .product-box-slide .content .desc { - max-height: 50px; - } - .product-box-slide .prices { - margin: 15px 0; - } - .product-box-slide .prices .actual-price { - font-size: 34px; - } -} -@media (max-width: 991px) { - .home-page-section { - overflow: hidden; - } - .category-container:nth-child(odd) .category-image { - order: 1; - margin: 0 0 15px 0; - } - .category-container:nth-child(odd) .category-product { - order: 2 - } - .home-page-banner + * { - margin-top: 0; - } - #GrandCarousel .carousel-indicators { - bottom: 0; - } - .section { - padding: 15px 0; - } - .gallery-item h3 { - font-size: 20px; - } -} -@media (max-width: 575px) { - .home-page .home-page-section { - margin: 15px 0; - } - .main-slider .animate__animated { - animation: none; - } - #GrandCarousel .h1 { - font-size: 24px; - } - #GrandCarousel .h5 { - font-size: 16px; - } - #GrandCarousel .btn { - padding: 5px 10px; - font-size: 14px; - } - .cat-gallery { - display: inline-flex; - overflow-x: auto; - overflow-y: hidden; - } - .cat-gallery .gallery-item { - -ms-flex: 0 0 50%; - flex: 0 0 50%; - max-width: 50%; - } - .featured-product-grid .category-container .category-product { - order: 2; - } - .featured-product-grid .category-container .category-image { - order: 1; - margin: 0 0 15px 0; - } - .products-tabs .nav-tabs { - justify-content: center; - width: 100%; - flex-wrap: wrap; - white-space: nowrap; - } - .products-tabs .nav-tabs .nav-link { - padding: 10px 14px; - font-size: 16px; - } - .generalTitle span::before { - top: 18px; - } - .product-box-slide { - padding: 0; - } - .product-box-slide .picture-outer { - height: 300px; - text-align: center; - } - .product-box-slide .picture-outer img { - max-height: unset; - max-width: 300px; - } - .product-box-slide .content { - padding: 0 0 15px 0; - align-items: center; - } - .product-box-slide .sale-label, .product-box-slide .flag-label { - position: fixed; - top: 15px; - left: 15px; - } - .product-box-slide .sale-label + .flag-label { - left: 55px; - } - .product-box-slide .content .sale-label + .flag-label + .title { - width: 100%; - justify-content: center; - text-align: center; - } - .product-box-slide .content .title a { - font-size: 26px; - } - .product-box-slide .content .desc { - display: none; - } - .product-box-slide .prices { - margin: 25px 0 15px 0; - } - .main-slider.row .swiper-container-horizontal > .swiper-pagination-bullets { - bottom: 15px; - } - .product-box-slide .button .add-to-cart-button.btn.btn-outline-secondary { - margin: 0 0 40px 0; - } -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/product.css b/src/Plugins/Theme.Modern/Content/css/product.css new file mode 100644 index 0000000000..f34ada862b --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/product.css @@ -0,0 +1,159 @@ +/* Modern - product page: gallery, sticky buy panel, attributes, tabs, reviews. */ + +.product-details-page { padding-top: 8px; } + +/* ---------- gallery ---------- */ + +.product-details-page .gallery img.image.main-image, +[dark-theme="true"] .product-details-page .gallery img.image.main-image { + width: 100%; + aspect-ratio: 1 / 1; + /* contain, not cover: this is where the customer must see the whole product */ + object-fit: contain; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-block); +} + +.product-details-page .gallery img.image.thumb-image, +[dark-theme="true"] .product-details-page .gallery img.image.thumb-image { + width: 72px; + height: 72px; + object-fit: cover; + background: var(--mdn-surface); + border: 2px solid transparent; + border-radius: var(--mdn-r-input); + cursor: pointer; + transition: border-color var(--mdn-dur) var(--mdn-ease); +} +.product-details-page .gallery img.image.thumb-image:hover, +.product-details-page .gallery img.image.thumb-image:focus-visible { border-color: var(--mdn-ink); } + +/* ---------- buy panel ---------- */ + +@media (min-width: 768px) { + .product-details-page .overview { + position: sticky; + top: 140px; /* under the two-row sticky header */ + align-self: flex-start; + padding-left: clamp(16px, 3vw, 48px) !important; /* beats Default's .ps-md-3 */ + } +} + +.product-details-page .overview .generalTitle { + margin: 4px 0 8px; + font-size: clamp(26px, 2.8vw, 38px); + letter-spacing: -.03em; +} + +.product-details-page .overview .product-label { position: static; margin-bottom: 8px; } + +.product-details-page .short-description { color: var(--mdn-muted-text); font-size: 16px; line-height: 1.55; } + +.product-details-page .overview .label, +[dark-theme="true"] .product-details-page .overview .label { color: var(--mdn-muted-text); font-weight: 400; } +.product-details-page .overview .value { color: var(--mdn-ink); } + +.product-details-page .overview .prices { margin-inline: 0; padding: 0 !important; align-items: flex-start !important; } /* beats .px-3 / .justify-content-center; .row's negative margins pushed the price out of line */ +.product-details-page .overview .actual-price, +[dark-theme="true"] .product-details-page .overview .actual-price { + color: var(--mdn-ink); + font-family: var(--mdn-mono); + font-size: clamp(26px, 2.4vw, 32px); + font-weight: 600; + letter-spacing: -.02em; +} +.product-details-page .overview .old-price, +.product-details-page .overview .old-product-price { color: var(--mdn-muted-text); font-family: var(--mdn-mono); text-decoration: line-through; } + +/* attributes: a surface block instead of Default's bordered table */ +.attributes, +[dark-theme="true"] .attributes { + padding: 8px 16px; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-card); +} +.attributes .table > :not(caption) > * > * { padding: 10px 0; background: transparent; border-color: var(--mdn-line); } +.attributes .attribute-name, +[dark-theme="true"] .attributes .attribute-name { color: var(--mdn-ink); font-size: 15px; font-weight: 600; } + +/* radio and checkbox options as pills; colour squares keep Default's swatches */ +.attributes .option-list:not(.color-squares) .form-check { display: inline-block; margin: 0 8px 8px 0; padding: 0; } +.attributes .option-list:not(.color-squares) .form-check-input { position: absolute; opacity: 0; } +.attributes .option-list:not(.color-squares) .form-check-label { + display: inline-flex; + align-items: center; + min-height: 40px; + padding: 0 16px; + background: var(--mdn-bg); + border: 1px solid var(--mdn-line); + border-radius: var(--mdn-r-pill); + cursor: pointer; + transition: border-color var(--mdn-dur) var(--mdn-ease), background-color var(--mdn-dur) var(--mdn-ease); +} +.attributes .option-list:not(.color-squares) .form-check-input:checked + .form-check-label, +[dark-theme="true"] .attributes .option-list:not(.color-squares) .form-check-input:checked + .form-check-label { + background: var(--mdn-ink); + border-color: var(--mdn-ink); + color: var(--mdn-on-ink); +} +.attributes .option-list:not(.color-squares) .form-check-input:focus-visible + .form-check-label { outline: 2px solid var(--mdn-accent); outline-offset: 2px; } +.attributes .color-container .color { border-radius: var(--mdn-r-pill); box-shadow: inset 0 0 0 1px var(--mdn-line); } /* a white swatch disappears on the surface without it */ + +/* quantity + add to cart */ +.product-details-page .product-add-to-cart { gap: 8px; } +.product-details-page .product-add-to-cart .qty-input, +.product-details-page .product-add-to-cart .qty-dropdown { flex: 0 0 88px; width: 88px; min-height: 52px; text-align: center; } +/* Bootstrap squares the inner corners at (0,6,0): .input-group:not(.has-validation) > :not(:last-child)... */ +.product-details-page .product-add-to-cart.input-group > .form-control:not(:last-child):not(.dropdown-toggle) { border-radius: var(--mdn-r-pill); } +.product-details-page .add-to-cart-button { min-height: 52px; justify-content: center; align-items: center; border-radius: var(--mdn-r-pill); font-size: 16px; } + +.product-details-page .overview .btn-group { gap: 8px; } +/* above Bootstrap's .btn-group > .btn:not(:last-child):not(.dropdown-toggle) (0,4,0) */ +.product-details-page .overview .btn-group > .btn:not(.btn-check):not(.dropdown-toggle) { flex: 1; min-height: 44px; border-radius: var(--mdn-r-pill); } + +/* ---------- tabs as a segmented control ---------- */ + +.product-details-page .nav-tabs, +[dark-theme="true"] .product-details-page .nav-tabs { + justify-content: flex-start !important; /* beats the .justify-content-center utility gn-tabs align="center" adds */ + gap: 4px; + width: max-content; + max-width: 100%; + padding: 4px; + overflow-x: auto; + background: var(--mdn-surface); + border: 0; + border-radius: var(--mdn-r-pill); +} +.product-details-page .nav-tabs .nav-link, +[dark-theme="true"] .product-details-page .nav-tabs .nav-link { + padding: 8px 16px; + background: transparent; + border: 0; + border-radius: var(--mdn-r-pill); + color: var(--mdn-muted-text); + font-size: 15px; + font-weight: 500; + white-space: nowrap; +} +.product-details-page .nav-tabs .nav-link::before { display: none; } +.product-details-page .nav-tabs .nav-link:hover:not(.active), +[dark-theme="true"] .product-details-page .nav-tabs .nav-link:hover:not(.active) { background: transparent; color: var(--mdn-ink); } +.product-details-page .nav-tabs .nav-link.active, +[dark-theme="true"] .product-details-page .nav-tabs .nav-link.active { + background: var(--mdn-bg); + box-shadow: var(--mdn-shadow); + color: var(--mdn-ink); +} +.product-details-page .tab-content { padding-top: 24px; line-height: 1.6; } + +/* ---------- reviews ---------- */ + +.product-review-item.card, +[dark-theme="true"] .product-review-item.card { background: var(--mdn-surface); border-radius: var(--mdn-r-card); } +.product-review-item .card-header { border: 0; font-weight: 600; } + +/* related / also-purchased rows: left-aligned heads like the home sections */ +.linked-product-grid > h2.generalTitle { margin-bottom: 20px; font-size: clamp(22px, 2.2vw, 28px); text-align: left !important; } /* beats .text-center */ diff --git a/src/Plugins/Theme.Modern/Content/css/product/product.css b/src/Plugins/Theme.Modern/Content/css/product/product.css deleted file mode 100644 index e94221e74a..0000000000 --- a/src/Plugins/Theme.Modern/Content/css/product/product.css +++ /dev/null @@ -1,442 +0,0 @@ -/* product gallery */ - -.gallery { - position: sticky; - position: -webkit-sticky; - top: 15px; -} -.gallery-slider { - display: inline-flex; - flex-wrap: nowrap; - width: 100%; - height: 600px; -} -.gallery-slider img { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - max-width: 100%; - max-height: 100%; - margin: auto; - pointer-events: none; -} -.gallery-top { - height: 100%; - width: 75%; - background-color: #eaf0fa; -} -.gallery-thumbs { - width: 25%; - height: 100%; - padding: 0 15px 0 0; - box-sizing: border-box; -} -.gallery-thumbs .swiper-slide { - width: 100%; - height: 25%; - opacity: 0.4; - background-color: #eaf0fa; - border: 1px solid rgba(0,0,0,.1); - box-shadow: none; - cursor: pointer; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} - -.gallery-thumbs .swiper-slide-active, .gallery-thumbs .swiper-slide:hover { - opacity: 1; - border-color: #25b232; - box-shadow: 0 6px 10px rgba(0,0,0,.3), 0 15px 12px rgba(0,0,0,.22); -} -.gallery-top .swiper-button-prev::after { - border-left: 0; -} -.gallery-top .swiper-button-next::after { - border-right: 0; -} - -/* gallery zoom */ - -.medium-zoom-overlay { - z-index: 1100; -} -.medium-zoom-image--opened { - z-index: 1101; -} -.medium-zoom-image { - background-color: #eaf0fa; -} -.zoom-button { - position: absolute; - top: 15px; - left: 15px; - width: 50px; - height: 50px; - background: #232323; - border: 1px solid #333; - border-radius: .25rem; - display: flex; - justify-content: center; - align-items: center; - color: #fff; - font-size: 32px; - opacity: 0; - visibility: hidden; - cursor: pointer; - -webkit-transition: all .2s ease-in-out; - transition: all .2s ease-in-out; -} -.zoom-button:hover { - border-color: #25b232; -} -.swiper-slide:hover .zoom-button { - opacity: 1; - visibility: visible; -} - -/* product overview */ - -.product-details-page .product-label { - position: absolute; - top: 0; - right: 0; - z-index: 1033; -} -.product-details-page .generalTitle { - margin: 0; - font-size: 32px; - font-weight: 900; -} -.product-details-page .product-label + .generalTitle { - max-width: calc(100% - 43px) -} -.product-details-page .product-label .badge { - padding: 6px 8px; -} -.product-details-page .overview .short-description { - color: #777; - font-size: 14px; -} -.product-details-page .overview .label { - color: #000; - font-size: 14px; - font-weight: 500; -} -.product-details-page .overview .value { - margin: 0 0 0 5px; - color: #25b232; - font-size: 16px; - font-weight: 700; -} - -/* spec */ - -.spec-container .spec-name { - color: #000; - font-size: 16px; - font-weight: 700; -} -.spec-container .spec-value { - color: #000; - font-size: 16px; - font-weight: 500; -} - -/* product attributes */ - -.attributes { - margin: 0 0 15px 0; - padding: 15px; - border: 0; - background-color: #eaf0fa; - border-radius: .25rem; -} -.attributes table { - margin: 0; - border: 0; -} -.attributes table td { - border-top: 0; -} -.attributes table tr:first-child td { - border-top: 0; - vertical-align: middle; - padding: 10px; -} -.attributes .text-prompt { - color: #000; - font-size: 16px; - font-weight: 700; -} -.attributes .custom-control-description { - color: #000; - font-size: 14px; - font-weight: 400; -} -.image-square-tooltip { - display: flex; - align-items: flex-start; - justify-content: center; - flex-direction: column; -} -.image-square-tooltip img { - max-width: 165px; -} -.attributes .color-container .color { - width: 32px; - height: 32px; -} -.attributes input:checked ~ .color-container .color::before { - left: 19px; -} -.attributes .option-list { - display: inline-flex; - align-items: center; - flex-wrap: wrap; -} -.attributes .option-list li { - margin: 0 10px 3px 0; -} - -/* product price */ - -.product-details-page .overview .actual-price { - color: #25b232; - font-family: 'Poppins', sans-serif; - font-size: 36px; - font-weight: 700; - line-height: 1; -} -.product-details-page .overview .actual-price label { - color: #aaa; - 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: 16px; - font-weight: 500; -} -.product-details-page .overview .old-price { - padding: 0 0 11px 0; - color: #999; - font-family: 'Poppins', sans-serif; - font-size: 18px; - font-weight: 400; - line-height: 1; - text-decoration: line-through; -} - -/* product add to cart */ - -.product-details-page .qty-controls { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 60px; - background: #eaf0fa; -} -.product-details-page .qty-controls div { - display: flex; - justify-content: center; - align-items: center; - position: relative; - width: 30px; - height: 30px; - border: 1px solid rgba(0,0,0,.2); - border-right: 0; - cursor: pointer; -} -.product-details-page .qty-controls div.plus::before, .product-details-page .qty-controls div.plus::after, .product-details-page .qty-controls div.minus::before { - content: ""; - position: absolute; - top: 13px; - left: 9px; - width: 12px; - height: 2px; - display: flex; - justify-content: center; - background: #000; -} -.product-details-page .qty-controls div.plus:hover::before, .product-details-page .qty-controls div.plus:hover::after, .product-details-page .qty-controls div.minus:hover::before { - background: #25b232; -} -.product-details-page .qty-controls div.plus::after { - transform: rotate( 90deg ); - -webkit-transform: rotate( 90deg ); -} -.product-details-page .qty-controls div:last-child { - border-top: 0; -} -.product-add-to-cart .qty-input, .product-add-to-cart .qty-dropdown { - max-width: 65px; - height: 60px; - color: #000; - font-size: 18px; - font-weight: 700; - background-color: #eaf0fa; - border-color: rgba(0,0,0,.2); -} -.product-details-page .add-to-cart-button.btn-info { - display: flex; - align-items: center; - justify-content: center; - font-family: 'Poppins', sans-serif; - font-size: 16px; - font-weight: 700; - text-transform: uppercase; - background-color: #25b232; - border: 0; -} -.product-details-page .add-to-cart-button.btn-info:focus, .product-details-page .add-to-cart-button.btn-info:active, .product-details-page .add-to-cart-button.btn-info:not(:disabled):not(.disabled):active:focus { - background-color: #dda128; - box-shadow: 0 0 0 0.2rem rgba(181, 126, 16, .5); -} -.product-other-buttons { - display: flex; - flex-direction: column; - flex-wrap: wrap; - align-items: flex-start; - margin: 15px 0; -} -.product-other-buttons .btn.btn-outline-secondary, -.product-other-buttons .btn.btn-outline-secondary:active, -.product-other-buttons .btn.btn-outline-secondary:active:focus { - display: flex; - align-items: center; - padding: 5px 0; - font-family: Poppins, sans-serif; - border: 0; - background-color: transparent; - color: #aaa; - box-shadow: none; -} -.product-other-buttons .btn.btn-outline-secondary:hover { - color: #25b232; -} -.product-other-buttons .btn.btn-outline-secondary .text { - margin: 0 0 0 10px; - font-size: 16px; - font-weight: 400; - line-height: 1; -} - -/* product tabs */ - -.product-details-page .nav-tabs { - position: relative; - justify-content: center; - border: 0; -} -.product-details-page .nav-tabs li:first-child::before { - content: ""; - position: absolute; - bottom: 0; - left: 0; - width: 50%; - height: 1px; - background: rgba(0,0,0,.1); - z-index: 0; -} -.product-details-page .nav-tabs li:first-child::after { - content: ""; - position: absolute; - bottom: 0; - right: 0; - width: 50%; - height: 1px; - background: rgba(0,0,0,.1); - z-index: 0; -} -.product-details-page .nav-tabs .nav-link { - position: relative; - margin: 0 -1px 2px 0; - padding: 16px 26px; - font-size: 14px; - text-transform: uppercase; - font-weight: 700; - font-family: Poppins, sans-serif; - color: #000; - background-color: transparent; - border: 1px solid rgba(0,0,0,.1); - border-bottom: 0; - outline: none; - border-radius: 0; - -webkit-transition: all .2s ease; - transition: all .2s ease; -} -.product-details-page .nav-tabs .nav-link.active { - color: #fff; - background-color: #25b232; -} -.product-details-page .nav-tabs .nav-link:hover:not(.active) { - color: #fff; - background-color: #000; -} -.product-details-page .nav-tabs .nav-link::before { - content: ""; - position: absolute; - bottom: 0px; - left: 50%; - right: 50%; - width: 0; - height: 100%; - background: #333; - -webkit-transition: all 0.2s ease-out; - transition: all 0.2s ease-out; - z-index: -1; -} -.product-details-page .nav-tabs .nav-link:hover::before { - left: 0; - right: 0; - width: 100%; -} -.product-details-page .nav-tabs .nav-link.active::before { - background: #25b232; - left: 0; - right: 0; - width: 100%; -} - -/* product collateral */ - -.product-collateral .add-to-cart-button { - max-width: 160px; -} -.product-collateral .add-to-wishlist-button { - width: 235px; -} - -/* quick view */ - -.modal-content .gallery-top { - width: 100%; -} - -@media (max-width: 767px) { - .product-details-page .gallery { - margin: 0 0 15px 0; - } - .product-details-page .generalTitle { - font-size: 28px; - } -} -@media (max-width: 575px) { - .product-details-page .gallery-slider { - height: 400px; - } - .product-details-page .generalTitle { - font-size: 24px; - } -} -@media (max-width: 380px) { - .product-details-page .generalTitle { - font-size: 22px; - margin: 0 0 10px 0; - } - .product-details-page .overview .short-description { - font-size: 14px; - } - .product-details-page .gallery-slider { - height: 320px; - } -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/css/tokens.css b/src/Plugins/Theme.Modern/Content/css/tokens.css new file mode 100644 index 0000000000..2cde23b49c --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/css/tokens.css @@ -0,0 +1,93 @@ +/* + Modern - design tokens. + + The palette lives in --mdn-*; Bootstrap's own variables are pointed at it for both + schemes, so every Default view the theme does not restyle still follows the palette. + The scheme is switched by the Default Partials/ColorScheme through data-bs-theme on . +*/ + +:root, +[data-bs-theme=light] { + --mdn-bg: #ffffff; + --mdn-surface: #f5f5f7; + --mdn-surface-2: #ececf0; + --mdn-line: #e8e8ec; + --mdn-ink: #0b0b0f; + --mdn-on-ink: #ffffff; + --mdn-muted-text: #5f6068; + --mdn-accent: #2160f5; /* #2563ff nudged darker: 4.75:1 on --mdn-surface */ + --mdn-on-accent: #ffffff; + --mdn-sale: #d92d33; + --mdn-on-sale: #ffffff; + --mdn-shadow: 0 1px 2px rgba(11, 11, 15, .04), 0 4px 16px rgba(11, 11, 15, .06); + --mdn-shadow-lift: 0 2px 4px rgba(11, 11, 15, .05), 0 12px 32px rgba(11, 11, 15, .10); +} + +[data-bs-theme=dark] { + --mdn-bg: #0a0a0c; + --mdn-surface: #16161a; + --mdn-surface-2: #1f1f24; + --mdn-line: #26262c; + --mdn-ink: #f4f4f6; + --mdn-on-ink: #0b0b0f; + --mdn-muted-text: #a1a1aa; + --mdn-accent: #5b8cff; + --mdn-on-accent: #0b0b0f; + --mdn-sale: #ff6369; + --mdn-on-sale: #0b0b0f; /* white on #ff6369 is only 2.9:1 */ + --mdn-shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 4px 16px rgba(0, 0, 0, .35); + --mdn-shadow-lift: 0 2px 4px rgba(0, 0, 0, .45), 0 12px 32px rgba(0, 0, 0, .5); +} + +:root, +[data-bs-theme=light], +[data-bs-theme=dark] { + --mdn-sans: "Geist", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --mdn-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --mdn-r-input: 10px; + --mdn-r-card: 16px; + --mdn-r-block: 24px; + --mdn-r-pill: 999px; + --mdn-ease: cubic-bezier(.2, .7, .2, 1); + --mdn-dur: .18s; + + --bs-body-bg: var(--mdn-bg); + --bs-body-bg-rgb: 255, 255, 255; + --bs-body-color: var(--mdn-ink); + --bs-body-color-rgb: 11, 11, 15; + --bs-body-font-family: var(--mdn-sans); + --bs-emphasis-color: var(--mdn-ink); + --bs-heading-color: var(--mdn-ink); + --bs-secondary-color: var(--mdn-muted-text); + --bs-tertiary-color: var(--mdn-muted-text); + --bs-secondary-bg: var(--mdn-surface); + --bs-tertiary-bg: var(--mdn-surface); + --bs-border-color: var(--mdn-line); + --bs-border-color-translucent: var(--mdn-line); + --bs-border-radius: var(--mdn-r-input); + --bs-border-radius-sm: 8px; + --bs-border-radius-lg: var(--mdn-r-card); + --bs-border-radius-xl: var(--mdn-r-block); + --bs-border-radius-pill: var(--mdn-r-pill); + --bs-link-color: var(--mdn-ink); + --bs-link-color-rgb: 11, 11, 15; + --bs-link-hover-color: var(--mdn-accent); + --bs-link-hover-color-rgb: 33, 96, 245; + --bs-primary: var(--mdn-accent); + --bs-primary-rgb: 33, 96, 245; + --bs-info: var(--mdn-accent); + --bs-info-rgb: 33, 96, 245; + --bs-dark: var(--mdn-ink); + --bs-light: var(--mdn-surface); + --bs-focus-ring-color: color-mix(in srgb, var(--mdn-accent) 35%, transparent); + --bs-box-shadow: var(--mdn-shadow); +} + +[data-bs-theme=dark] { + --bs-body-bg-rgb: 10, 10, 12; + --bs-body-color-rgb: 244, 244, 246; + --bs-link-color-rgb: 244, 244, 246; + --bs-link-hover-color-rgb: 91, 140, 255; + --bs-primary-rgb: 91, 140, 255; + --bs-info-rgb: 91, 140, 255; +} diff --git a/src/Plugins/Theme.Modern/Content/fonts/OFL-Geist.txt b/src/Plugins/Theme.Modern/Content/fonts/OFL-Geist.txt new file mode 100644 index 0000000000..61f6f4b785 --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/fonts/OFL-Geist.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/fonts/README.md b/src/Plugins/Theme.Modern/Content/fonts/README.md new file mode 100644 index 0000000000..bd4b0b5e7d --- /dev/null +++ b/src/Plugins/Theme.Modern/Content/fonts/README.md @@ -0,0 +1,22 @@ +# Fonts + +Geist and Geist Mono are licensed under the SIL Open Font License 1.1 (see `OFL-Geist.txt`). +They are served from this folder; the storefront makes no request to Google Fonts. + +The files are the `latin` and `latin-ext` subsets of the variable fonts served by Google Fonts, +downloaded once on 2026-09-27. `latin-ext` is included because `latin` lacks Polish and other +Central European letters; `fonts.css` gives each file its `unicode-range`, so a page only +downloads `latin-ext` when it contains such a character. + +| File | Axes | Source | +|---|---|---| +| `geist-latin-var.woff2` | wght 400–700 | https://fonts.gstatic.com/s/geist/v5/gyByhwUxId8gMEwcGFU.woff2 | +| `geist-latin-ext-var.woff2` | wght 400–700 | https://fonts.gstatic.com/s/geist/v5/gyByhwUxId8gMEwSGFWfOw.woff2 | +| `geist-mono-latin-var.woff2` | wght 400–600 | https://fonts.gstatic.com/s/geistmono/v6/or3nQ6H-1_WfwkMZI_qYFrcdmg.woff2 | +| `geist-mono-latin-ext-var.woff2` | wght 400–600 | https://fonts.gstatic.com/s/geistmono/v6/or3nQ6H-1_WfwkMZI_qYFrkdmgPn.woff2 | + +They were found through +`https://fonts.googleapis.com/css2?family=Geist:wght@400..700&family=Geist+Mono:wght@400..600&display=swap` +(requested with a Chrome User-Agent). + +Licence: https://github.com/google/fonts/blob/main/ofl/geist/OFL.txt diff --git a/src/Plugins/Theme.Modern/Content/fonts/geist-latin-ext-var.woff2 b/src/Plugins/Theme.Modern/Content/fonts/geist-latin-ext-var.woff2 new file mode 100644 index 0000000000..ba90e20949 Binary files /dev/null and b/src/Plugins/Theme.Modern/Content/fonts/geist-latin-ext-var.woff2 differ diff --git a/src/Plugins/Theme.Modern/Content/fonts/geist-latin-var.woff2 b/src/Plugins/Theme.Modern/Content/fonts/geist-latin-var.woff2 new file mode 100644 index 0000000000..991445d78a Binary files /dev/null and b/src/Plugins/Theme.Modern/Content/fonts/geist-latin-var.woff2 differ diff --git a/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-ext-var.woff2 b/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-ext-var.woff2 new file mode 100644 index 0000000000..ea22fd948e Binary files /dev/null and b/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-ext-var.woff2 differ diff --git a/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-var.woff2 b/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-var.woff2 new file mode 100644 index 0000000000..0750682c8d Binary files /dev/null and b/src/Plugins/Theme.Modern/Content/fonts/geist-mono-latin-var.woff2 differ diff --git a/src/Plugins/Theme.Modern/Content/images/facebook.svg b/src/Plugins/Theme.Modern/Content/images/facebook.svg deleted file mode 100644 index d775b2897c..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/facebook.svg +++ /dev/null @@ -1 +0,0 @@ -Facebook icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/instagram.svg b/src/Plugins/Theme.Modern/Content/images/instagram.svg deleted file mode 100644 index e0b8ffd7e2..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/instagram.svg +++ /dev/null @@ -1 +0,0 @@ -Instagram icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/linkedin.svg b/src/Plugins/Theme.Modern/Content/images/linkedin.svg deleted file mode 100644 index 3dc9f68a52..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/linkedin.svg +++ /dev/null @@ -1 +0,0 @@ -LinkedIn icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/pinterest.svg b/src/Plugins/Theme.Modern/Content/images/pinterest.svg deleted file mode 100644 index ad07407de9..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/pinterest.svg +++ /dev/null @@ -1 +0,0 @@ -Pinterest icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/rss.svg b/src/Plugins/Theme.Modern/Content/images/rss.svg deleted file mode 100644 index 1428e1a160..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/rss.svg +++ /dev/null @@ -1 +0,0 @@ -RSS icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/twitter.svg b/src/Plugins/Theme.Modern/Content/images/twitter.svg deleted file mode 100644 index af5b7c3419..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/twitter.svg +++ /dev/null @@ -1 +0,0 @@ -Twitter icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/images/youtube.svg b/src/Plugins/Theme.Modern/Content/images/youtube.svg deleted file mode 100644 index 6aca43ca57..0000000000 --- a/src/Plugins/Theme.Modern/Content/images/youtube.svg +++ /dev/null @@ -1 +0,0 @@ -YouTube icon \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/script/app.js b/src/Plugins/Theme.Modern/Content/script/app.js deleted file mode 100644 index 82a12dae04..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/app.js +++ /dev/null @@ -1,686 +0,0 @@ -"use strict"; - -Vue.use(VueAwesomeSwiper) -var vm = new Vue({ - el: '#app', - data: function () { - return { - show: false, - fluid: false, - hover: false, - darkMode: false, - active: false, - NextDropdownVisible: false, - value: 5, - searchitems: null, - searchcategories: null, - searchbrands: null, - searchblog: null, - searchproducts: null, - flycartfirstload: true, - PopupAddToCartVueModal: null, - PopupQuickViewVueModal: null, - menuToggled: false, - index: null, - RelatedProducts: null, - compareproducts: null, - compareProductsQty: 0, - loader: false, - flyMenuContainer: false, - topHeaderInfo: { - swiperOptions: { - slidesPerView: 1, - autoplay: { - delay: '3000' - }, - effect: 'flip', - flipEffect: { - slideShadows: false, - }, - loop: true, - spaceBetween: 10, - } - }, - QuickViewSlider: { - swiperOptions: { - slidesPerView: 1, - spaceBetween: 10, - navigation: { - nextEl: '#ModalQuickView .swiper-button-next', - prevEl: '#ModalQuickView .swiper-button-prev' - } - }, - }, - QuickViewSliderThumbs: { - swiperOptions: { - spaceBetween: 10, - slidesPerView: 5, - watchSlidesVisibility: true, - watchSlidesProgress: true, - slideToClickedSlide: true, - }, - }, - } - }, - props: { - flycart: null, - flycartitems: null, - flycartindicator: undefined, - flywish: null, - wishlistitems: null, - wishindicator: undefined, - UpdatedShoppingCartItemId: null, - }, - mounted: function () { - 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.updateCompareProductsQty(); - this.backToTop(); - if (991 < window.innerWidth) { - this.linksContainer(); - } - window.addEventListener('DOMContentLoaded', () => { - vm.$forceUpdate(); - }); - }, - created: function () { - if (location.pathname !== "/") { - this.menuToggled = false; - } - }, - watch: { - fluid: function (newName) { - localStorage.fluid = newName; - }, - darkMode: function (newValue) { - localStorage.darkMode = newValue; - }, - PopupQuickViewVueModal: function () { - vm.getLinkedProductsQV(vm.PopupQuickViewVueModal.Id); - } - }, - methods: { - openMenu(el, mainMenu) { - var menu = document.getElementById(mainMenu); - if (menu.classList.contains('show')) { - menu.classList.remove('show'); - el.classList.remove('show'); - } else { - menu.classList.add('show'); - el.classList.add('show'); - } - }, - linksContainer() { - var ol = document.querySelector('#mainMenu .other-links'); - var lc = document.getElementById('links-container'); - if (ol !== null) { - lc.prepend(ol) - } - }, - menuButton: function () { - this.menuToggled = !this.menuToggled; - }, - productImageSlide: function (event) { - var Imagesrc = event.target.parentElement.getAttribute('data-href'); - function collectionHas(a, b) { - for (var i = 0, len = a.length; i < len; i++) { - if (a[i] == b) return true; - } - return false; - } - function findParentBySelector(elm, selector) { - var all = document.querySelectorAll(selector); - var cur = elm.parentNode; - while (cur && !collectionHas(all, cur)) { - cur = cur.parentNode; - } - return cur; - } - - var yourElm = event.target - var selector = ".product-box-slide"; - var parent = findParentBySelector(yourElm, selector); - var Image = parent.querySelectorAll(".main-product-img")[0]; - Image.setAttribute('src', Imagesrc); - }, - slideTo: function (e) { - vm.$refs.QuickViewSlider.$swiper.slideTo(e.dataset.index); - }, - TopScroll: function () { - var body = document.body; - var scrollUp = "scroll-up"; - var scrollDown = "scroll-down"; - var onTop = "onTop"; - let lastScroll = 0; - - var currentScrollWindow = window.pageYOffset; - if (currentScrollWindow == 0) { - body.classList.add(onTop); - } - - window.addEventListener("scroll", function () { - var currentScroll = window.pageYOffset; - if (window.pageYOffset <= 10) { - body.classList.add(onTop); - } else { - body.classList.remove(onTop); - } - if (currentScroll > lastScroll && !body.classList.contains(scrollDown)) { - // down - body.classList.remove(scrollUp); - if (lastScroll != 0) { - body.classList.add(scrollDown); - return; - } else { - body.classList.remove(scrollDown); - } - } else if (currentScroll < lastScroll && body.classList.contains(scrollDown)) { - // up - body.classList.remove(scrollDown); - body.classList.add(scrollUp); - } - lastScroll = currentScroll; - }); - }, - toogleMenu: function () { - if (this.flyMenuContainer) { - this.flyMenuContainer = false; - } else { - this.flyMenuContainer = true; - } - }, - backToTop() { - if (!document.querySelector('.up-btn')) { - const upBtn = document.createElement('div'); - const upBtnContent = document.createElement('div'); - - upBtn.classList.add('up-btn', 'up-btn__hide'); - - function showBtn(num) { - if (document.documentElement.scrollTop >= num) { - upBtn.classList.remove('up-btn__hide'); - } else { - upBtn.classList.add('up-btn__hide'); - } - } - - document.body.append(upBtn); - upBtn.append(upBtnContent) - window.addEventListener('scroll', () => { - showBtn(400); - }); - - upBtn.addEventListener('click', () => { - window.scrollTo({ - top: 0, - behavior: "smooth" - }); - }); - } - }, - newsletterBox(AllowToUnsubscribe, url) { - let subscribe; - if (AllowToUnsubscribe) { - subscribe = this.$refs.newsletterSubscribe.checked - } else { - subscribe = true - } - var postData = { - subscribe: subscribe, - email: document.getElementById("newsletter-email").value - }; - axios({ - url: url, - params: postData, - method: 'post', - }).then(function (response) { - let result = response.data.Result; - let resultCategory = response.data.ResultCategory; - let showCategories = response.data.Showcategories; - let success = response.data.Success; - let variant; - - if (success) { - variant = "info"; - } else { - variant = "danger"; - } - - vm.$bvToast.toast(result, { - variant: variant, - autoHideDelay: 3500, - solid: true, - }); - - if (showCategories) { - vm.displayPopup(resultCategory, 'ModalNewsletterCategory'); - } - - }); - }, - newsletterSubscribeCategory(url) { - let form = document.getElementById('newsletter-category-method-form'); - let data = new FormData(form); - axios({ - url: url, - method: 'post', - data: data, - }).then(function (response) { - if (!response.data.Success) { - alert(response.data.Message); - } - }).catch(function (error) { - alert(error); - }) - }, - getPrivacyPreference(href) { - axios({ - url: href, - method: 'get', - }).then(function (response) { - vm.displayPopup(response.data.html, 'ModalPrivacyPreference') - }).catch(function (error) { - alert(error); - }); - }, - savePrivacyPreference(href) { - let form = document.getElementById('frmPrivacyPreference'); - let data = new FormData(form); - axios({ - url: href, - method: 'post', - data: data - }).catch(function (error) { - alert(error); - }); - }, - 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() - } - }, - mounted: function () { - var self = this; - self.template = Vue.compile(html).render; - this.darkMode = vm.darkMode; - }, - updated: function () { - this.showModal(); - } - }); - }, - displayBarNotification(message, url, messagetype, timeout) { - var variant; - - if (messagetype == 'error') { - variant = "danger"; - } else { - variant = "info"; - } - - this.$bvToast.toast(message, { - title: messagetype, - variant: variant, - href: url, - autoHideDelay: timeout, - solid: true - }) - }, - deletecartitem: function (href) { - axios({ - method: "post", - baseURL: href - }).then(function (response) { - const newfly = response.data.sidebarshoppingcartmodel; - vm.flycart = newfly; - vm.flycartitems = newfly.Items; - vm.flycartindicator = newfly.TotalProducts; - }).catch(function (error) { - alert(error); - }); - return false; - }, - updateCompareProductsQty: function () { - const cookie = AxiosCart.getCookie('Grand.CompareProduct'); - if (cookie !== '') { - const qty = cookie.split('|').filter(Boolean).length; - this.compareProductsQty = qty; - } else { - this.compareProductsQty = 0; - } - }, - updateSidebarShoppingCart: function (url) { - axios({ - baseURL: url, - method: 'get', - data: null, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(response => ( - this.flycart = response.data, - this.flycartitems = response.data.Items, - this.flycartindicator = response.data.TotalProducts, - this.flycartfirstload = false - )) - }, - updateWishlist: function (url) { - axios({ - baseURL: url, - method: 'get', - data: null, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(response => ( - this.loader = false, - this.flywish = response.data, - this.wishlistitems = response.data.Items, - this.wishindicator = response.data.Items.length - )) - }, - getCompareList: function (url) { - this.loader = true; - axios({ - baseURL: url, - method: 'get', - params: { - t: new Date().getTime() - }, - data: null, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(response => { - this.loader = false; - this.compareproducts = response.data - }) - }, - removeFromCompareList: function (product, index) { - if (product !== undefined) { - const compareList = AxiosCart.getCookie('Grand.CompareProduct'); - const newCompareList = compareList.replace(product, ''); - AxiosCart.setCookie('Grand.CompareProduct', newCompareList); - vm.compareproducts.Products.splice(index, 1); - } else { - AxiosCart.setCookie('Grand.CompareProduct', ''); - vm.compareproducts.Products.splice(0); - } - 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) { - for (var i = 0, len = a.length; i < len; i++) { - if (a[i] == b) return true; - } - return false; - } - function findParentBySelector(elm, selector) { - var all = document.querySelectorAll(selector); - var cur = elm.parentNode; - while (cur && !collectionHas(all, cur)) { - cur = cur.parentNode; - } - return cur; - } - - var yourElm = event.target - var selector = ".product-box"; - var parent = findParentBySelector(yourElm, selector); - var Image = parent.querySelectorAll(".main-product-img")[0]; - Image.setAttribute('src', Imagesrc); - }, - formSubmit() { - vm.$refs.form.submit(); - }, - formSubmitParam(e, observer) { - if (e && observer) { - observer.validate().then(success => { - if (!success) { - return - } else { - var submitter = e.target.querySelector('[type="submit"]'); - eval(submitter.dataset.form) - } - }); - } - }, - isMobile: function () { - return (typeof window.orientation !== "undefined") || (navigator.userAgent.indexOf('IEMobile') !== -1); - }, - attrchange: function (productId, loadPicture) { - var form = document.getElementById('product-details-form'); - var data = new FormData(form); - var pId; - - if (vm.PopupQuickViewVueModal.ProductBundleModels.length > 0) { - pId = vm.PopupQuickViewVueModal.Id; - } else { - pId = productId; - } - - axios({ - url: '/product/productdetails_attributechange?productId=' + pId + '&loadPicture=' + loadPicture, - data: data, - method: 'post', - params: { product: pId }, - }).then(function (response) { - if (vm.PopupQuickViewVueModal.ProductBundleModels.length > 0) { - if (response.data.price) { - if (vm.PopupQuickViewVueModal.ProductPrice.PriceWithDiscount != null) - vm.PopupQuickViewVueModal.ProductPrice.PriceWithDiscount = response.data.price; - else - vm.PopupQuickViewVueModal.ProductPrice.Price = response.data.price; - } - } else { - if (response.data.price) { - if (vm.PopupQuickViewVueModal.ProductType == 0) { - if (vm.PopupQuickViewVueModal.ProductPrice.PriceWithDiscount != null) - vm.PopupQuickViewVueModal.ProductPrice.PriceWithDiscount = response.data.price; - else - vm.PopupQuickViewVueModal.ProductPrice.Price = response.data.price; - } else { - vm.PopupQuickViewVueModal.AssociatedProducts.find(x => x.Id === pId).ProductPrice.Price = response.data.price; - } - } - if (response.data.sku) { - vm.PopupQuickViewVueModal.Sku = response.data.sku; - } - if (response.data.mpn) { - vm.PopupQuickViewVueModal.Mpn = response.data.mpn; - } - if (response.data.gtin) { - vm.PopupQuickViewVueModal.Gtin = response.data.gtin; - } - 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"; - } - } - 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"; - } - } - /*if (response.data.notAvailableAttributeMappingids) { - document.querySelectorAll('[data-disable]').forEach((element) => element.disabled = false); - for (var i = 0; i < response.data.notAvailableAttributeMappingids.length; i++) { - if (document.querySelectorAll("[data-disable='" + response.data.notAvailableAttributeMappingids[i] + "']").length > 0) { - document.querySelectorAll("[data-disable='" + response.data.notAvailableAttributeMappingids[i] + "']")[0].disabled = true; - } - } - }*/ - 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) - } - }) - } - } else { - vm.PopupQuickViewVueModal.DefaultPictureModel.ImageUrl = response.data.pictureDefaultSizeUrl; - } - } - } - }); - }, - uploadFile: function (e) { - var formData = new FormData(); - var imagefile = e; - var url = imagefile.getAttribute('data-url'); - formData.append("file", e.files[0]); - axios.post(url, formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } - }).then(function (response) { - if (response.data.success) { - var message = response.data.message; - var downloadGuid = response.data.downloadGuid; - var downloadUrl = response.data.downloadUrl; - var downloadBtn = document.querySelector('.download-file'); - var messageContainer = document.getElementById('download-message'); - - e.setAttribute('qq-button-id', downloadGuid); - document.querySelector('.hidden-upload-input').value = downloadGuid; - - messageContainer.style.display = "block"; - messageContainer.classList.remove('alert-danger'); - messageContainer.classList.add('alert-info'); - messageContainer.innerText = message; - - downloadBtn.style.display = "block"; - downloadBtn.children[0].setAttribute('href', downloadUrl); - - } else { - var message = response.data.message; - var messageContainer = document.getElementById('download-message'); - messageContainer.style.display = "block"; - messageContainer.classList.remove('alert-info'); - messageContainer.classList.add('alert-danger'); - messageContainer.innerText = message; - } - }) - }, - initReservationQV: function () { - if (vm.PopupQuickViewVueModal !== null && vm.PopupQuickViewVueModal.ProductType == 20) { - var productId = vm.PopupQuickViewVueModal.Id; - var fullDate = vm.PopupQuickViewVueModal.ReservationFullDate; - var year = vm.PopupQuickViewVueModal.ReservationYear; - var month = vm.PopupQuickViewVueModal.ReservationMonth; - Reservation.init(fullDate, year, month, "No available reservations", "/Product/GetDatesForMonth", productId, "/product/productdetails_attributechange?productId=" + productId); - } - }, - getLinkedProductsQV: function (id) { - axios({ - url: '/Product/RelatedProducts', - method: 'get', - params: { "productId": id }, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(function (response) { - vm.RelatedProducts = response.data; - }); - }, - warehouse_change_handler(id, url) { - var data = new FormData(); - data.append('warehouseId', document.getElementById('WarehouseId').value); - data.append('productId', id); - axios({ - url: url, - data: data, - method: 'post' - }).then(function (response) { - if (response.data.stockAvailability) { - vm.PopupQuickViewVueModal.StockAvailability = response.data.stockAvailability; - } - }) - }, - formatDate(date) { - var d = new Date(date), - month = '' + (d.getMonth() + 1), - day = '' + d.getDate(), - year = d.getFullYear(); - - if (month.length < 2) - month = '0' + month; - if (day.length < 2) - day = '0' + day; - - return [month, day, year].join('/'); - }, - QuickViewShown: function () { - if (vm.PopupQuickViewVueModal.ProductAttributes.length > 0) { - vm.attrchange(vm.PopupQuickViewVueModal.Id, true) - } else { - var bundleProducts = vm.PopupQuickViewVueModal.ProductBundleModels; - if (bundleProducts.length > 0) { - vm.attrchange(vm.PopupQuickViewVueModal.Id, true) - } - } - if (vm.PopupQuickViewVueModal.ProductType == 20) { - var StartDate; - var EndDate; - if (vm.PopupQuickViewVueModal.IntervalUnit == 10) { - if (vm.PopupQuickViewVueModal.RentalStartDateUtc !== null) { - StartDate = this.formatDate(vm.PopupQuickViewVueModal.RentalStartDateUtc); - vm.PopupQuickViewVueModal.RentalStartDateUtc = StartDate; - } - if (vm.PopupQuickViewVueModal.RentalEndDateUtc !== null) { - EndDate = this.formatDate(vm.PopupQuickViewVueModal.RentalEndDateUtc); - vm.PopupQuickViewVueModal.RentalEndDateUtc = EndDate; - } - } else { - if (vm.PopupQuickViewVueModal.RentalStartDateUtc !== null) { - vm.PopupQuickViewVueModal.RentalStartDateUtc = this.formatDate(vm.PopupQuickViewVueModal.RentalStartDateUtc); - } else { - vm.PopupQuickViewVueModal.RentalStartDateUtc = null; - } - } - } - }, - }, -}); diff --git a/src/Plugins/Theme.Modern/Content/script/catalog.js b/src/Plugins/Theme.Modern/Content/script/catalog.js deleted file mode 100644 index fe05192b8c..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/catalog.js +++ /dev/null @@ -1,75 +0,0 @@ -Vue.use(VueAwesomeSwiper) -var subcatslider = new Vue({ - data() { - return { - swiperOptions: { - effect: 'slide', - lazy: { - preloaderClass: 'preloader' - }, - autoplay: { - delay: 5000, - }, - slidesPerView: 2, - spaceBetween: 15, - breakpoints: { - 320: { - slidesPerView: 2, - }, - 576: { - slidesPerView: 2, - }, - 768: { - slidesPerView: 3, - }, - 992: { - slidesPerView: 3, - }, - 1200: { - slidesPerView: 3, - } - }, - - } - } - }, -}); -function sideToggle() { - var leftSide = document.querySelector(".generalLeftSide"); - if (leftSide.classList.contains('show')) { - leftSide.classList.remove('show'); - localStorage.setItem('leftSideOpen', 'false'); - } else { - leftSide.classList.add('show'); - localStorage.setItem('leftSideOpen', 'true'); - } - setTimeout(function () { - if (vm.$refs.SubCategories) { - vm.$refs.SubCategories.$swiper.update(); - } - }, 400); -} -document.addEventListener("DOMContentLoaded", function () { - document.querySelectorAll(".viewmode-icon").forEach(function (element) { - element.addEventListener("click", function (e) { - document.querySelectorAll('.viewmode-icon').forEach(function (el) { - el.classList.remove('selected'); - }); - element.classList.add('selected'); - }); - }); -}); - -function closeLeftSide() { - var leftSide = document.querySelector(".generalLeftSide"); - if (leftSide.classList.contains('show')) { - leftSide.classList.remove('show'); - localStorage.setItem('leftSideOpen', 'false'); - } - setTimeout(function () { - if (vm.$refs.SubCategories) { - vm.$refs.SubCategories.$swiper.update(); - } - }, 400); -} - diff --git a/src/Plugins/Theme.Modern/Content/script/home.js b/src/Plugins/Theme.Modern/Content/script/home.js deleted file mode 100644 index 4075d31efd..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/home.js +++ /dev/null @@ -1,394 +0,0 @@ -Vue.use(VueAwesomeSwiper) -var hpi = new Vue({ - props: { - HomePageItems: { - type: Array, - default: () => [ - { - HomePageCategories: { - items: [], - swiperOptions: { - lazy: true, - freeMode: true, - autoplay: { - delay: 5000, - }, - spaceBetween: 10, - breakpoints: { - 320: { - slidesPerView: 1, - }, - 576: { - slidesPerView: 2, - }, - 768: { - slidesPerView: 3, - }, - 992: { - slidesPerView: 3, - }, - 1200: { - slidesPerView: 3, - } - }, - }, - loading: true, - }, - HomePageBrands: { - items: [], - loading: true, - swiperOptions: { - loop: true, - autoplay: { - delay: 5000, - }, - breakpoints: { - 320: { - slidesPerView: 2, - }, - 576: { - slidesPerView: 2, - }, - 768: { - slidesPerView: 4, - }, - 992: { - slidesPerView: 4, - }, - 1200: { - slidesPerView: 5, - }, - 1400: { - slidesPerView: 6, - } - }, - }, - }, - HomePageProducts: { - items: [], - loading: true, - ProductsToShow: null, - NumberOfProducts: null, - }, - CategoryFeaturedProducts: { - items: [], - swiperOptions: { - loop: true, - lazy: true, - watchOverflow: true, - autoplay: { - delay: 5000, - }, - slidesPerView: 1, - slidesPerColumn: 3, - slidesPerColumnFill: 'row', - breakpoints: { - 320: { - slidesPerView: 2, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 575: { - slidesPerView: 2, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 768: { - slidesPerView: 3, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 992: { - slidesPerView: 4, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 1399: { - slidesPerView: 5, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 1600: { - slidesPerView: 6, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - }, - spaceBetween: 0, - lazy: { - preloaderClass: 'preloader', - } - }, - loading: true, - }, - CollectionFeaturedProducts: { - items: [], - swiperOptions: { - loop: true, - lazy: true, - watchOverflow: true, - autoplay: { - delay: 5000, - }, - slidesPerView: 1, - slidesPerColumn: 3, - slidesPerColumnFill: 'row', - breakpoints: { - 320: { - slidesPerView: 2, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 575: { - slidesPerView: 2, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 768: { - slidesPerView: 3, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 992: { - slidesPerView: 4, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 1399: { - slidesPerView: 5, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - 1600: { - slidesPerView: 6, - slidesPerColumn: 2, - slidesPerColumnFill: 'row', - }, - }, - spaceBetween: 0, - lazy: { - preloaderClass: 'preloader', - }, - navigation: { - nextEl: '#HomePageBestSellers .swiper-button-next', - prevEl: '#HomePageBestSellers .swiper-button-prev' - } - }, - loading: true, - }, - HomePageBestSellers: { - items: [], - loading: true, - ProductsToShow: null, - NumberOfProducts: null, - }, - CustomerRecommendedProducts: { - items: [], - loading: true, - ProductsToShow: null, - NumberOfProducts: null, - }, - PersonalizedProducts: { - items: [], - loading: true, - ProductsToShow: null, - NumberOfProducts: null, - }, - SuggestedProducts: { - items: [], - loading: true, - ProductsToShow: null, - NumberOfProducts: null, - }, - HomePageNews: { - items: [], - swiperOptions: { - lazy: { - preloaderClass: 'preloader' - }, - autoplay: { - delay: 5000, - }, - fadeEffect: { - crossFade: true - }, - slidesPerView: 1, - spaceBetween: 15, - navigation: { - nextEl: '#HomePageNews .swiper-button-next', - prevEl: '#HomePageNews .swiper-button-prev' - }, - breakpoints: { - 320: { - slidesPerView: 1 - }, - 575: { - slidesPerView: 2, - }, - 992: { - slidesPerView: 3, - } - } - }, - loading: true, - }, - HomePageBlog: { - items: [], - swiperOptions: { - lazy: { - preloaderClass: 'preloader' - }, - loop: true, - autoplay: { - delay: 5000, - }, - slidesPerView: 1, - spaceBetween: 15, - navigation: { - nextEl: '#HomePageBlog .swiper-button-next', - prevEl: '#HomePageBlog .swiper-button-prev' - }, - pagination: { - el: '#HomePageBlog .swiper-pagination', - type: 'bullets', - clickable: true, - }, - breakpoints: { - 320: { - slidesPerView: 1, - }, - 575: { - slidesPerView: 2, - }, - 768: { - slidesPerView: 3, - }, - 992: { - slidesPerView: 2, - }, - 1250: { - slidesPerView: 3, - }, - 1540: { - slidesPerView: 3, - }, - 1680: { - slidesPerView: 3, - }, - 1920: { - slidesPerView: 3, - }, - }, - }, - loading: true, - }, - } - ] - } - }, - methods: { - loadMore(event, NumberProducts, Products) { - var SectionName = event.target.closest(".section"); - var LoadedProducts = SectionName.querySelectorAll(".product-box").length; - - if (LoadedProducts >= NumberProducts) { - event.target.classList.add('disabled'); - event.target.innerText = event.target.getAttribute("data-nomore"); - } else { - if ((NumberProducts - LoadedProducts) >= 6) { - var showed = Products.ProductsToShow; - Products.ProductsToShow = showed + 6; - } else { - Products.ProductsToShow = Products.ProductsToShow + (NumberProducts - LoadedProducts); - event.target.classList.add('disabled'); - event.target.innerText = event.target.getAttribute("data-nomore"); - } - } - }, - groupBy(xs, key) { - return xs.reduce(function (rv, x) { - (rv[x[key]] = rv[x[key]] || []).push(x); - return rv; - }, {}); - } - } -}) - -function LoadStandard(section) { - axios({ - baseURL: '/Component/Form?Name=' + section + '', - method: 'get', - data: null, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(response => { - if (response.data != "") { - hpi.HomePageItems[0][section].items = response.data; - } - }).then(function () { - hpi.HomePageItems[0][section].loading = false; - }) -} -function LoadMore(section) { - axios({ - baseURL: '/Component/Form?Name=' + section + '', - method: 'get', - data: null, - headers: { - 'Accept': 'asplication/json', - 'Content-Type': 'asplication/json', - 'X-Response-View': 'Json' - } - }).then(response => { - if (response.data != "") { - hpi.HomePageItems[0][section].items = response.data; - hpi.HomePageItems[0][section].NumberOfProducts = response.data.length; - if (response.data.length < 6) { - hpi.HomePageItems[0][section].ProductsToShow = hpi.HomePageItems[0][section].NumberOfProducts; - } else { - hpi.HomePageItems[0][section].ProductsToShow = 6; - } - } - }).then(function () { - hpi.HomePageItems[0][section].loading = false; - }).then(function () { - hpi.HomePageItems[0][section].items.forEach(function (element) { - element.SpecificationAttributeModels = hpi.groupBy(element.SpecificationAttributeModels, 'SpecificationAttributeName') - }) - }); -} - -function LazyLoad() { - var hp_section = document.querySelectorAll('.home-page-section'); - var viewLine = window.scrollY + window.innerHeight; - for (var i = 0; i < hp_section.length; i++) { - var section_loaded = hp_section[i].dataset.isloaded; - if (hp_section[i].offsetTop - viewLine < 0 && section_loaded !== 'true') { - var hpsectionid = hp_section[i].dataset.id; - if (hp_section[i].dataset.load === 'standard') { - LoadStandard(hpsectionid) - } else { - LoadMore(hpsectionid) - } - hp_section[i].dataset.isloaded = 'true'; - } - } -} - -window.onload = function () { - LazyLoad(); -}; -document.addEventListener("DOMContentLoaded", function () { - window.addEventListener('scroll', LazyLoad); - - document.querySelectorAll('.parallax-banner').forEach(function (element) { - var scene = element; - var parallaxInstance = new Parallax(scene, { - relativeInput: true, - hoverOnly: true, - }); - }) -}); \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/script/medium-zoom.min.js b/src/Plugins/Theme.Modern/Content/script/medium-zoom.min.js deleted file mode 100644 index 0f94570b44..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/medium-zoom.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! medium-zoom 1.0.6 | MIT License | https://github.com/francoischalifour/medium-zoom */ -!function (e, t) { "object" == typeof exports && "undefined" != typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define(t) : (e = e || self).mediumZoom = t() }(this, (function () { "use strict"; var e = Object.assign || function (e) { for (var t = 1; t < arguments.length; t++) { var o = arguments[t]; for (var n in o) Object.prototype.hasOwnProperty.call(o, n) && (e[n] = o[n]) } return e }, t = function (e) { return "IMG" === e.tagName }, o = function (e) { return e && 1 === e.nodeType }, n = function (e) { return ".svg" === (e.currentSrc || e.src).substr(-4).toLowerCase() }, i = function (e) { try { return Array.isArray(e) ? e.filter(t) : function (e) { return NodeList.prototype.isPrototypeOf(e) }(e) ? [].slice.call(e).filter(t) : o(e) ? [e].filter(t) : "string" == typeof e ? [].slice.call(document.querySelectorAll(e)).filter(t) : [] } catch (e) { throw new TypeError("The provided selector is invalid.\nExpects a CSS selector, a Node element, a NodeList or an array.\nSee: https://github.com/francoischalifour/medium-zoom") } }, r = function (e) { var t = document.createElement("div"); return t.classList.add("medium-zoom-overlay"), t.style.background = e, t }, d = function (e) { var t = e.getBoundingClientRect(), o = t.top, n = t.left, i = t.width, r = t.height, d = e.cloneNode(), m = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0, a = window.pageXOffset || document.documentElement.scrollLeft || document.body.scrollLeft || 0; return d.removeAttribute("id"), d.style.position = "absolute", d.style.top = o + m + "px", d.style.left = n + a + "px", d.style.width = i + "px", d.style.height = r + "px", d.style.transform = "", d }, m = function (t, o) { var n = e({ bubbles: !1, cancelable: !1, detail: void 0 }, o); if ("function" == typeof window.CustomEvent) return new CustomEvent(t, n); var i = document.createEvent("CustomEvent"); return i.initCustomEvent(t, n.bubbles, n.cancelable, n.detail), i }; return function (e, t) { void 0 === t && (t = {}); var o = t.insertAt; if (e && "undefined" != typeof document) { var n = document.head || document.getElementsByTagName("head")[0], i = document.createElement("style"); i.type = "text/css", "top" === o && n.firstChild ? n.insertBefore(i, n.firstChild) : n.appendChild(i), i.styleSheet ? i.styleSheet.cssText = e : i.appendChild(document.createTextNode(e)) } }(".medium-zoom-overlay{position:fixed;top:0;right:0;bottom:0;left:0;opacity:0;transition:opacity .3s;will-change:opacity}.medium-zoom--opened .medium-zoom-overlay{cursor:pointer;cursor:zoom-out;opacity:1}.medium-zoom-image{cursor:pointer;cursor:zoom-in;transition:transform .3s cubic-bezier(.2,0,.2,1)!important}.medium-zoom-image--hidden{visibility:hidden}.medium-zoom-image--opened{position:relative;cursor:pointer;cursor:zoom-out;will-change:transform}"), function t(a) { var l = arguments.length > 1 && void 0 !== arguments[1] ? arguments[1] : {}, c = window.Promise || function (e) { function t() { } e(t, t) }, u = function (e) { var t = e.target; t !== N ? -1 !== O.indexOf(t) && w({ target: t }) : E() }, s = function () { if (!A && T.original) { var e = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; Math.abs(k - e) > S.scrollOffset && setTimeout(E, 150) } }, f = function (e) { var t = e.key || e.keyCode; "Escape" !== t && "Esc" !== t && 27 !== t || E() }, p = function () { var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}, n = t; if (t.background && (N.style.background = t.background), t.container && t.container instanceof Object && (n.container = e({}, S.container, t.container)), t.template) { var i = o(t.template) ? t.template : document.querySelector(t.template); n.template = i } return S = e({}, S, n), O.forEach((function (e) { e.dispatchEvent(m("medium-zoom:update", { detail: { zoom: j } })) })), j }, g = function () { var o = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}; return t(e({}, S, o)) }, v = function () { for (var e = arguments.length, t = Array(e), o = 0; o < e; o++)t[o] = arguments[o]; var n = t.reduce((function (e, t) { return [].concat(e, i(t)) }), []); return n.filter((function (e) { return -1 === O.indexOf(e) })).forEach((function (e) { O.push(e), e.classList.add("medium-zoom-image") })), x.forEach((function (e) { var t = e.type, o = e.listener, i = e.options; n.forEach((function (e) { e.addEventListener(t, o, i) })) })), j }, h = function () { for (var e = arguments.length, t = Array(e), o = 0; o < e; o++)t[o] = arguments[o]; T.zoomed && E(); var n = t.length > 0 ? t.reduce((function (e, t) { return [].concat(e, i(t)) }), []) : O; return n.forEach((function (e) { e.classList.remove("medium-zoom-image"), e.dispatchEvent(m("medium-zoom:detach", { detail: { zoom: j } })) })), O = O.filter((function (e) { return -1 === n.indexOf(e) })), j }, z = function (e, t) { var o = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; return O.forEach((function (n) { n.addEventListener("medium-zoom:" + e, t, o) })), x.push({ type: "medium-zoom:" + e, listener: t, options: o }), j }, y = function (e, t) { var o = arguments.length > 2 && void 0 !== arguments[2] ? arguments[2] : {}; return O.forEach((function (n) { n.removeEventListener("medium-zoom:" + e, t, o) })), x = x.filter((function (o) { return !(o.type === "medium-zoom:" + e && o.listener.toString() === t.toString()) })), j }, b = function () { var t = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}, i = t.target, r = function () { var t = { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, left: 0, top: 0, right: 0, bottom: 0 }, i = void 0, r = void 0; if (S.container) if (S.container instanceof Object) i = (t = e({}, t, S.container)).width - t.left - t.right - 2 * S.margin, r = t.height - t.top - t.bottom - 2 * S.margin; else { var d = (o(S.container) ? S.container : document.querySelector(S.container)).getBoundingClientRect(), m = d.width, a = d.height, l = d.left, c = d.top; t = e({}, t, { width: m, height: a, left: l, top: c }) } i = i || t.width - 2 * S.margin, r = r || t.height - 2 * S.margin; var u = T.zoomedHd || T.original, s = n(u) ? i : u.naturalWidth || i, f = n(u) ? r : u.naturalHeight || r, p = u.getBoundingClientRect(), g = p.top, v = p.left, h = p.width, z = p.height, y = Math.min(s, i) / h, b = Math.min(f, r) / z, E = Math.min(y, b), w = "scale(" + E + ") translate3d(" + ((i - h) / 2 - v + S.margin + t.left) / E + "px, " + ((r - z) / 2 - g + S.margin + t.top) / E + "px, 0)"; T.zoomed.style.transform = w, T.zoomedHd && (T.zoomedHd.style.transform = w) }; return new c((function (e) { if (i && -1 === O.indexOf(i)) e(j); else { if (T.zoomed) e(j); else { if (i) T.original = i; else { if (!(O.length > 0)) return void e(j); var t = O; T.original = t[0] } if (T.original.dispatchEvent(m("medium-zoom:open", { detail: { zoom: j } })), k = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0, A = !0, T.zoomed = d(T.original), document.body.appendChild(N), S.template) { var n = o(S.template) ? S.template : document.querySelector(S.template); T.template = document.createElement("div"), T.template.appendChild(n.content.cloneNode(!0)), document.body.appendChild(T.template) } if (document.body.appendChild(T.zoomed), window.requestAnimationFrame((function () { document.body.classList.add("medium-zoom--opened") })), T.original.classList.add("medium-zoom-image--hidden"), T.zoomed.classList.add("medium-zoom-image--opened"), T.zoomed.addEventListener("click", E), T.zoomed.addEventListener("transitionend", (function t() { A = !1, T.zoomed.removeEventListener("transitionend", t), T.original.dispatchEvent(m("medium-zoom:opened", { detail: { zoom: j } })), e(j) })), T.original.getAttribute("data-zoom-src")) { T.zoomedHd = T.zoomed.cloneNode(), T.zoomedHd.removeAttribute("srcset"), T.zoomedHd.removeAttribute("sizes"), T.zoomedHd.src = T.zoomed.getAttribute("data-zoom-src"), T.zoomedHd.onerror = function () { clearInterval(a), console.warn("Unable to reach the zoom image target " + T.zoomedHd.src), T.zoomedHd = null, r() }; var a = setInterval((function () { T.zoomedHd.complete && (clearInterval(a), T.zoomedHd.classList.add("medium-zoom-image--opened"), T.zoomedHd.addEventListener("click", E), document.body.appendChild(T.zoomedHd), r()) }), 10) } else if (T.original.hasAttribute("srcset")) { T.zoomedHd = T.zoomed.cloneNode(), T.zoomedHd.removeAttribute("sizes"), T.zoomedHd.removeAttribute("loading"); var l = T.zoomedHd.addEventListener("load", (function () { T.zoomedHd.removeEventListener("load", l), T.zoomedHd.classList.add("medium-zoom-image--opened"), T.zoomedHd.addEventListener("click", E), document.body.appendChild(T.zoomedHd), r() })) } else r() } } })) }, E = function () { return new c((function (e) { if (!A && T.original) { A = !0, document.body.classList.remove("medium-zoom--opened"), T.zoomed.style.transform = "", T.zoomedHd && (T.zoomedHd.style.transform = ""), T.template && (T.template.style.transition = "opacity 150ms", T.template.style.opacity = 0), T.original.dispatchEvent(m("medium-zoom:close", { detail: { zoom: j } })), T.zoomed.addEventListener("transitionend", (function t() { T.original.classList.remove("medium-zoom-image--hidden"), document.body.removeChild(T.zoomed), T.zoomedHd && document.body.removeChild(T.zoomedHd), document.body.removeChild(N), T.zoomed.classList.remove("medium-zoom-image--opened"), T.template && document.body.removeChild(T.template), A = !1, T.zoomed.removeEventListener("transitionend", t), T.original.dispatchEvent(m("medium-zoom:closed", { detail: { zoom: j } })), T.original = null, T.zoomed = null, T.zoomedHd = null, T.template = null, e(j) })) } else e(j) })) }, w = function () { var e = arguments.length > 0 && void 0 !== arguments[0] ? arguments[0] : {}, t = e.target; return T.original ? E() : b({ target: t }) }, L = function () { return S }, H = function () { return O }, C = function () { return T.original }, O = [], x = [], A = !1, k = 0, S = l, T = { original: null, zoomed: null, zoomedHd: null, template: null }; "[object Object]" === Object.prototype.toString.call(a) ? S = a : (a || "string" == typeof a) && v(a), S = e({ margin: 0, background: "#fff", scrollOffset: 40, container: null, template: null }, S); var N = r(S.background); document.addEventListener("click", u), document.addEventListener("keyup", f), document.addEventListener("scroll", s), window.addEventListener("resize", E); var j = { open: b, close: E, toggle: w, update: p, clone: g, attach: v, detach: h, on: z, off: y, getOptions: L, getImages: H, getZoomedImage: C }; return j } })); \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/script/menu.js b/src/Plugins/Theme.Modern/Content/script/menu.js deleted file mode 100644 index ecc1d4f37c..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/menu.js +++ /dev/null @@ -1,158 +0,0 @@ -(function () { - function $(selector, context) { - context = context || document; - return context["querySelectorAll"](selector); - } - - function forEach(collection, iterator) { - for (var key in Object.keys(collection)) { - iterator(collection[key]); - } - } - - function showMenu(menu) { - - if (menu.target.classList.contains('hasGallery')) { - checkPosition(menu.target); - } - - if (this.tagName == "a") { - var menuli = this; - } else { - var menuli = menu.target; - } - var ul = $("ul", menuli)[0]; - - if (!ul || ul.classList.contains("-visible")) return; - - ul.classList.add("-visible"); - if (menu.target.previousElementSibling !== null) { - menu.target.previousElementSibling.classList.add("-visible") - } - document.getElementById("backdrop-menu").classList.remove("hide"); - document.getElementById("backdrop-menu").classList.add("show"); - } - - function hideMenu(menu) { - var menu = this; - var ul = $("ul", menu)[0]; - - if (!ul || !ul.classList.contains("-visible")) return; - - menu.classList.remove("-active"); - ul.classList.add("-animating"); - setTimeout(function () { - ul.classList.remove("-visible"); - ul.classList.remove("-animating"); - }, 200); - document.getElementById("backdrop-menu").classList.remove("show"); - document.getElementById("backdrop-menu").classList.add("hide"); - } - function showMenuMobile(menu) { - document.querySelectorAll(".-visible").forEach(function (element) { - element.classList.remove("-visible"); - }); - if (this.tagName == "a") { - var menuli = this; - } else { - var menuli = this.parentElement; - } - var ul = $("ul", menuli)[0]; - - if (!ul || ul.classList.contains("-visible")) return; - - ul.classList.add("-visible"); - menu.target.previousElementSibling.classList.add("-visible") - } - function hideMenuMobile(menu) { - menu.target.classList.remove("-visible"); - var menu = this.parentElement; - var ul = $("ul", menu)[0]; - - menu.classList.remove("-active"); - ul.classList.add("-animating"); - setTimeout(function () { - ul.classList.remove("-visible"); - ul.classList.remove("-animating"); - }, 200); - } - - function checkPosition(li) { - var positions = li.getBoundingClientRect(); - var containerH = document.querySelector(".left-side-container").clientHeight; - var menuH = document.querySelector(".Menu.-vertical").clientHeight; - var Top = positions.top; - - if (document.body.classList.contains('.onTop')) { - li.querySelectorAll("li > ul")[0].style.top = containerH - Top + 15 + "px"; - } else { - li.querySelectorAll("li > ul")[0].style.top = containerH - Top + "px"; - } - li.querySelectorAll("li > ul")[0].style.minHeight = menuH + 2 + "px"; - } - - function topMenuScroll() { - - var body = document.body; - var scrollUp = "scroll-up"; - var scrollDown = "scroll-down"; - var onTop = "onTop"; - let lastScroll = 0; - - var currentScrollWindow = window.pageYOffset; - if (currentScrollWindow == 0) { - body.classList.add(onTop); - } - - window.addEventListener("scroll", function () { - var currentScroll = window.pageYOffset; - if (window.pageYOffset <= 120) { - body.classList.add(onTop); - } else { - body.classList.remove(onTop); - } - if (currentScroll > lastScroll && !body.classList.contains(scrollDown)) { - // down - body.classList.remove(scrollUp); - if (lastScroll != 0) { - body.classList.add(scrollDown); - return; - } else { - body.classList.remove(scrollDown); - } - } else if (currentScroll < lastScroll && body.classList.contains(scrollDown)) { - // up - body.classList.remove(scrollDown); - body.classList.add(scrollUp); - } - lastScroll = currentScroll; - }); - } - - document.addEventListener("DOMContentLoaded", function () { - if (991 < window.innerWidth) { - topMenuScroll(); - forEach($(".Menu > li.-hasSubmenu.hasGallery"), function (e) { - checkPosition(e); - }); - forEach($(".Menu > li.-hasSubmenu"), function (e) { - e.addEventListener("mouseenter", showMenu); - }); - forEach($(".Menu > li"), function (e) { - e.addEventListener("mouseleave", hideMenu); - }); - } else { - forEach($(".Menu > li.-hasSubmenu > .go-forward"), function (e) { - e.addEventListener("click", showMenuMobile); - }); - forEach($(".Menu > li.-hasSubmenu > .go-back"), function (e) { - e.addEventListener("click", hideMenuMobile); - }); - } - - //forEach($(".Menu li .back"), function(e){ - // e.addEventListener("click", hideMenu); - //}); - - }); -})(); diff --git a/src/Plugins/Theme.Modern/Content/script/npslider-home.js b/src/Plugins/Theme.Modern/Content/script/npslider-home.js deleted file mode 100644 index 63f8dd5f96..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/npslider-home.js +++ /dev/null @@ -1,112 +0,0 @@ -Vue.use(VueAwesomeSwiper) -var hpnslider = new Vue({ - data() { - return { - Model: null, - swiperOptions: { - effect: 'fade', - lazy: { - preloaderClass: 'preloader' - }, - autoplay: { - delay: 5000, - }, - fadeEffect: { - crossFade: true - }, - pagination: { - el: '#hpnslider .swiper-pagination', - clickable: true - }, - navigation: { - nextEl: '#hpnslider .swiper-button-next', - prevEl: '#hpnslider .swiper-button-prev' - }, - scrollbar: { - el: '#hpnslider .swiper-scrollbar', - draggable: true, - hide: false - }, - slidesPerView: 1, - on: { - init: function () { - hpnslider.progressBarIn(this); - hpnslider.animateIn(this); - }, - slideChangeTransitionStart: function () { - hpnslider.progressBarIn(this); - hpnslider.animateIn(this); - }, - activeIndexChange: function () { - hpnslider.progressBarOut(this); - hpnslider.animateOut(this); - }, - } - } - } - }, - methods: { - animateIn(slider) { - if (typeof (hpnslider.Model) == 'object') { - if (slider.slides.length > 0) { - var active = slider.slides[slider.activeIndex]; - active.querySelectorAll('.animate__animated').forEach(function (element) { - var delay = element.dataset.delay; - var animation = element.dataset.animation; - element.classList.add(delay); - element.classList.add(animation); - }); - } - } - }, - animateOut(slider) { - if (typeof (hpnslider.Model) == 'object') { - var prev = slider.slides[slider.previousIndex]; - prev.querySelectorAll('.animate__animated').forEach(function (element) { - var delay = element.dataset.delay; - var animation = element.dataset.animation; - element.classList.remove(delay); - element.classList.remove(animation); - }); - } - }, - progressBarIn(slider) { - slider.scrollbar.init(); - void slider.el.querySelector('#hpnslider .swiper-scrollbar').offsetWidth; - slider.el.querySelector('#hpnslider .swiper-scrollbar').classList.add('progressAnimation'); - slider.scrollbar.dragEl.style.animationDuration = "5s"; - }, - progressBarOut(slider) { - slider.scrollbar.init(); - void slider.el.querySelector('#hpnslider .swiper-scrollbar').offsetWidth; - slider.el.querySelector('#hpnslider .swiper-scrollbar').classList.remove('progressAnimation'); - slider.scrollbar.dragEl.style.animationDuration = "0s"; - }, - groupBy(xs, key) { - return xs.reduce(function (rv, x) { - (rv[x[key]] = rv[x[key]] || []).push(x); - return rv; - }, {}); - } - }, - created() { - axios({ - baseURL: '/Component/Index?Name=HomePageNewProducts', - method: 'get', - data: null, - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-Response-View': 'Json' - } - }).then(response => { - this.Model = response.data; - }).then(function () { - if (hpnslider.Model.length > 0) { - hpnslider.Model.forEach(function (element) { - element.SpecificationAttributeModels = hpnslider.groupBy(element.SpecificationAttributeModels, 'SpecificationAttributeName') - }) - } - }); - } -}); \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/script/parallax.min.js b/src/Plugins/Theme.Modern/Content/script/parallax.min.js deleted file mode 100644 index c47b900847..0000000000 --- a/src/Plugins/Theme.Modern/Content/script/parallax.min.js +++ /dev/null @@ -1 +0,0 @@ -!function(t){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).Parallax=t()}}(function(){return function t(e,i,n){function o(r,a){if(!i[r]){if(!e[r]){var l="function"==typeof require&&require;if(!a&&l)return l(r,!0);if(s)return s(r,!0);var h=new Error("Cannot find module '"+r+"'");throw h.code="MODULE_NOT_FOUND",h}var u=i[r]={exports:{}};e[r][0].call(u.exports,function(t){var i=e[r][1][t];return o(i||t)},u,u.exports,t,e,i,n)}return i[r].exports}for(var s="function"==typeof require&&require,r=0;r1)for(var i=1;ii?i:t:te?e:t},data:function(t,e){return a.deserialize(t.getAttribute("data-"+e))},deserialize:function(t){return"true"===t||"false"!==t&&("null"===t?null:!isNaN(parseFloat(t))&&isFinite(t)?parseFloat(t):t)},camelCase:function(t){return t.replace(/-+(.)?/g,function(t,e){return e?e.toUpperCase():""})},accelerate:function(t){a.css(t,"transform","translate3d(0,0,0) rotate(0.0001deg)"),a.css(t,"transform-style","preserve-3d"),a.css(t,"backface-visibility","hidden")},transformSupport:function(t){for(var e=document.createElement("div"),i=!1,n=null,o=!1,s=null,r=null,l=0,h=a.vendors.length;l0&&"none"!==n,c.style.overflow=d,u.removeChild(e),m&&(u.removeAttribute("style"),u.parentNode.removeChild(u))}}return o},css:function(t,e,i){var n=a.propertyCache[e];if(!n)for(var o=0,s=a.vendors.length;othis.calibrationThreshold||Math.abs(e)>this.calibrationThreshold)&&this.queueCalibration(0),this.portrait?(this.motionX=this.calibrateX?e:this.inputY,this.motionY=this.calibrateY?t:this.inputX):(this.motionX=this.calibrateX?t:this.inputX,this.motionY=this.calibrateY?e:this.inputY),this.motionX*=this.elementWidth*(this.scalarX/100),this.motionY*=this.elementHeight*(this.scalarY/100),isNaN(parseFloat(this.limitX))||(this.motionX=a.clamp(this.motionX,-this.limitX,this.limitX)),isNaN(parseFloat(this.limitY))||(this.motionY=a.clamp(this.motionY,-this.limitY,this.limitY)),this.velocityX+=(this.motionX-this.velocityX)*this.frictionX,this.velocityY+=(this.motionY-this.velocityY)*this.frictionY;for(var i=0;ithis.windowWidth;this.portrait!==o&&(this.portrait=o,this.calibrationFlag=!0),this.calibrationFlag&&(this.calibrationFlag=!1,this.calibrationX=i,this.calibrationY=n),this.inputX=i,this.inputY=n}},{key:"onDeviceOrientation",value:function(t){var e=t.beta,i=t.gamma;null!==e&&null!==i&&(this.orientationStatus=1,this.rotate(e,i))}},{key:"onDeviceMotion",value:function(t){var e=t.rotationRate.beta,i=t.rotationRate.gamma;null!==e&&null!==i&&(this.motionStatus=1,this.rotate(e,i))}},{key:"onMouseMove",value:function(t){var e=t.clientX,i=t.clientY;if(this.hoverOnly&&(ethis.elementPositionX+this.elementWidth||ithis.elementPositionY+this.elementHeight))return this.inputX=0,void(this.inputY=0);this.relativeInput?(this.clipRelativeInput&&(e=Math.max(e,this.elementPositionX),e=Math.min(e,this.elementPositionX+this.elementWidth),i=Math.max(i,this.elementPositionY),i=Math.min(i,this.elementPositionY+this.elementHeight)),this.elementRangeX&&this.elementRangeY&&(this.inputX=(e-this.elementPositionX-this.elementCenterX)/this.elementRangeX,this.inputY=(i-this.elementPositionY-this.elementCenterY)/this.elementRangeY)):this.windowRadiusX&&this.windowRadiusY&&(this.inputX=(e-this.windowCenterX)/this.windowRadiusX,this.inputY=(i-this.windowCenterY)/this.windowRadiusY)}},{key:"destroy",value:function(){this.disable(),clearTimeout(this.calibrationTimer),clearTimeout(this.detectionTimer),this.element.removeAttribute("style");for(var t=0;t=c[b].max?c[b].max:e),a.options.horizontal&&!a.options.vertical&&(g=g>=c[b].max?c[b].max:g));a.elems[b].style[D]="translate3d("+(a.options.horizontal?g:"0")+"px,"+(a.options.vertical?e:"0")+"px,"+c[b].zindex+ -"px) "+c[b].transform}a.options.callback(d)};a.destroy=function(){for(var d=0;d.swiper-wrapper{flex-direction:column}.swiper-wrapper{position:relative;width:100%;height:100%;z-index:1;display:flex;transition-property:transform;box-sizing:content-box}.swiper-container-android .swiper-slide,.swiper-wrapper{transform:translate3d(0px,0,0)}.swiper-container-multirow>.swiper-wrapper{flex-wrap:wrap}.swiper-container-multirow-column>.swiper-wrapper{flex-wrap:wrap;flex-direction:column}.swiper-container-free-mode>.swiper-wrapper{transition-timing-function:ease-out;margin:0 auto}.swiper-slide{flex-shrink:0;width:100%;height:100%;position:relative;transition-property:transform}.swiper-slide-invisible-blank{visibility:hidden}.swiper-container-autoheight,.swiper-container-autoheight .swiper-slide{height:auto}.swiper-container-autoheight .swiper-wrapper{align-items:flex-start;transition-property:transform,height}.swiper-container-3d{perspective:1200px}.swiper-container-3d .swiper-cube-shadow,.swiper-container-3d .swiper-slide,.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top,.swiper-container-3d .swiper-wrapper{transform-style:preserve-3d}.swiper-container-3d .swiper-slide-shadow-bottom,.swiper-container-3d .swiper-slide-shadow-left,.swiper-container-3d .swiper-slide-shadow-right,.swiper-container-3d .swiper-slide-shadow-top{position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:10}.swiper-container-3d .swiper-slide-shadow-left{background-image:linear-gradient(to left,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-container-3d .swiper-slide-shadow-right{background-image:linear-gradient(to right,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-container-3d .swiper-slide-shadow-top{background-image:linear-gradient(to top,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-container-3d .swiper-slide-shadow-bottom{background-image:linear-gradient(to bottom,rgba(0,0,0,.5),rgba(0,0,0,0))}.swiper-container-css-mode>.swiper-wrapper{overflow:auto;scrollbar-width:none;-ms-overflow-style:none}.swiper-container-css-mode>.swiper-wrapper::-webkit-scrollbar{display:none}.swiper-container-css-mode>.swiper-wrapper>.swiper-slide{scroll-snap-align:start start}.swiper-container-horizontal.swiper-container-css-mode>.swiper-wrapper{scroll-snap-type:x mandatory}.swiper-container-vertical.swiper-container-css-mode>.swiper-wrapper{scroll-snap-type:y mandatory}:root{--swiper-navigation-size:44px}.swiper-button-next,.swiper-button-prev{position:absolute;top:50%;width:calc(var(--swiper-navigation-size)/ 44 * 27);height:var(--swiper-navigation-size);margin-top:calc(-1 * var(--swiper-navigation-size)/ 2);z-index:10;cursor:pointer;display:flex;align-items:center;justify-content:center;color:var(--swiper-navigation-color,var(--swiper-theme-color))}.swiper-button-next.swiper-button-disabled,.swiper-button-prev.swiper-button-disabled{opacity:.35;cursor:auto;pointer-events:none}.swiper-button-next:after,.swiper-button-prev:after{font-family:swiper-icons;font-size:var(--swiper-navigation-size);text-transform:none!important;letter-spacing:0;text-transform:none;font-variant:initial}.swiper-button-prev,.swiper-container-rtl .swiper-button-next{left:10px;right:auto}.swiper-button-prev:after,.swiper-container-rtl .swiper-button-next:after{content:'prev'}.swiper-button-next,.swiper-container-rtl .swiper-button-prev{right:10px;left:auto}.swiper-button-next:after,.swiper-container-rtl .swiper-button-prev:after{content:'next'}.swiper-button-next.swiper-button-white,.swiper-button-prev.swiper-button-white{--swiper-navigation-color:#ffffff}.swiper-button-next.swiper-button-black,.swiper-button-prev.swiper-button-black{--swiper-navigation-color:#000000}.swiper-button-lock{display:none}.swiper-pagination{position:absolute;text-align:center;transition:.3s opacity;transform:translate3d(0,0,0);z-index:10}.swiper-pagination.swiper-pagination-hidden{opacity:0}.swiper-container-horizontal>.swiper-pagination-bullets,.swiper-pagination-custom,.swiper-pagination-fraction{bottom:10px;left:0;width:100%}.swiper-pagination-bullets-dynamic{overflow:hidden;font-size:0}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transform:scale(.33);position:relative}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active{transform:scale(1)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-main{transform:scale(1)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev{transform:scale(.66)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-prev-prev{transform:scale(.33)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next{transform:scale(.66)}.swiper-pagination-bullets-dynamic .swiper-pagination-bullet-active-next-next{transform:scale(.33)}.swiper-pagination-bullet{width:8px;height:8px;display:inline-block;border-radius:100%;background:#000;opacity:.2}button.swiper-pagination-bullet{border:none;margin:0;padding:0;box-shadow:none;-webkit-appearance:none;-moz-appearance:none;appearance:none}.swiper-pagination-clickable .swiper-pagination-bullet{cursor:pointer}.swiper-pagination-bullet-active{opacity:1;background:var(--swiper-pagination-color,var(--swiper-theme-color))}.swiper-container-vertical>.swiper-pagination-bullets{right:10px;top:50%;transform:translate3d(0px,-50%,0)}.swiper-container-vertical>.swiper-pagination-bullets .swiper-pagination-bullet{margin:6px 0;display:block}.swiper-container-vertical>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic{top:50%;transform:translateY(-50%);width:8px}.swiper-container-vertical>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{display:inline-block;transition:.2s transform,.2s top}.swiper-container-horizontal>.swiper-pagination-bullets .swiper-pagination-bullet{margin:0 4px}.swiper-container-horizontal>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic{left:50%;transform:translateX(-50%);white-space:nowrap}.swiper-container-horizontal>.swiper-pagination-bullets.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transition:.2s transform,.2s left}.swiper-container-horizontal.swiper-container-rtl>.swiper-pagination-bullets-dynamic .swiper-pagination-bullet{transition:.2s transform,.2s right}.swiper-pagination-progressbar{background:rgba(0,0,0,.25);position:absolute}.swiper-pagination-progressbar .swiper-pagination-progressbar-fill{background:var(--swiper-pagination-color,var(--swiper-theme-color));position:absolute;left:0;top:0;width:100%;height:100%;transform:scale(0);transform-origin:left top}.swiper-container-rtl .swiper-pagination-progressbar .swiper-pagination-progressbar-fill{transform-origin:right top}.swiper-container-horizontal>.swiper-pagination-progressbar,.swiper-container-vertical>.swiper-pagination-progressbar.swiper-pagination-progressbar-opposite{width:100%;height:4px;left:0;top:0}.swiper-container-horizontal>.swiper-pagination-progressbar.swiper-pagination-progressbar-opposite,.swiper-container-vertical>.swiper-pagination-progressbar{width:4px;height:100%;left:0;top:0}.swiper-pagination-white{--swiper-pagination-color:#ffffff}.swiper-pagination-black{--swiper-pagination-color:#000000}.swiper-pagination-lock{display:none}.swiper-scrollbar{border-radius:10px;position:relative;-ms-touch-action:none;background:rgba(0,0,0,.1)}.swiper-container-horizontal>.swiper-scrollbar{position:absolute;left:1%;bottom:3px;z-index:50;height:5px;width:98%}.swiper-container-vertical>.swiper-scrollbar{position:absolute;right:3px;top:1%;z-index:50;width:5px;height:98%}.swiper-scrollbar-drag{height:100%;width:100%;position:relative;background:rgba(0,0,0,.5);border-radius:10px;left:0;top:0}.swiper-scrollbar-cursor-drag{cursor:move}.swiper-scrollbar-lock{display:none}.swiper-zoom-container{width:100%;height:100%;display:flex;justify-content:center;align-items:center;text-align:center}.swiper-zoom-container>canvas,.swiper-zoom-container>img,.swiper-zoom-container>svg{max-width:100%;max-height:100%;object-fit:contain}.swiper-slide-zoomed{cursor:move}.swiper-lazy-preloader{width:42px;height:42px;position:absolute;left:50%;top:50%;margin-left:-21px;margin-top:-21px;z-index:10;transform-origin:50%;animation:swiper-preloader-spin 1s infinite linear;box-sizing:border-box;border:4px solid var(--swiper-preloader-color,var(--swiper-theme-color));border-radius:50%;border-top-color:transparent}.swiper-lazy-preloader-white{--swiper-preloader-color:#fff}.swiper-lazy-preloader-black{--swiper-preloader-color:#000}@keyframes swiper-preloader-spin{100%{transform:rotate(360deg)}}.swiper-container .swiper-notification{position:absolute;left:0;top:0;pointer-events:none;opacity:0;z-index:-1000}.swiper-container-fade.swiper-container-free-mode .swiper-slide{transition-timing-function:ease-out}.swiper-container-fade .swiper-slide{pointer-events:none;transition-property:opacity}.swiper-container-fade .swiper-slide .swiper-slide{pointer-events:none}.swiper-container-fade .swiper-slide-active,.swiper-container-fade .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-container-cube{overflow:visible}.swiper-container-cube .swiper-slide{pointer-events:none;-webkit-backface-visibility:hidden;backface-visibility:hidden;z-index:1;visibility:hidden;transform-origin:0 0;width:100%;height:100%}.swiper-container-cube .swiper-slide .swiper-slide{pointer-events:none}.swiper-container-cube.swiper-container-rtl .swiper-slide{transform-origin:100% 0}.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-container-cube .swiper-slide-active,.swiper-container-cube .swiper-slide-next,.swiper-container-cube .swiper-slide-next+.swiper-slide,.swiper-container-cube .swiper-slide-prev{pointer-events:auto;visibility:visible}.swiper-container-cube .swiper-slide-shadow-bottom,.swiper-container-cube .swiper-slide-shadow-left,.swiper-container-cube .swiper-slide-shadow-right,.swiper-container-cube .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;backface-visibility:hidden}.swiper-container-cube .swiper-cube-shadow{position:absolute;left:0;bottom:0px;width:100%;height:100%;background:#000;opacity:.6;-webkit-filter:blur(50px);filter:blur(50px);z-index:0}.swiper-container-flip{overflow:visible}.swiper-container-flip .swiper-slide{pointer-events:none;-webkit-backface-visibility:hidden;backface-visibility:hidden;z-index:1}.swiper-container-flip .swiper-slide .swiper-slide{pointer-events:none}.swiper-container-flip .swiper-slide-active,.swiper-container-flip .swiper-slide-active .swiper-slide-active{pointer-events:auto}.swiper-container-flip .swiper-slide-shadow-bottom,.swiper-container-flip .swiper-slide-shadow-left,.swiper-container-flip .swiper-slide-shadow-right,.swiper-container-flip .swiper-slide-shadow-top{z-index:0;-webkit-backface-visibility:hidden;backface-visibility:hidden} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Content/swiper/swiper.min.js b/src/Plugins/Theme.Modern/Content/swiper/swiper.min.js deleted file mode 100644 index 5ac1f3eaad..0000000000 --- a/src/Plugins/Theme.Modern/Content/swiper/swiper.min.js +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Swiper 6.4.11 - * Most modern mobile touch slider and framework with hardware accelerated transitions - * https://swiperjs.com - * - * Copyright 2014-2021 Vladimir Kharlampidi - * - * Released under the MIT License - * - * Released on: February 6, 2021 - */ - -!function (e, t) { "object" == typeof exports && "undefined" != typeof module ? module.exports = t() : "function" == typeof define && define.amd ? define(t) : (e = "undefined" != typeof globalThis ? globalThis : e || self).Swiper = t() }(this, (function () { "use strict"; function e(e, t) { for (var a = 0; a < t.length; a++) { var i = t[a]; i.enumerable = i.enumerable || !1, i.configurable = !0, "value" in i && (i.writable = !0), Object.defineProperty(e, i.key, i) } } function t() { return (t = Object.assign || function (e) { for (var t = 1; t < arguments.length; t++) { var a = arguments[t]; for (var i in a) Object.prototype.hasOwnProperty.call(a, i) && (e[i] = a[i]) } return e }).apply(this, arguments) } function a(e) { return null !== e && "object" == typeof e && "constructor" in e && e.constructor === Object } function i(e, t) { void 0 === e && (e = {}), void 0 === t && (t = {}), Object.keys(t).forEach((function (s) { void 0 === e[s] ? e[s] = t[s] : a(t[s]) && a(e[s]) && Object.keys(t[s]).length > 0 && i(e[s], t[s]) })) } var s = { body: {}, addEventListener: function () { }, removeEventListener: function () { }, activeElement: { blur: function () { }, nodeName: "" }, querySelector: function () { return null }, querySelectorAll: function () { return [] }, getElementById: function () { return null }, createEvent: function () { return { initEvent: function () { } } }, createElement: function () { return { children: [], childNodes: [], style: {}, setAttribute: function () { }, getElementsByTagName: function () { return [] } } }, createElementNS: function () { return {} }, importNode: function () { return null }, location: { hash: "", host: "", hostname: "", href: "", origin: "", pathname: "", protocol: "", search: "" } }; function r() { var e = "undefined" != typeof document ? document : {}; return i(e, s), e } var n = { document: s, navigator: { userAgent: "" }, location: { hash: "", host: "", hostname: "", href: "", origin: "", pathname: "", protocol: "", search: "" }, history: { replaceState: function () { }, pushState: function () { }, go: function () { }, back: function () { } }, CustomEvent: function () { return this }, addEventListener: function () { }, removeEventListener: function () { }, getComputedStyle: function () { return { getPropertyValue: function () { return "" } } }, Image: function () { }, Date: function () { }, screen: {}, setTimeout: function () { }, clearTimeout: function () { }, matchMedia: function () { return {} }, requestAnimationFrame: function (e) { return "undefined" == typeof setTimeout ? (e(), null) : setTimeout(e, 0) }, cancelAnimationFrame: function (e) { "undefined" != typeof setTimeout && clearTimeout(e) } }; function l() { var e = "undefined" != typeof window ? window : {}; return i(e, n), e } function o(e) { return (o = Object.setPrototypeOf ? Object.getPrototypeOf : function (e) { return e.__proto__ || Object.getPrototypeOf(e) })(e) } function d(e, t) { return (d = Object.setPrototypeOf || function (e, t) { return e.__proto__ = t, e })(e, t) } function p() { if ("undefined" == typeof Reflect || !Reflect.construct) return !1; if (Reflect.construct.sham) return !1; if ("function" == typeof Proxy) return !0; try { return Date.prototype.toString.call(Reflect.construct(Date, [], (function () { }))), !0 } catch (e) { return !1 } } function u(e, t, a) { return (u = p() ? Reflect.construct : function (e, t, a) { var i = [null]; i.push.apply(i, t); var s = new (Function.bind.apply(e, i)); return a && d(s, a.prototype), s }).apply(null, arguments) } function c(e) { var t = "function" == typeof Map ? new Map : void 0; return (c = function (e) { if (null === e || (a = e, -1 === Function.toString.call(a).indexOf("[native code]"))) return e; var a; if ("function" != typeof e) throw new TypeError("Super expression must either be null or a function"); if (void 0 !== t) { if (t.has(e)) return t.get(e); t.set(e, i) } function i() { return u(e, arguments, o(this).constructor) } return i.prototype = Object.create(e.prototype, { constructor: { value: i, enumerable: !1, writable: !0, configurable: !0 } }), d(i, e) })(e) } var h = function (e) { var t, a; function i(t) { var a, i, s; return a = e.call.apply(e, [this].concat(t)) || this, i = function (e) { if (void 0 === e) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return e }(a), s = i.__proto__, Object.defineProperty(i, "__proto__", { get: function () { return s }, set: function (e) { s.__proto__ = e } }), a } return a = e, (t = i).prototype = Object.create(a.prototype), t.prototype.constructor = t, t.__proto__ = a, i }(c(Array)); function v(e) { void 0 === e && (e = []); var t = []; return e.forEach((function (e) { Array.isArray(e) ? t.push.apply(t, v(e)) : t.push(e) })), t } function f(e, t) { return Array.prototype.filter.call(e, t) } function m(e, t) { var a = l(), i = r(), s = []; if (!t && e instanceof h) return e; if (!e) return new h(s); if ("string" == typeof e) { var n = e.trim(); if (n.indexOf("<") >= 0 && n.indexOf(">") >= 0) { var o = "div"; 0 === n.indexOf(" 0 })).length > 0 }, toggleClass: function () { for (var e = arguments.length, t = new Array(e), a = 0; a < e; a++)t[a] = arguments[a]; var i = v(t.map((function (e) { return e.split(" ") }))); this.forEach((function (e) { i.forEach((function (t) { e.classList.toggle(t) })) })) }, attr: function (e, t) { if (1 === arguments.length && "string" == typeof e) return this[0] ? this[0].getAttribute(e) : void 0; for (var a = 0; a < this.length; a += 1)if (2 === arguments.length) this[a].setAttribute(e, t); else for (var i in e) this[a][i] = e[i], this[a].setAttribute(i, e[i]); return this }, removeAttr: function (e) { for (var t = 0; t < this.length; t += 1)this[t].removeAttribute(e); return this }, transform: function (e) { for (var t = 0; t < this.length; t += 1)this[t].style.transform = e; return this }, transition: function (e) { for (var t = 0; t < this.length; t += 1)this[t].style.transitionDuration = "string" != typeof e ? e + "ms" : e; return this }, on: function () { for (var e = arguments.length, t = new Array(e), a = 0; a < e; a++)t[a] = arguments[a]; var i = t[0], s = t[1], r = t[2], n = t[3]; function l(e) { var t = e.target; if (t) { var a = e.target.dom7EventData || []; if (a.indexOf(e) < 0 && a.unshift(e), m(t).is(s)) r.apply(t, a); else for (var i = m(t).parents(), n = 0; n < i.length; n += 1)m(i[n]).is(s) && r.apply(i[n], a) } } function o(e) { var t = e && e.target && e.target.dom7EventData || []; t.indexOf(e) < 0 && t.unshift(e), r.apply(this, t) } "function" == typeof t[1] && (i = t[0], r = t[1], n = t[2], s = void 0), n || (n = !1); for (var d, p = i.split(" "), u = 0; u < this.length; u += 1) { var c = this[u]; if (s) for (d = 0; d < p.length; d += 1) { var h = p[d]; c.dom7LiveListeners || (c.dom7LiveListeners = {}), c.dom7LiveListeners[h] || (c.dom7LiveListeners[h] = []), c.dom7LiveListeners[h].push({ listener: r, proxyListener: l }), c.addEventListener(h, l, n) } else for (d = 0; d < p.length; d += 1) { var v = p[d]; c.dom7Listeners || (c.dom7Listeners = {}), c.dom7Listeners[v] || (c.dom7Listeners[v] = []), c.dom7Listeners[v].push({ listener: r, proxyListener: o }), c.addEventListener(v, o, n) } } return this }, off: function () { for (var e = arguments.length, t = new Array(e), a = 0; a < e; a++)t[a] = arguments[a]; var i = t[0], s = t[1], r = t[2], n = t[3]; "function" == typeof t[1] && (i = t[0], r = t[1], n = t[2], s = void 0), n || (n = !1); for (var l = i.split(" "), o = 0; o < l.length; o += 1)for (var d = l[o], p = 0; p < this.length; p += 1) { var u = this[p], c = void 0; if (!s && u.dom7Listeners ? c = u.dom7Listeners[d] : s && u.dom7LiveListeners && (c = u.dom7LiveListeners[d]), c && c.length) for (var h = c.length - 1; h >= 0; h -= 1) { var v = c[h]; r && v.listener === r || r && v.listener && v.listener.dom7proxy && v.listener.dom7proxy === r ? (u.removeEventListener(d, v.proxyListener, n), c.splice(h, 1)) : r || (u.removeEventListener(d, v.proxyListener, n), c.splice(h, 1)) } } return this }, trigger: function () { for (var e = l(), t = arguments.length, a = new Array(t), i = 0; i < t; i++)a[i] = arguments[i]; for (var s = a[0].split(" "), r = a[1], n = 0; n < s.length; n += 1)for (var o = s[n], d = 0; d < this.length; d += 1) { var p = this[d]; if (e.CustomEvent) { var u = new e.CustomEvent(o, { detail: r, bubbles: !0, cancelable: !0 }); p.dom7EventData = a.filter((function (e, t) { return t > 0 })), p.dispatchEvent(u), p.dom7EventData = [], delete p.dom7EventData } } return this }, transitionEnd: function (e) { var t = this; return e && t.on("transitionend", (function a(i) { i.target === this && (e.call(this, i), t.off("transitionend", a)) })), this }, outerWidth: function (e) { if (this.length > 0) { if (e) { var t = this.styles(); return this[0].offsetWidth + parseFloat(t.getPropertyValue("margin-right")) + parseFloat(t.getPropertyValue("margin-left")) } return this[0].offsetWidth } return null }, outerHeight: function (e) { if (this.length > 0) { if (e) { var t = this.styles(); return this[0].offsetHeight + parseFloat(t.getPropertyValue("margin-top")) + parseFloat(t.getPropertyValue("margin-bottom")) } return this[0].offsetHeight } return null }, styles: function () { var e = l(); return this[0] ? e.getComputedStyle(this[0], null) : {} }, offset: function () { if (this.length > 0) { var e = l(), t = r(), a = this[0], i = a.getBoundingClientRect(), s = t.body, n = a.clientTop || s.clientTop || 0, o = a.clientLeft || s.clientLeft || 0, d = a === e ? e.scrollY : a.scrollTop, p = a === e ? e.scrollX : a.scrollLeft; return { top: i.top + d - n, left: i.left + p - o } } return null }, css: function (e, t) { var a, i = l(); if (1 === arguments.length) { if ("string" != typeof e) { for (a = 0; a < this.length; a += 1)for (var s in e) this[a].style[s] = e[s]; return this } if (this[0]) return i.getComputedStyle(this[0], null).getPropertyValue(e) } if (2 === arguments.length && "string" == typeof e) { for (a = 0; a < this.length; a += 1)this[a].style[e] = t; return this } return this }, each: function (e) { return e ? (this.forEach((function (t, a) { e.apply(t, [t, a]) })), this) : this }, html: function (e) { if (void 0 === e) return this[0] ? this[0].innerHTML : null; for (var t = 0; t < this.length; t += 1)this[t].innerHTML = e; return this }, text: function (e) { if (void 0 === e) return this[0] ? this[0].textContent.trim() : null; for (var t = 0; t < this.length; t += 1)this[t].textContent = e; return this }, is: function (e) { var t, a, i = l(), s = r(), n = this[0]; if (!n || void 0 === e) return !1; if ("string" == typeof e) { if (n.matches) return n.matches(e); if (n.webkitMatchesSelector) return n.webkitMatchesSelector(e); if (n.msMatchesSelector) return n.msMatchesSelector(e); for (t = m(e), a = 0; a < t.length; a += 1)if (t[a] === n) return !0; return !1 } if (e === s) return n === s; if (e === i) return n === i; if (e.nodeType || e instanceof h) { for (t = e.nodeType ? [e] : e, a = 0; a < t.length; a += 1)if (t[a] === n) return !0; return !1 } return !1 }, index: function () { var e, t = this[0]; if (t) { for (e = 0; null !== (t = t.previousSibling);)1 === t.nodeType && (e += 1); return e } }, eq: function (e) { if (void 0 === e) return this; var t = this.length; if (e > t - 1) return m([]); if (e < 0) { var a = t + e; return m(a < 0 ? [] : [this[a]]) } return m([this[e]]) }, append: function () { for (var e, t = r(), a = 0; a < arguments.length; a += 1) { e = a < 0 || arguments.length <= a ? void 0 : arguments[a]; for (var i = 0; i < this.length; i += 1)if ("string" == typeof e) { var s = t.createElement("div"); for (s.innerHTML = e; s.firstChild;)this[i].appendChild(s.firstChild) } else if (e instanceof h) for (var n = 0; n < e.length; n += 1)this[i].appendChild(e[n]); else this[i].appendChild(e) } return this }, prepend: function (e) { var t, a, i = r(); for (t = 0; t < this.length; t += 1)if ("string" == typeof e) { var s = i.createElement("div"); for (s.innerHTML = e, a = s.childNodes.length - 1; a >= 0; a -= 1)this[t].insertBefore(s.childNodes[a], this[t].childNodes[0]) } else if (e instanceof h) for (a = 0; a < e.length; a += 1)this[t].insertBefore(e[a], this[t].childNodes[0]); else this[t].insertBefore(e, this[t].childNodes[0]); return this }, next: function (e) { return this.length > 0 ? e ? this[0].nextElementSibling && m(this[0].nextElementSibling).is(e) ? m([this[0].nextElementSibling]) : m([]) : this[0].nextElementSibling ? m([this[0].nextElementSibling]) : m([]) : m([]) }, nextAll: function (e) { var t = [], a = this[0]; if (!a) return m([]); for (; a.nextElementSibling;) { var i = a.nextElementSibling; e ? m(i).is(e) && t.push(i) : t.push(i), a = i } return m(t) }, prev: function (e) { if (this.length > 0) { var t = this[0]; return e ? t.previousElementSibling && m(t.previousElementSibling).is(e) ? m([t.previousElementSibling]) : m([]) : t.previousElementSibling ? m([t.previousElementSibling]) : m([]) } return m([]) }, prevAll: function (e) { var t = [], a = this[0]; if (!a) return m([]); for (; a.previousElementSibling;) { var i = a.previousElementSibling; e ? m(i).is(e) && t.push(i) : t.push(i), a = i } return m(t) }, parent: function (e) { for (var t = [], a = 0; a < this.length; a += 1)null !== this[a].parentNode && (e ? m(this[a].parentNode).is(e) && t.push(this[a].parentNode) : t.push(this[a].parentNode)); return m(t) }, parents: function (e) { for (var t = [], a = 0; a < this.length; a += 1)for (var i = this[a].parentNode; i;)e ? m(i).is(e) && t.push(i) : t.push(i), i = i.parentNode; return m(t) }, closest: function (e) { var t = this; return void 0 === e ? m([]) : (t.is(e) || (t = t.parents(e).eq(0)), t) }, find: function (e) { for (var t = [], a = 0; a < this.length; a += 1)for (var i = this[a].querySelectorAll(e), s = 0; s < i.length; s += 1)t.push(i[s]); return m(t) }, children: function (e) { for (var t = [], a = 0; a < this.length; a += 1)for (var i = this[a].children, s = 0; s < i.length; s += 1)e && !m(i[s]).is(e) || t.push(i[s]); return m(t) }, filter: function (e) { return m(f(this, e)) }, remove: function () { for (var e = 0; e < this.length; e += 1)this[e].parentNode && this[e].parentNode.removeChild(this[e]); return this } }; function E(e, t) { return void 0 === t && (t = 0), setTimeout(e, t) } function x() { return Date.now() } function T(e, t) { void 0 === t && (t = "x"); var a, i, s, r = l(), n = r.getComputedStyle(e, null); return r.WebKitCSSMatrix ? ((i = n.transform || n.webkitTransform).split(",").length > 6 && (i = i.split(", ").map((function (e) { return e.replace(",", ".") })).join(", ")), s = new r.WebKitCSSMatrix("none" === i ? "" : i)) : a = (s = n.MozTransform || n.OTransform || n.MsTransform || n.msTransform || n.transform || n.getPropertyValue("transform").replace("translate(", "matrix(1, 0, 0, 1,")).toString().split(","), "x" === t && (i = r.WebKitCSSMatrix ? s.m41 : 16 === a.length ? parseFloat(a[12]) : parseFloat(a[4])), "y" === t && (i = r.WebKitCSSMatrix ? s.m42 : 16 === a.length ? parseFloat(a[13]) : parseFloat(a[5])), i || 0 } function C(e) { return "object" == typeof e && null !== e && e.constructor && e.constructor === Object } function S() { for (var e = Object(arguments.length <= 0 ? void 0 : arguments[0]), t = 1; t < arguments.length; t += 1) { var a = t < 0 || arguments.length <= t ? void 0 : arguments[t]; if (null != a) for (var i = Object.keys(Object(a)), s = 0, r = i.length; s < r; s += 1) { var n = i[s], l = Object.getOwnPropertyDescriptor(a, n); void 0 !== l && l.enumerable && (C(e[n]) && C(a[n]) ? S(e[n], a[n]) : !C(e[n]) && C(a[n]) ? (e[n] = {}, S(e[n], a[n])) : e[n] = a[n]) } } return e } function M(e, t) { Object.keys(t).forEach((function (a) { C(t[a]) && Object.keys(t[a]).forEach((function (i) { "function" == typeof t[a][i] && (t[a][i] = t[a][i].bind(e)) })), e[a] = t[a] })) } function z() { return g || (g = function () { var e = l(), t = r(); return { touch: !!("ontouchstart" in e || e.DocumentTouch && t instanceof e.DocumentTouch), pointerEvents: !!e.PointerEvent && "maxTouchPoints" in e.navigator && e.navigator.maxTouchPoints >= 0, observer: "MutationObserver" in e || "WebkitMutationObserver" in e, passiveListener: function () { var t = !1; try { var a = Object.defineProperty({}, "passive", { get: function () { t = !0 } }); e.addEventListener("testPassiveListener", null, a) } catch (e) { } return t }(), gestures: "ongesturestart" in e } }()), g } function P(e) { return void 0 === e && (e = {}), y || (y = function (e) { var t = (void 0 === e ? {} : e).userAgent, a = z(), i = l(), s = i.navigator.platform, r = t || i.navigator.userAgent, n = { ios: !1, android: !1 }, o = i.screen.width, d = i.screen.height, p = r.match(/(Android);?[\s\/]+([\d.]+)?/), u = r.match(/(iPad).*OS\s([\d_]+)/), c = r.match(/(iPod)(.*OS\s([\d_]+))?/), h = !u && r.match(/(iPhone\sOS|iOS)\s([\d_]+)/), v = "Win32" === s, f = "MacIntel" === s; return !u && f && a.touch && ["1024x1366", "1366x1024", "834x1194", "1194x834", "834x1112", "1112x834", "768x1024", "1024x768", "820x1180", "1180x820", "810x1080", "1080x810"].indexOf(o + "x" + d) >= 0 && ((u = r.match(/(Version)\/([\d.]+)/)) || (u = [0, 1, "13_0_0"]), f = !1), p && !v && (n.os = "android", n.android = !0), (u || h || c) && (n.os = "ios", n.ios = !0), n }(e)), y } function k() { return w || (w = function () { var e, t = l(); return { isEdge: !!t.navigator.userAgent.match(/Edge/g), isSafari: (e = t.navigator.userAgent.toLowerCase(), e.indexOf("safari") >= 0 && e.indexOf("chrome") < 0 && e.indexOf("android") < 0), isWebView: /(iPhone|iPod|iPad).*AppleWebKit(?!.*Safari)/i.test(t.navigator.userAgent) } }()), w } Object.keys(b).forEach((function (e) { m.fn[e] = b[e] })); var L = { name: "resize", create: function () { var e = this; S(e, { resize: { resizeHandler: function () { e && !e.destroyed && e.initialized && (e.emit("beforeResize"), e.emit("resize")) }, orientationChangeHandler: function () { e && !e.destroyed && e.initialized && e.emit("orientationchange") } } }) }, on: { init: function (e) { var t = l(); t.addEventListener("resize", e.resize.resizeHandler), t.addEventListener("orientationchange", e.resize.orientationChangeHandler) }, destroy: function (e) { var t = l(); t.removeEventListener("resize", e.resize.resizeHandler), t.removeEventListener("orientationchange", e.resize.orientationChangeHandler) } } }, $ = { attach: function (e, t) { void 0 === t && (t = {}); var a = l(), i = this, s = new (a.MutationObserver || a.WebkitMutationObserver)((function (e) { if (1 !== e.length) { var t = function () { i.emit("observerUpdate", e[0]) }; a.requestAnimationFrame ? a.requestAnimationFrame(t) : a.setTimeout(t, 0) } else i.emit("observerUpdate", e[0]) })); s.observe(e, { attributes: void 0 === t.attributes || t.attributes, childList: void 0 === t.childList || t.childList, characterData: void 0 === t.characterData || t.characterData }), i.observer.observers.push(s) }, init: function () { var e = this; if (e.support.observer && e.params.observer) { if (e.params.observeParents) for (var t = e.$el.parents(), a = 0; a < t.length; a += 1)e.observer.attach(t[a]); e.observer.attach(e.$el[0], { childList: e.params.observeSlideChildren }), e.observer.attach(e.$wrapperEl[0], { attributes: !1 }) } }, destroy: function () { this.observer.observers.forEach((function (e) { e.disconnect() })), this.observer.observers = [] } }, I = { name: "observer", params: { observer: !1, observeParents: !1, observeSlideChildren: !1 }, create: function () { M(this, { observer: t({}, $, { observers: [] }) }) }, on: { init: function (e) { e.observer.init() }, destroy: function (e) { e.observer.destroy() } } }; function O(e) { var t = this, a = r(), i = l(), s = t.touchEventsData, n = t.params, o = t.touches; if (!t.animating || !n.preventInteractionOnTransition) { var d = e; d.originalEvent && (d = d.originalEvent); var p = m(d.target); if ("wrapper" !== n.touchEventsTarget || p.closest(t.wrapperEl).length) if (s.isTouchEvent = "touchstart" === d.type, s.isTouchEvent || !("which" in d) || 3 !== d.which) if (!(!s.isTouchEvent && "button" in d && d.button > 0)) if (!s.isTouched || !s.isMoved) if (!!n.noSwipingClass && "" !== n.noSwipingClass && d.target && d.target.shadowRoot && e.path && e.path[0] && (p = m(e.path[0])), n.noSwiping && p.closest(n.noSwipingSelector ? n.noSwipingSelector : "." + n.noSwipingClass)[0]) t.allowClick = !0; else if (!n.swipeHandler || p.closest(n.swipeHandler)[0]) { o.currentX = "touchstart" === d.type ? d.targetTouches[0].pageX : d.pageX, o.currentY = "touchstart" === d.type ? d.targetTouches[0].pageY : d.pageY; var u = o.currentX, c = o.currentY, h = n.edgeSwipeDetection || n.iOSEdgeSwipeDetection, v = n.edgeSwipeThreshold || n.iOSEdgeSwipeThreshold; if (h && (u <= v || u >= i.innerWidth - v)) { if ("prevent" !== h) return; e.preventDefault() } if (S(s, { isTouched: !0, isMoved: !1, allowTouchCallbacks: !0, isScrolling: void 0, startMoving: void 0 }), o.startX = u, o.startY = c, s.touchStartTime = x(), t.allowClick = !0, t.updateSize(), t.swipeDirection = void 0, n.threshold > 0 && (s.allowThresholdMove = !1), "touchstart" !== d.type) { var f = !0; p.is(s.formElements) && (f = !1), a.activeElement && m(a.activeElement).is(s.formElements) && a.activeElement !== p[0] && a.activeElement.blur(); var g = f && t.allowTouchMove && n.touchStartPreventDefault; !n.touchStartForcePreventDefault && !g || p[0].isContentEditable || d.preventDefault() } t.emit("touchStart", d) } } } function A(e) { var t = r(), a = this, i = a.touchEventsData, s = a.params, n = a.touches, l = a.rtlTranslate, o = e; if (o.originalEvent && (o = o.originalEvent), i.isTouched) { if (!i.isTouchEvent || "touchmove" === o.type) { var d = "touchmove" === o.type && o.targetTouches && (o.targetTouches[0] || o.changedTouches[0]), p = "touchmove" === o.type ? d.pageX : o.pageX, u = "touchmove" === o.type ? d.pageY : o.pageY; if (o.preventedByNestedSwiper) return n.startX = p, void (n.startY = u); if (!a.allowTouchMove) return a.allowClick = !1, void (i.isTouched && (S(n, { startX: p, startY: u, currentX: p, currentY: u }), i.touchStartTime = x())); if (i.isTouchEvent && s.touchReleaseOnEdges && !s.loop) if (a.isVertical()) { if (u < n.startY && a.translate <= a.maxTranslate() || u > n.startY && a.translate >= a.minTranslate()) return i.isTouched = !1, void (i.isMoved = !1) } else if (p < n.startX && a.translate <= a.maxTranslate() || p > n.startX && a.translate >= a.minTranslate()) return; if (i.isTouchEvent && t.activeElement && o.target === t.activeElement && m(o.target).is(i.formElements)) return i.isMoved = !0, void (a.allowClick = !1); if (i.allowTouchCallbacks && a.emit("touchMove", o), !(o.targetTouches && o.targetTouches.length > 1)) { n.currentX = p, n.currentY = u; var c = n.currentX - n.startX, h = n.currentY - n.startY; if (!(a.params.threshold && Math.sqrt(Math.pow(c, 2) + Math.pow(h, 2)) < a.params.threshold)) { var v; if (void 0 === i.isScrolling) a.isHorizontal() && n.currentY === n.startY || a.isVertical() && n.currentX === n.startX ? i.isScrolling = !1 : c * c + h * h >= 25 && (v = 180 * Math.atan2(Math.abs(h), Math.abs(c)) / Math.PI, i.isScrolling = a.isHorizontal() ? v > s.touchAngle : 90 - v > s.touchAngle); if (i.isScrolling && a.emit("touchMoveOpposite", o), void 0 === i.startMoving && (n.currentX === n.startX && n.currentY === n.startY || (i.startMoving = !0)), i.isScrolling) i.isTouched = !1; else if (i.startMoving) { a.allowClick = !1, !s.cssMode && o.cancelable && o.preventDefault(), s.touchMoveStopPropagation && !s.nested && o.stopPropagation(), i.isMoved || (s.loop && a.loopFix(), i.startTranslate = a.getTranslate(), a.setTransition(0), a.animating && a.$wrapperEl.trigger("webkitTransitionEnd transitionend"), i.allowMomentumBounce = !1, !s.grabCursor || !0 !== a.allowSlideNext && !0 !== a.allowSlidePrev || a.setGrabCursor(!0), a.emit("sliderFirstMove", o)), a.emit("sliderMove", o), i.isMoved = !0; var f = a.isHorizontal() ? c : h; n.diff = f, f *= s.touchRatio, l && (f = -f), a.swipeDirection = f > 0 ? "prev" : "next", i.currentTranslate = f + i.startTranslate; var g = !0, y = s.resistanceRatio; if (s.touchReleaseOnEdges && (y = 0), f > 0 && i.currentTranslate > a.minTranslate() ? (g = !1, s.resistance && (i.currentTranslate = a.minTranslate() - 1 + Math.pow(-a.minTranslate() + i.startTranslate + f, y))) : f < 0 && i.currentTranslate < a.maxTranslate() && (g = !1, s.resistance && (i.currentTranslate = a.maxTranslate() + 1 - Math.pow(a.maxTranslate() - i.startTranslate - f, y))), g && (o.preventedByNestedSwiper = !0), !a.allowSlideNext && "next" === a.swipeDirection && i.currentTranslate < i.startTranslate && (i.currentTranslate = i.startTranslate), !a.allowSlidePrev && "prev" === a.swipeDirection && i.currentTranslate > i.startTranslate && (i.currentTranslate = i.startTranslate), s.threshold > 0) { if (!(Math.abs(f) > s.threshold || i.allowThresholdMove)) return void (i.currentTranslate = i.startTranslate); if (!i.allowThresholdMove) return i.allowThresholdMove = !0, n.startX = n.currentX, n.startY = n.currentY, i.currentTranslate = i.startTranslate, void (n.diff = a.isHorizontal() ? n.currentX - n.startX : n.currentY - n.startY) } s.followFinger && !s.cssMode && ((s.freeMode || s.watchSlidesProgress || s.watchSlidesVisibility) && (a.updateActiveIndex(), a.updateSlidesClasses()), s.freeMode && (0 === i.velocities.length && i.velocities.push({ position: n[a.isHorizontal() ? "startX" : "startY"], time: i.touchStartTime }), i.velocities.push({ position: n[a.isHorizontal() ? "currentX" : "currentY"], time: x() })), a.updateProgress(i.currentTranslate), a.setTranslate(i.currentTranslate)) } } } } } else i.startMoving && i.isScrolling && a.emit("touchMoveOpposite", o) } function D(e) { var t = this, a = t.touchEventsData, i = t.params, s = t.touches, r = t.rtlTranslate, n = t.$wrapperEl, l = t.slidesGrid, o = t.snapGrid, d = e; if (d.originalEvent && (d = d.originalEvent), a.allowTouchCallbacks && t.emit("touchEnd", d), a.allowTouchCallbacks = !1, !a.isTouched) return a.isMoved && i.grabCursor && t.setGrabCursor(!1), a.isMoved = !1, void (a.startMoving = !1); i.grabCursor && a.isMoved && a.isTouched && (!0 === t.allowSlideNext || !0 === t.allowSlidePrev) && t.setGrabCursor(!1); var p, u = x(), c = u - a.touchStartTime; if (t.allowClick && (t.updateClickedSlide(d), t.emit("tap click", d), c < 300 && u - a.lastClickTime < 300 && t.emit("doubleTap doubleClick", d)), a.lastClickTime = x(), E((function () { t.destroyed || (t.allowClick = !0) })), !a.isTouched || !a.isMoved || !t.swipeDirection || 0 === s.diff || a.currentTranslate === a.startTranslate) return a.isTouched = !1, a.isMoved = !1, void (a.startMoving = !1); if (a.isTouched = !1, a.isMoved = !1, a.startMoving = !1, p = i.followFinger ? r ? t.translate : -t.translate : -a.currentTranslate, !i.cssMode) if (i.freeMode) { if (p < -t.minTranslate()) return void t.slideTo(t.activeIndex); if (p > -t.maxTranslate()) return void (t.slides.length < o.length ? t.slideTo(o.length - 1) : t.slideTo(t.slides.length - 1)); if (i.freeModeMomentum) { if (a.velocities.length > 1) { var h = a.velocities.pop(), v = a.velocities.pop(), f = h.position - v.position, m = h.time - v.time; t.velocity = f / m, t.velocity /= 2, Math.abs(t.velocity) < i.freeModeMinimumVelocity && (t.velocity = 0), (m > 150 || x() - h.time > 300) && (t.velocity = 0) } else t.velocity = 0; t.velocity *= i.freeModeMomentumVelocityRatio, a.velocities.length = 0; var g = 1e3 * i.freeModeMomentumRatio, y = t.velocity * g, w = t.translate + y; r && (w = -w); var b, T, C = !1, S = 20 * Math.abs(t.velocity) * i.freeModeMomentumBounceRatio; if (w < t.maxTranslate()) i.freeModeMomentumBounce ? (w + t.maxTranslate() < -S && (w = t.maxTranslate() - S), b = t.maxTranslate(), C = !0, a.allowMomentumBounce = !0) : w = t.maxTranslate(), i.loop && i.centeredSlides && (T = !0); else if (w > t.minTranslate()) i.freeModeMomentumBounce ? (w - t.minTranslate() > S && (w = t.minTranslate() + S), b = t.minTranslate(), C = !0, a.allowMomentumBounce = !0) : w = t.minTranslate(), i.loop && i.centeredSlides && (T = !0); else if (i.freeModeSticky) { for (var M, z = 0; z < o.length; z += 1)if (o[z] > -w) { M = z; break } w = -(w = Math.abs(o[M] - w) < Math.abs(o[M - 1] - w) || "next" === t.swipeDirection ? o[M] : o[M - 1]) } if (T && t.once("transitionEnd", (function () { t.loopFix() })), 0 !== t.velocity) { if (g = r ? Math.abs((-w - t.translate) / t.velocity) : Math.abs((w - t.translate) / t.velocity), i.freeModeSticky) { var P = Math.abs((r ? -w : w) - t.translate), k = t.slidesSizesGrid[t.activeIndex]; g = P < k ? i.speed : P < 2 * k ? 1.5 * i.speed : 2.5 * i.speed } } else if (i.freeModeSticky) return void t.slideToClosest(); i.freeModeMomentumBounce && C ? (t.updateProgress(b), t.setTransition(g), t.setTranslate(w), t.transitionStart(!0, t.swipeDirection), t.animating = !0, n.transitionEnd((function () { t && !t.destroyed && a.allowMomentumBounce && (t.emit("momentumBounce"), t.setTransition(i.speed), setTimeout((function () { t.setTranslate(b), n.transitionEnd((function () { t && !t.destroyed && t.transitionEnd() })) }), 0)) }))) : t.velocity ? (t.updateProgress(w), t.setTransition(g), t.setTranslate(w), t.transitionStart(!0, t.swipeDirection), t.animating || (t.animating = !0, n.transitionEnd((function () { t && !t.destroyed && t.transitionEnd() })))) : t.updateProgress(w), t.updateActiveIndex(), t.updateSlidesClasses() } else if (i.freeModeSticky) return void t.slideToClosest(); (!i.freeModeMomentum || c >= i.longSwipesMs) && (t.updateProgress(), t.updateActiveIndex(), t.updateSlidesClasses()) } else { for (var L = 0, $ = t.slidesSizesGrid[0], I = 0; I < l.length; I += I < i.slidesPerGroupSkip ? 1 : i.slidesPerGroup) { var O = I < i.slidesPerGroupSkip - 1 ? 1 : i.slidesPerGroup; void 0 !== l[I + O] ? p >= l[I] && p < l[I + O] && (L = I, $ = l[I + O] - l[I]) : p >= l[I] && (L = I, $ = l[l.length - 1] - l[l.length - 2]) } var A = (p - l[L]) / $, D = L < i.slidesPerGroupSkip - 1 ? 1 : i.slidesPerGroup; if (c > i.longSwipesMs) { if (!i.longSwipes) return void t.slideTo(t.activeIndex); "next" === t.swipeDirection && (A >= i.longSwipesRatio ? t.slideTo(L + D) : t.slideTo(L)), "prev" === t.swipeDirection && (A > 1 - i.longSwipesRatio ? t.slideTo(L + D) : t.slideTo(L)) } else { if (!i.shortSwipes) return void t.slideTo(t.activeIndex); t.navigation && (d.target === t.navigation.nextEl || d.target === t.navigation.prevEl) ? d.target === t.navigation.nextEl ? t.slideTo(L + D) : t.slideTo(L) : ("next" === t.swipeDirection && t.slideTo(L + D), "prev" === t.swipeDirection && t.slideTo(L)) } } } function G() { var e = this, t = e.params, a = e.el; if (!a || 0 !== a.offsetWidth) { t.breakpoints && e.setBreakpoint(); var i = e.allowSlideNext, s = e.allowSlidePrev, r = e.snapGrid; e.allowSlideNext = !0, e.allowSlidePrev = !0, e.updateSize(), e.updateSlides(), e.updateSlidesClasses(), ("auto" === t.slidesPerView || t.slidesPerView > 1) && e.isEnd && !e.isBeginning && !e.params.centeredSlides ? e.slideTo(e.slides.length - 1, 0, !1, !0) : e.slideTo(e.activeIndex, 0, !1, !0), e.autoplay && e.autoplay.running && e.autoplay.paused && e.autoplay.run(), e.allowSlidePrev = s, e.allowSlideNext = i, e.params.watchOverflow && r !== e.snapGrid && e.checkOverflow() } } function N(e) { var t = this; t.allowClick || (t.params.preventClicks && e.preventDefault(), t.params.preventClicksPropagation && t.animating && (e.stopPropagation(), e.stopImmediatePropagation())) } function B() { var e = this, t = e.wrapperEl, a = e.rtlTranslate; e.previousTranslate = e.translate, e.isHorizontal() ? e.translate = a ? t.scrollWidth - t.offsetWidth - t.scrollLeft : -t.scrollLeft : e.translate = -t.scrollTop, -0 === e.translate && (e.translate = 0), e.updateActiveIndex(), e.updateSlidesClasses(); var i = e.maxTranslate() - e.minTranslate(); (0 === i ? 0 : (e.translate - e.minTranslate()) / i) !== e.progress && e.updateProgress(a ? -e.translate : e.translate), e.emit("setTranslate", e.translate, !1) } var H = !1; function X() { } var Y = { init: !0, direction: "horizontal", touchEventsTarget: "container", initialSlide: 0, speed: 300, cssMode: !1, updateOnWindowResize: !0, nested: !1, width: null, height: null, preventInteractionOnTransition: !1, userAgent: null, url: null, edgeSwipeDetection: !1, edgeSwipeThreshold: 20, freeMode: !1, freeModeMomentum: !0, freeModeMomentumRatio: 1, freeModeMomentumBounce: !0, freeModeMomentumBounceRatio: 1, freeModeMomentumVelocityRatio: 1, freeModeSticky: !1, freeModeMinimumVelocity: .02, autoHeight: !1, setWrapperSize: !1, virtualTranslate: !1, effect: "slide", breakpoints: void 0, spaceBetween: 0, slidesPerView: 1, slidesPerColumn: 1, slidesPerColumnFill: "column", slidesPerGroup: 1, slidesPerGroupSkip: 0, centeredSlides: !1, centeredSlidesBounds: !1, slidesOffsetBefore: 0, slidesOffsetAfter: 0, normalizeSlideIndex: !0, centerInsufficientSlides: !1, watchOverflow: !1, roundLengths: !1, touchRatio: 1, touchAngle: 45, simulateTouch: !0, shortSwipes: !0, longSwipes: !0, longSwipesRatio: .5, longSwipesMs: 300, followFinger: !0, allowTouchMove: !0, threshold: 0, touchMoveStopPropagation: !1, touchStartPreventDefault: !0, touchStartForcePreventDefault: !1, touchReleaseOnEdges: !1, uniqueNavElements: !0, resistance: !0, resistanceRatio: .85, watchSlidesProgress: !1, watchSlidesVisibility: !1, grabCursor: !1, preventClicks: !0, preventClicksPropagation: !0, slideToClickedSlide: !1, preloadImages: !0, updateOnImagesReady: !0, loop: !1, loopAdditionalSlides: 0, loopedSlides: null, loopFillGroupWithBlank: !1, loopPreventsSlide: !0, allowSlidePrev: !0, allowSlideNext: !0, swipeHandler: null, noSwiping: !0, noSwipingClass: "swiper-no-swiping", noSwipingSelector: null, passiveListeners: !0, containerModifierClass: "swiper-container-", slideClass: "swiper-slide", slideBlankClass: "swiper-slide-invisible-blank", slideActiveClass: "swiper-slide-active", slideDuplicateActiveClass: "swiper-slide-duplicate-active", slideVisibleClass: "swiper-slide-visible", slideDuplicateClass: "swiper-slide-duplicate", slideNextClass: "swiper-slide-next", slideDuplicateNextClass: "swiper-slide-duplicate-next", slidePrevClass: "swiper-slide-prev", slideDuplicatePrevClass: "swiper-slide-duplicate-prev", wrapperClass: "swiper-wrapper", runCallbacksOnInit: !0, _emitClasses: !1 }, V = { modular: { useParams: function (e) { var t = this; t.modules && Object.keys(t.modules).forEach((function (a) { var i = t.modules[a]; i.params && S(e, i.params) })) }, useModules: function (e) { void 0 === e && (e = {}); var t = this; t.modules && Object.keys(t.modules).forEach((function (a) { var i = t.modules[a], s = e[a] || {}; i.on && t.on && Object.keys(i.on).forEach((function (e) { t.on(e, i.on[e]) })), i.create && i.create.bind(t)(s) })) } }, eventsEmitter: { on: function (e, t, a) { var i = this; if ("function" != typeof t) return i; var s = a ? "unshift" : "push"; return e.split(" ").forEach((function (e) { i.eventsListeners[e] || (i.eventsListeners[e] = []), i.eventsListeners[e][s](t) })), i }, once: function (e, t, a) { var i = this; if ("function" != typeof t) return i; function s() { i.off(e, s), s.__emitterProxy && delete s.__emitterProxy; for (var a = arguments.length, r = new Array(a), n = 0; n < a; n++)r[n] = arguments[n]; t.apply(i, r) } return s.__emitterProxy = t, i.on(e, s, a) }, onAny: function (e, t) { var a = this; if ("function" != typeof e) return a; var i = t ? "unshift" : "push"; return a.eventsAnyListeners.indexOf(e) < 0 && a.eventsAnyListeners[i](e), a }, offAny: function (e) { var t = this; if (!t.eventsAnyListeners) return t; var a = t.eventsAnyListeners.indexOf(e); return a >= 0 && t.eventsAnyListeners.splice(a, 1), t }, off: function (e, t) { var a = this; return a.eventsListeners ? (e.split(" ").forEach((function (e) { void 0 === t ? a.eventsListeners[e] = [] : a.eventsListeners[e] && a.eventsListeners[e].forEach((function (i, s) { (i === t || i.__emitterProxy && i.__emitterProxy === t) && a.eventsListeners[e].splice(s, 1) })) })), a) : a }, emit: function () { var e, t, a, i = this; if (!i.eventsListeners) return i; for (var s = arguments.length, r = new Array(s), n = 0; n < s; n++)r[n] = arguments[n]; "string" == typeof r[0] || Array.isArray(r[0]) ? (e = r[0], t = r.slice(1, r.length), a = i) : (e = r[0].events, t = r[0].data, a = r[0].context || i), t.unshift(a); var l = Array.isArray(e) ? e : e.split(" "); return l.forEach((function (e) { i.eventsAnyListeners && i.eventsAnyListeners.length && i.eventsAnyListeners.forEach((function (i) { i.apply(a, [e].concat(t)) })), i.eventsListeners && i.eventsListeners[e] && i.eventsListeners[e].forEach((function (e) { e.apply(a, t) })) })), i } }, update: { updateSize: function () { var e, t, a = this, i = a.$el; e = void 0 !== a.params.width && null !== a.params.width ? a.params.width : i[0].clientWidth, t = void 0 !== a.params.height && null !== a.params.height ? a.params.height : i[0].clientHeight, 0 === e && a.isHorizontal() || 0 === t && a.isVertical() || (e = e - parseInt(i.css("padding-left") || 0, 10) - parseInt(i.css("padding-right") || 0, 10), t = t - parseInt(i.css("padding-top") || 0, 10) - parseInt(i.css("padding-bottom") || 0, 10), Number.isNaN(e) && (e = 0), Number.isNaN(t) && (t = 0), S(a, { width: e, height: t, size: a.isHorizontal() ? e : t })) }, updateSlides: function () { var e = this, t = l(), a = e.params, i = e.$wrapperEl, s = e.size, r = e.rtlTranslate, n = e.wrongRTL, o = e.virtual && a.virtual.enabled, d = o ? e.virtual.slides.length : e.slides.length, p = i.children("." + e.params.slideClass), u = o ? e.virtual.slides.length : p.length, c = [], h = [], v = []; function f(e, t) { return !a.cssMode || t !== p.length - 1 } var m = a.slidesOffsetBefore; "function" == typeof m && (m = a.slidesOffsetBefore.call(e)); var g = a.slidesOffsetAfter; "function" == typeof g && (g = a.slidesOffsetAfter.call(e)); var y = e.snapGrid.length, w = e.slidesGrid.length, b = a.spaceBetween, E = -m, x = 0, T = 0; if (void 0 !== s) { var C, M; "string" == typeof b && b.indexOf("%") >= 0 && (b = parseFloat(b.replace("%", "")) / 100 * s), e.virtualSize = -b, r ? p.css({ marginLeft: "", marginTop: "" }) : p.css({ marginRight: "", marginBottom: "" }), a.slidesPerColumn > 1 && (C = Math.floor(u / a.slidesPerColumn) === u / e.params.slidesPerColumn ? u : Math.ceil(u / a.slidesPerColumn) * a.slidesPerColumn, "auto" !== a.slidesPerView && "row" === a.slidesPerColumnFill && (C = Math.max(C, a.slidesPerView * a.slidesPerColumn))); for (var z, P = a.slidesPerColumn, k = C / P, L = Math.floor(u / a.slidesPerColumn), $ = 0; $ < u; $ += 1) { M = 0; var I = p.eq($); if (a.slidesPerColumn > 1) { var O = void 0, A = void 0, D = void 0; if ("row" === a.slidesPerColumnFill && a.slidesPerGroup > 1) { var G = Math.floor($ / (a.slidesPerGroup * a.slidesPerColumn)), N = $ - a.slidesPerColumn * a.slidesPerGroup * G, B = 0 === G ? a.slidesPerGroup : Math.min(Math.ceil((u - G * P * a.slidesPerGroup) / P), a.slidesPerGroup); O = (A = N - (D = Math.floor(N / B)) * B + G * a.slidesPerGroup) + D * C / P, I.css({ "-webkit-box-ordinal-group": O, "-moz-box-ordinal-group": O, "-ms-flex-order": O, "-webkit-order": O, order: O }) } else "column" === a.slidesPerColumnFill ? (D = $ - (A = Math.floor($ / P)) * P, (A > L || A === L && D === P - 1) && (D += 1) >= P && (D = 0, A += 1)) : A = $ - (D = Math.floor($ / k)) * k; I.css("margin-" + (e.isHorizontal() ? "top" : "left"), 0 !== D && a.spaceBetween && a.spaceBetween + "px") } if ("none" !== I.css("display")) { if ("auto" === a.slidesPerView) { var H = t.getComputedStyle(I[0], null), X = I[0].style.transform, Y = I[0].style.webkitTransform; if (X && (I[0].style.transform = "none"), Y && (I[0].style.webkitTransform = "none"), a.roundLengths) M = e.isHorizontal() ? I.outerWidth(!0) : I.outerHeight(!0); else if (e.isHorizontal()) { var V = parseFloat(H.getPropertyValue("width") || 0), F = parseFloat(H.getPropertyValue("padding-left") || 0), R = parseFloat(H.getPropertyValue("padding-right") || 0), W = parseFloat(H.getPropertyValue("margin-left") || 0), q = parseFloat(H.getPropertyValue("margin-right") || 0), j = H.getPropertyValue("box-sizing"); if (j && "border-box" === j) M = V + W + q; else { var _ = I[0], U = _.clientWidth; M = V + F + R + W + q + (_.offsetWidth - U) } } else { var K = parseFloat(H.getPropertyValue("height") || 0), Z = parseFloat(H.getPropertyValue("padding-top") || 0), J = parseFloat(H.getPropertyValue("padding-bottom") || 0), Q = parseFloat(H.getPropertyValue("margin-top") || 0), ee = parseFloat(H.getPropertyValue("margin-bottom") || 0), te = H.getPropertyValue("box-sizing"); if (te && "border-box" === te) M = K + Q + ee; else { var ae = I[0], ie = ae.clientHeight; M = K + Z + J + Q + ee + (ae.offsetHeight - ie) } } X && (I[0].style.transform = X), Y && (I[0].style.webkitTransform = Y), a.roundLengths && (M = Math.floor(M)) } else M = (s - (a.slidesPerView - 1) * b) / a.slidesPerView, a.roundLengths && (M = Math.floor(M)), p[$] && (e.isHorizontal() ? p[$].style.width = M + "px" : p[$].style.height = M + "px"); p[$] && (p[$].swiperSlideSize = M), v.push(M), a.centeredSlides ? (E = E + M / 2 + x / 2 + b, 0 === x && 0 !== $ && (E = E - s / 2 - b), 0 === $ && (E = E - s / 2 - b), Math.abs(E) < .001 && (E = 0), a.roundLengths && (E = Math.floor(E)), T % a.slidesPerGroup == 0 && c.push(E), h.push(E)) : (a.roundLengths && (E = Math.floor(E)), (T - Math.min(e.params.slidesPerGroupSkip, T)) % e.params.slidesPerGroup == 0 && c.push(E), h.push(E), E = E + M + b), e.virtualSize += M + b, x = M, T += 1 } } if (e.virtualSize = Math.max(e.virtualSize, s) + g, r && n && ("slide" === a.effect || "coverflow" === a.effect) && i.css({ width: e.virtualSize + a.spaceBetween + "px" }), a.setWrapperSize && (e.isHorizontal() ? i.css({ width: e.virtualSize + a.spaceBetween + "px" }) : i.css({ height: e.virtualSize + a.spaceBetween + "px" })), a.slidesPerColumn > 1 && (e.virtualSize = (M + a.spaceBetween) * C, e.virtualSize = Math.ceil(e.virtualSize / a.slidesPerColumn) - a.spaceBetween, e.isHorizontal() ? i.css({ width: e.virtualSize + a.spaceBetween + "px" }) : i.css({ height: e.virtualSize + a.spaceBetween + "px" }), a.centeredSlides)) { z = []; for (var se = 0; se < c.length; se += 1) { var re = c[se]; a.roundLengths && (re = Math.floor(re)), c[se] < e.virtualSize + c[0] && z.push(re) } c = z } if (!a.centeredSlides) { z = []; for (var ne = 0; ne < c.length; ne += 1) { var le = c[ne]; a.roundLengths && (le = Math.floor(le)), c[ne] <= e.virtualSize - s && z.push(le) } c = z, Math.floor(e.virtualSize - s) - Math.floor(c[c.length - 1]) > 1 && c.push(e.virtualSize - s) } if (0 === c.length && (c = [0]), 0 !== a.spaceBetween && (e.isHorizontal() ? r ? p.filter(f).css({ marginLeft: b + "px" }) : p.filter(f).css({ marginRight: b + "px" }) : p.filter(f).css({ marginBottom: b + "px" })), a.centeredSlides && a.centeredSlidesBounds) { var oe = 0; v.forEach((function (e) { oe += e + (a.spaceBetween ? a.spaceBetween : 0) })); var de = (oe -= a.spaceBetween) - s; c = c.map((function (e) { return e < 0 ? -m : e > de ? de + g : e })) } if (a.centerInsufficientSlides) { var pe = 0; if (v.forEach((function (e) { pe += e + (a.spaceBetween ? a.spaceBetween : 0) })), (pe -= a.spaceBetween) < s) { var ue = (s - pe) / 2; c.forEach((function (e, t) { c[t] = e - ue })), h.forEach((function (e, t) { h[t] = e + ue })) } } S(e, { slides: p, snapGrid: c, slidesGrid: h, slidesSizesGrid: v }), u !== d && e.emit("slidesLengthChange"), c.length !== y && (e.params.watchOverflow && e.checkOverflow(), e.emit("snapGridLengthChange")), h.length !== w && e.emit("slidesGridLengthChange"), (a.watchSlidesProgress || a.watchSlidesVisibility) && e.updateSlidesOffset() } }, updateAutoHeight: function (e) { var t, a = this, i = [], s = 0; if ("number" == typeof e ? a.setTransition(e) : !0 === e && a.setTransition(a.params.speed), "auto" !== a.params.slidesPerView && a.params.slidesPerView > 1) if (a.params.centeredSlides) a.visibleSlides.each((function (e) { i.push(e) })); else for (t = 0; t < Math.ceil(a.params.slidesPerView); t += 1) { var r = a.activeIndex + t; if (r > a.slides.length) break; i.push(a.slides.eq(r)[0]) } else i.push(a.slides.eq(a.activeIndex)[0]); for (t = 0; t < i.length; t += 1)if (void 0 !== i[t]) { var n = i[t].offsetHeight; s = n > s ? n : s } s && a.$wrapperEl.css("height", s + "px") }, updateSlidesOffset: function () { for (var e = this.slides, t = 0; t < e.length; t += 1)e[t].swiperSlideOffset = this.isHorizontal() ? e[t].offsetLeft : e[t].offsetTop }, updateSlidesProgress: function (e) { void 0 === e && (e = this && this.translate || 0); var t = this, a = t.params, i = t.slides, s = t.rtlTranslate; if (0 !== i.length) { void 0 === i[0].swiperSlideOffset && t.updateSlidesOffset(); var r = -e; s && (r = e), i.removeClass(a.slideVisibleClass), t.visibleSlidesIndexes = [], t.visibleSlides = []; for (var n = 0; n < i.length; n += 1) { var l = i[n], o = (r + (a.centeredSlides ? t.minTranslate() : 0) - l.swiperSlideOffset) / (l.swiperSlideSize + a.spaceBetween); if (a.watchSlidesVisibility || a.centeredSlides && a.autoHeight) { var d = -(r - l.swiperSlideOffset), p = d + t.slidesSizesGrid[n]; (d >= 0 && d < t.size - 1 || p > 1 && p <= t.size || d <= 0 && p >= t.size) && (t.visibleSlides.push(l), t.visibleSlidesIndexes.push(n), i.eq(n).addClass(a.slideVisibleClass)) } l.progress = s ? -o : o } t.visibleSlides = m(t.visibleSlides) } }, updateProgress: function (e) { var t = this; if (void 0 === e) { var a = t.rtlTranslate ? -1 : 1; e = t && t.translate && t.translate * a || 0 } var i = t.params, s = t.maxTranslate() - t.minTranslate(), r = t.progress, n = t.isBeginning, l = t.isEnd, o = n, d = l; 0 === s ? (r = 0, n = !0, l = !0) : (n = (r = (e - t.minTranslate()) / s) <= 0, l = r >= 1), S(t, { progress: r, isBeginning: n, isEnd: l }), (i.watchSlidesProgress || i.watchSlidesVisibility || i.centeredSlides && i.autoHeight) && t.updateSlidesProgress(e), n && !o && t.emit("reachBeginning toEdge"), l && !d && t.emit("reachEnd toEdge"), (o && !n || d && !l) && t.emit("fromEdge"), t.emit("progress", r) }, updateSlidesClasses: function () { var e, t = this, a = t.slides, i = t.params, s = t.$wrapperEl, r = t.activeIndex, n = t.realIndex, l = t.virtual && i.virtual.enabled; a.removeClass(i.slideActiveClass + " " + i.slideNextClass + " " + i.slidePrevClass + " " + i.slideDuplicateActiveClass + " " + i.slideDuplicateNextClass + " " + i.slideDuplicatePrevClass), (e = l ? t.$wrapperEl.find("." + i.slideClass + '[data-swiper-slide-index="' + r + '"]') : a.eq(r)).addClass(i.slideActiveClass), i.loop && (e.hasClass(i.slideDuplicateClass) ? s.children("." + i.slideClass + ":not(." + i.slideDuplicateClass + ')[data-swiper-slide-index="' + n + '"]').addClass(i.slideDuplicateActiveClass) : s.children("." + i.slideClass + "." + i.slideDuplicateClass + '[data-swiper-slide-index="' + n + '"]').addClass(i.slideDuplicateActiveClass)); var o = e.nextAll("." + i.slideClass).eq(0).addClass(i.slideNextClass); i.loop && 0 === o.length && (o = a.eq(0)).addClass(i.slideNextClass); var d = e.prevAll("." + i.slideClass).eq(0).addClass(i.slidePrevClass); i.loop && 0 === d.length && (d = a.eq(-1)).addClass(i.slidePrevClass), i.loop && (o.hasClass(i.slideDuplicateClass) ? s.children("." + i.slideClass + ":not(." + i.slideDuplicateClass + ')[data-swiper-slide-index="' + o.attr("data-swiper-slide-index") + '"]').addClass(i.slideDuplicateNextClass) : s.children("." + i.slideClass + "." + i.slideDuplicateClass + '[data-swiper-slide-index="' + o.attr("data-swiper-slide-index") + '"]').addClass(i.slideDuplicateNextClass), d.hasClass(i.slideDuplicateClass) ? s.children("." + i.slideClass + ":not(." + i.slideDuplicateClass + ')[data-swiper-slide-index="' + d.attr("data-swiper-slide-index") + '"]').addClass(i.slideDuplicatePrevClass) : s.children("." + i.slideClass + "." + i.slideDuplicateClass + '[data-swiper-slide-index="' + d.attr("data-swiper-slide-index") + '"]').addClass(i.slideDuplicatePrevClass)), t.emitSlidesClasses() }, updateActiveIndex: function (e) { var t, a = this, i = a.rtlTranslate ? a.translate : -a.translate, s = a.slidesGrid, r = a.snapGrid, n = a.params, l = a.activeIndex, o = a.realIndex, d = a.snapIndex, p = e; if (void 0 === p) { for (var u = 0; u < s.length; u += 1)void 0 !== s[u + 1] ? i >= s[u] && i < s[u + 1] - (s[u + 1] - s[u]) / 2 ? p = u : i >= s[u] && i < s[u + 1] && (p = u + 1) : i >= s[u] && (p = u); n.normalizeSlideIndex && (p < 0 || void 0 === p) && (p = 0) } if (r.indexOf(i) >= 0) t = r.indexOf(i); else { var c = Math.min(n.slidesPerGroupSkip, p); t = c + Math.floor((p - c) / n.slidesPerGroup) } if (t >= r.length && (t = r.length - 1), p !== l) { var h = parseInt(a.slides.eq(p).attr("data-swiper-slide-index") || p, 10); S(a, { snapIndex: t, realIndex: h, previousIndex: l, activeIndex: p }), a.emit("activeIndexChange"), a.emit("snapIndexChange"), o !== h && a.emit("realIndexChange"), (a.initialized || a.params.runCallbacksOnInit) && a.emit("slideChange") } else t !== d && (a.snapIndex = t, a.emit("snapIndexChange")) }, updateClickedSlide: function (e) { var t = this, a = t.params, i = m(e.target).closest("." + a.slideClass)[0], s = !1; if (i) for (var r = 0; r < t.slides.length; r += 1)t.slides[r] === i && (s = !0); if (!i || !s) return t.clickedSlide = void 0, void (t.clickedIndex = void 0); t.clickedSlide = i, t.virtual && t.params.virtual.enabled ? t.clickedIndex = parseInt(m(i).attr("data-swiper-slide-index"), 10) : t.clickedIndex = m(i).index(), a.slideToClickedSlide && void 0 !== t.clickedIndex && t.clickedIndex !== t.activeIndex && t.slideToClickedSlide() } }, translate: { getTranslate: function (e) { void 0 === e && (e = this.isHorizontal() ? "x" : "y"); var t = this, a = t.params, i = t.rtlTranslate, s = t.translate, r = t.$wrapperEl; if (a.virtualTranslate) return i ? -s : s; if (a.cssMode) return s; var n = T(r[0], e); return i && (n = -n), n || 0 }, setTranslate: function (e, t) { var a = this, i = a.rtlTranslate, s = a.params, r = a.$wrapperEl, n = a.wrapperEl, l = a.progress, o = 0, d = 0; a.isHorizontal() ? o = i ? -e : e : d = e, s.roundLengths && (o = Math.floor(o), d = Math.floor(d)), s.cssMode ? n[a.isHorizontal() ? "scrollLeft" : "scrollTop"] = a.isHorizontal() ? -o : -d : s.virtualTranslate || r.transform("translate3d(" + o + "px, " + d + "px, 0px)"), a.previousTranslate = a.translate, a.translate = a.isHorizontal() ? o : d; var p = a.maxTranslate() - a.minTranslate(); (0 === p ? 0 : (e - a.minTranslate()) / p) !== l && a.updateProgress(e), a.emit("setTranslate", a.translate, t) }, minTranslate: function () { return -this.snapGrid[0] }, maxTranslate: function () { return -this.snapGrid[this.snapGrid.length - 1] }, translateTo: function (e, t, a, i, s) { void 0 === e && (e = 0), void 0 === t && (t = this.params.speed), void 0 === a && (a = !0), void 0 === i && (i = !0); var r = this, n = r.params, l = r.wrapperEl; if (r.animating && n.preventInteractionOnTransition) return !1; var o, d = r.minTranslate(), p = r.maxTranslate(); if (o = i && e > d ? d : i && e < p ? p : e, r.updateProgress(o), n.cssMode) { var u, c = r.isHorizontal(); if (0 === t) l[c ? "scrollLeft" : "scrollTop"] = -o; else if (l.scrollTo) l.scrollTo(((u = {})[c ? "left" : "top"] = -o, u.behavior = "smooth", u)); else l[c ? "scrollLeft" : "scrollTop"] = -o; return !0 } return 0 === t ? (r.setTransition(0), r.setTranslate(o), a && (r.emit("beforeTransitionStart", t, s), r.emit("transitionEnd"))) : (r.setTransition(t), r.setTranslate(o), a && (r.emit("beforeTransitionStart", t, s), r.emit("transitionStart")), r.animating || (r.animating = !0, r.onTranslateToWrapperTransitionEnd || (r.onTranslateToWrapperTransitionEnd = function (e) { r && !r.destroyed && e.target === this && (r.$wrapperEl[0].removeEventListener("transitionend", r.onTranslateToWrapperTransitionEnd), r.$wrapperEl[0].removeEventListener("webkitTransitionEnd", r.onTranslateToWrapperTransitionEnd), r.onTranslateToWrapperTransitionEnd = null, delete r.onTranslateToWrapperTransitionEnd, a && r.emit("transitionEnd")) }), r.$wrapperEl[0].addEventListener("transitionend", r.onTranslateToWrapperTransitionEnd), r.$wrapperEl[0].addEventListener("webkitTransitionEnd", r.onTranslateToWrapperTransitionEnd))), !0 } }, transition: { setTransition: function (e, t) { var a = this; a.params.cssMode || a.$wrapperEl.transition(e), a.emit("setTransition", e, t) }, transitionStart: function (e, t) { void 0 === e && (e = !0); var a = this, i = a.activeIndex, s = a.params, r = a.previousIndex; if (!s.cssMode) { s.autoHeight && a.updateAutoHeight(); var n = t; if (n || (n = i > r ? "next" : i < r ? "prev" : "reset"), a.emit("transitionStart"), e && i !== r) { if ("reset" === n) return void a.emit("slideResetTransitionStart"); a.emit("slideChangeTransitionStart"), "next" === n ? a.emit("slideNextTransitionStart") : a.emit("slidePrevTransitionStart") } } }, transitionEnd: function (e, t) { void 0 === e && (e = !0); var a = this, i = a.activeIndex, s = a.previousIndex, r = a.params; if (a.animating = !1, !r.cssMode) { a.setTransition(0); var n = t; if (n || (n = i > s ? "next" : i < s ? "prev" : "reset"), a.emit("transitionEnd"), e && i !== s) { if ("reset" === n) return void a.emit("slideResetTransitionEnd"); a.emit("slideChangeTransitionEnd"), "next" === n ? a.emit("slideNextTransitionEnd") : a.emit("slidePrevTransitionEnd") } } } }, slide: { slideTo: function (e, t, a, i) { if (void 0 === e && (e = 0), void 0 === t && (t = this.params.speed), void 0 === a && (a = !0), "number" != typeof e && "string" != typeof e) throw new Error("The 'index' argument cannot have type other than 'number' or 'string'. [" + typeof e + "] given."); if ("string" == typeof e) { var s = parseInt(e, 10); if (!isFinite(s)) throw new Error("The passed-in 'index' (string) couldn't be converted to 'number'. [" + e + "] given."); e = s } var r = this, n = e; n < 0 && (n = 0); var l = r.params, o = r.snapGrid, d = r.slidesGrid, p = r.previousIndex, u = r.activeIndex, c = r.rtlTranslate, h = r.wrapperEl; if (r.animating && l.preventInteractionOnTransition) return !1; var v = Math.min(r.params.slidesPerGroupSkip, n), f = v + Math.floor((n - v) / r.params.slidesPerGroup); f >= o.length && (f = o.length - 1), (u || l.initialSlide || 0) === (p || 0) && a && r.emit("beforeSlideChangeStart"); var m, g = -o[f]; if (r.updateProgress(g), l.normalizeSlideIndex) for (var y = 0; y < d.length; y += 1) { var w = -Math.floor(100 * g), b = Math.floor(100 * d[y]), E = Math.floor(100 * d[y + 1]); void 0 !== d[y + 1] ? w >= b && w < E - (E - b) / 2 ? n = y : w >= b && w < E && (n = y + 1) : w >= b && (n = y) } if (r.initialized && n !== u) { if (!r.allowSlideNext && g < r.translate && g < r.minTranslate()) return !1; if (!r.allowSlidePrev && g > r.translate && g > r.maxTranslate() && (u || 0) !== n) return !1 } if (m = n > u ? "next" : n < u ? "prev" : "reset", c && -g === r.translate || !c && g === r.translate) return r.updateActiveIndex(n), l.autoHeight && r.updateAutoHeight(), r.updateSlidesClasses(), "slide" !== l.effect && r.setTranslate(g), "reset" !== m && (r.transitionStart(a, m), r.transitionEnd(a, m)), !1; if (l.cssMode) { var x, T = r.isHorizontal(), C = -g; if (c && (C = h.scrollWidth - h.offsetWidth - C), 0 === t) h[T ? "scrollLeft" : "scrollTop"] = C; else if (h.scrollTo) h.scrollTo(((x = {})[T ? "left" : "top"] = C, x.behavior = "smooth", x)); else h[T ? "scrollLeft" : "scrollTop"] = C; return !0 } return 0 === t ? (r.setTransition(0), r.setTranslate(g), r.updateActiveIndex(n), r.updateSlidesClasses(), r.emit("beforeTransitionStart", t, i), r.transitionStart(a, m), r.transitionEnd(a, m)) : (r.setTransition(t), r.setTranslate(g), r.updateActiveIndex(n), r.updateSlidesClasses(), r.emit("beforeTransitionStart", t, i), r.transitionStart(a, m), r.animating || (r.animating = !0, r.onSlideToWrapperTransitionEnd || (r.onSlideToWrapperTransitionEnd = function (e) { r && !r.destroyed && e.target === this && (r.$wrapperEl[0].removeEventListener("transitionend", r.onSlideToWrapperTransitionEnd), r.$wrapperEl[0].removeEventListener("webkitTransitionEnd", r.onSlideToWrapperTransitionEnd), r.onSlideToWrapperTransitionEnd = null, delete r.onSlideToWrapperTransitionEnd, r.transitionEnd(a, m)) }), r.$wrapperEl[0].addEventListener("transitionend", r.onSlideToWrapperTransitionEnd), r.$wrapperEl[0].addEventListener("webkitTransitionEnd", r.onSlideToWrapperTransitionEnd))), !0 }, slideToLoop: function (e, t, a, i) { void 0 === e && (e = 0), void 0 === t && (t = this.params.speed), void 0 === a && (a = !0); var s = this, r = e; return s.params.loop && (r += s.loopedSlides), s.slideTo(r, t, a, i) }, slideNext: function (e, t, a) { void 0 === e && (e = this.params.speed), void 0 === t && (t = !0); var i = this, s = i.params, r = i.animating, n = i.activeIndex < s.slidesPerGroupSkip ? 1 : s.slidesPerGroup; if (s.loop) { if (r && s.loopPreventsSlide) return !1; i.loopFix(), i._clientLeft = i.$wrapperEl[0].clientLeft } return i.slideTo(i.activeIndex + n, e, t, a) }, slidePrev: function (e, t, a) { void 0 === e && (e = this.params.speed), void 0 === t && (t = !0); var i = this, s = i.params, r = i.animating, n = i.snapGrid, l = i.slidesGrid, o = i.rtlTranslate; if (s.loop) { if (r && s.loopPreventsSlide) return !1; i.loopFix(), i._clientLeft = i.$wrapperEl[0].clientLeft } function d(e) { return e < 0 ? -Math.floor(Math.abs(e)) : Math.floor(e) } var p = d(o ? i.translate : -i.translate), u = n.map((function (e) { return d(e) })); n[u.indexOf(p)]; var c, h = n[u.indexOf(p) - 1]; return void 0 === h && s.cssMode && n.forEach((function (e) { !h && p >= e && (h = e) })), void 0 !== h && (c = l.indexOf(h)) < 0 && (c = i.activeIndex - 1), i.slideTo(c, e, t, a) }, slideReset: function (e, t, a) { return void 0 === e && (e = this.params.speed), void 0 === t && (t = !0), this.slideTo(this.activeIndex, e, t, a) }, slideToClosest: function (e, t, a, i) { void 0 === e && (e = this.params.speed), void 0 === t && (t = !0), void 0 === i && (i = .5); var s = this, r = s.activeIndex, n = Math.min(s.params.slidesPerGroupSkip, r), l = n + Math.floor((r - n) / s.params.slidesPerGroup), o = s.rtlTranslate ? s.translate : -s.translate; if (o >= s.snapGrid[l]) { var d = s.snapGrid[l]; o - d > (s.snapGrid[l + 1] - d) * i && (r += s.params.slidesPerGroup) } else { var p = s.snapGrid[l - 1]; o - p <= (s.snapGrid[l] - p) * i && (r -= s.params.slidesPerGroup) } return r = Math.max(r, 0), r = Math.min(r, s.slidesGrid.length - 1), s.slideTo(r, e, t, a) }, slideToClickedSlide: function () { var e, t = this, a = t.params, i = t.$wrapperEl, s = "auto" === a.slidesPerView ? t.slidesPerViewDynamic() : a.slidesPerView, r = t.clickedIndex; if (a.loop) { if (t.animating) return; e = parseInt(m(t.clickedSlide).attr("data-swiper-slide-index"), 10), a.centeredSlides ? r < t.loopedSlides - s / 2 || r > t.slides.length - t.loopedSlides + s / 2 ? (t.loopFix(), r = i.children("." + a.slideClass + '[data-swiper-slide-index="' + e + '"]:not(.' + a.slideDuplicateClass + ")").eq(0).index(), E((function () { t.slideTo(r) }))) : t.slideTo(r) : r > t.slides.length - s ? (t.loopFix(), r = i.children("." + a.slideClass + '[data-swiper-slide-index="' + e + '"]:not(.' + a.slideDuplicateClass + ")").eq(0).index(), E((function () { t.slideTo(r) }))) : t.slideTo(r) } else t.slideTo(r) } }, loop: { loopCreate: function () { var e = this, t = r(), a = e.params, i = e.$wrapperEl; i.children("." + a.slideClass + "." + a.slideDuplicateClass).remove(); var s = i.children("." + a.slideClass); if (a.loopFillGroupWithBlank) { var n = a.slidesPerGroup - s.length % a.slidesPerGroup; if (n !== a.slidesPerGroup) { for (var l = 0; l < n; l += 1) { var o = m(t.createElement("div")).addClass(a.slideClass + " " + a.slideBlankClass); i.append(o) } s = i.children("." + a.slideClass) } } "auto" !== a.slidesPerView || a.loopedSlides || (a.loopedSlides = s.length), e.loopedSlides = Math.ceil(parseFloat(a.loopedSlides || a.slidesPerView, 10)), e.loopedSlides += a.loopAdditionalSlides, e.loopedSlides > s.length && (e.loopedSlides = s.length); var d = [], p = []; s.each((function (t, a) { var i = m(t); a < e.loopedSlides && p.push(t), a < s.length && a >= s.length - e.loopedSlides && d.push(t), i.attr("data-swiper-slide-index", a) })); for (var u = 0; u < p.length; u += 1)i.append(m(p[u].cloneNode(!0)).addClass(a.slideDuplicateClass)); for (var c = d.length - 1; c >= 0; c -= 1)i.prepend(m(d[c].cloneNode(!0)).addClass(a.slideDuplicateClass)) }, loopFix: function () { var e = this; e.emit("beforeLoopFix"); var t, a = e.activeIndex, i = e.slides, s = e.loopedSlides, r = e.allowSlidePrev, n = e.allowSlideNext, l = e.snapGrid, o = e.rtlTranslate; e.allowSlidePrev = !0, e.allowSlideNext = !0; var d = -l[a] - e.getTranslate(); if (a < s) t = i.length - 3 * s + a, t += s, e.slideTo(t, 0, !1, !0) && 0 !== d && e.setTranslate((o ? -e.translate : e.translate) - d); else if (a >= i.length - s) { t = -i.length + a + s, t += s, e.slideTo(t, 0, !1, !0) && 0 !== d && e.setTranslate((o ? -e.translate : e.translate) - d) } e.allowSlidePrev = r, e.allowSlideNext = n, e.emit("loopFix") }, loopDestroy: function () { var e = this, t = e.$wrapperEl, a = e.params, i = e.slides; t.children("." + a.slideClass + "." + a.slideDuplicateClass + ",." + a.slideClass + "." + a.slideBlankClass).remove(), i.removeAttr("data-swiper-slide-index") } }, grabCursor: { setGrabCursor: function (e) { var t = this; if (!(t.support.touch || !t.params.simulateTouch || t.params.watchOverflow && t.isLocked || t.params.cssMode)) { var a = t.el; a.style.cursor = "move", a.style.cursor = e ? "-webkit-grabbing" : "-webkit-grab", a.style.cursor = e ? "-moz-grabbin" : "-moz-grab", a.style.cursor = e ? "grabbing" : "grab" } }, unsetGrabCursor: function () { var e = this; e.support.touch || e.params.watchOverflow && e.isLocked || e.params.cssMode || (e.el.style.cursor = "") } }, manipulation: { appendSlide: function (e) { var t = this, a = t.$wrapperEl, i = t.params; if (i.loop && t.loopDestroy(), "object" == typeof e && "length" in e) for (var s = 0; s < e.length; s += 1)e[s] && a.append(e[s]); else a.append(e); i.loop && t.loopCreate(), i.observer && t.support.observer || t.update() }, prependSlide: function (e) { var t = this, a = t.params, i = t.$wrapperEl, s = t.activeIndex; a.loop && t.loopDestroy(); var r = s + 1; if ("object" == typeof e && "length" in e) { for (var n = 0; n < e.length; n += 1)e[n] && i.prepend(e[n]); r = s + e.length } else i.prepend(e); a.loop && t.loopCreate(), a.observer && t.support.observer || t.update(), t.slideTo(r, 0, !1) }, addSlide: function (e, t) { var a = this, i = a.$wrapperEl, s = a.params, r = a.activeIndex; s.loop && (r -= a.loopedSlides, a.loopDestroy(), a.slides = i.children("." + s.slideClass)); var n = a.slides.length; if (e <= 0) a.prependSlide(t); else if (e >= n) a.appendSlide(t); else { for (var l = r > e ? r + 1 : r, o = [], d = n - 1; d >= e; d -= 1) { var p = a.slides.eq(d); p.remove(), o.unshift(p) } if ("object" == typeof t && "length" in t) { for (var u = 0; u < t.length; u += 1)t[u] && i.append(t[u]); l = r > e ? r + t.length : r } else i.append(t); for (var c = 0; c < o.length; c += 1)i.append(o[c]); s.loop && a.loopCreate(), s.observer && a.support.observer || a.update(), s.loop ? a.slideTo(l + a.loopedSlides, 0, !1) : a.slideTo(l, 0, !1) } }, removeSlide: function (e) { var t = this, a = t.params, i = t.$wrapperEl, s = t.activeIndex; a.loop && (s -= t.loopedSlides, t.loopDestroy(), t.slides = i.children("." + a.slideClass)); var r, n = s; if ("object" == typeof e && "length" in e) { for (var l = 0; l < e.length; l += 1)r = e[l], t.slides[r] && t.slides.eq(r).remove(), r < n && (n -= 1); n = Math.max(n, 0) } else r = e, t.slides[r] && t.slides.eq(r).remove(), r < n && (n -= 1), n = Math.max(n, 0); a.loop && t.loopCreate(), a.observer && t.support.observer || t.update(), a.loop ? t.slideTo(n + t.loopedSlides, 0, !1) : t.slideTo(n, 0, !1) }, removeAllSlides: function () { for (var e = [], t = 0; t < this.slides.length; t += 1)e.push(t); this.removeSlide(e) } }, events: { attachEvents: function () { var e = this, t = r(), a = e.params, i = e.touchEvents, s = e.el, n = e.wrapperEl, l = e.device, o = e.support; e.onTouchStart = O.bind(e), e.onTouchMove = A.bind(e), e.onTouchEnd = D.bind(e), a.cssMode && (e.onScroll = B.bind(e)), e.onClick = N.bind(e); var d = !!a.nested; if (!o.touch && o.pointerEvents) s.addEventListener(i.start, e.onTouchStart, !1), t.addEventListener(i.move, e.onTouchMove, d), t.addEventListener(i.end, e.onTouchEnd, !1); else { if (o.touch) { var p = !("touchstart" !== i.start || !o.passiveListener || !a.passiveListeners) && { passive: !0, capture: !1 }; s.addEventListener(i.start, e.onTouchStart, p), s.addEventListener(i.move, e.onTouchMove, o.passiveListener ? { passive: !1, capture: d } : d), s.addEventListener(i.end, e.onTouchEnd, p), i.cancel && s.addEventListener(i.cancel, e.onTouchEnd, p), H || (t.addEventListener("touchstart", X), H = !0) } (a.simulateTouch && !l.ios && !l.android || a.simulateTouch && !o.touch && l.ios) && (s.addEventListener("mousedown", e.onTouchStart, !1), t.addEventListener("mousemove", e.onTouchMove, d), t.addEventListener("mouseup", e.onTouchEnd, !1)) } (a.preventClicks || a.preventClicksPropagation) && s.addEventListener("click", e.onClick, !0), a.cssMode && n.addEventListener("scroll", e.onScroll), a.updateOnWindowResize ? e.on(l.ios || l.android ? "resize orientationchange observerUpdate" : "resize observerUpdate", G, !0) : e.on("observerUpdate", G, !0) }, detachEvents: function () { var e = this, t = r(), a = e.params, i = e.touchEvents, s = e.el, n = e.wrapperEl, l = e.device, o = e.support, d = !!a.nested; if (!o.touch && o.pointerEvents) s.removeEventListener(i.start, e.onTouchStart, !1), t.removeEventListener(i.move, e.onTouchMove, d), t.removeEventListener(i.end, e.onTouchEnd, !1); else { if (o.touch) { var p = !("onTouchStart" !== i.start || !o.passiveListener || !a.passiveListeners) && { passive: !0, capture: !1 }; s.removeEventListener(i.start, e.onTouchStart, p), s.removeEventListener(i.move, e.onTouchMove, d), s.removeEventListener(i.end, e.onTouchEnd, p), i.cancel && s.removeEventListener(i.cancel, e.onTouchEnd, p) } (a.simulateTouch && !l.ios && !l.android || a.simulateTouch && !o.touch && l.ios) && (s.removeEventListener("mousedown", e.onTouchStart, !1), t.removeEventListener("mousemove", e.onTouchMove, d), t.removeEventListener("mouseup", e.onTouchEnd, !1)) } (a.preventClicks || a.preventClicksPropagation) && s.removeEventListener("click", e.onClick, !0), a.cssMode && n.removeEventListener("scroll", e.onScroll), e.off(l.ios || l.android ? "resize orientationchange observerUpdate" : "resize observerUpdate", G) } }, breakpoints: { setBreakpoint: function () { var e = this, t = e.activeIndex, a = e.initialized, i = e.loopedSlides, s = void 0 === i ? 0 : i, r = e.params, n = e.$el, l = r.breakpoints; if (l && (!l || 0 !== Object.keys(l).length)) { var o = e.getBreakpoint(l); if (o && e.currentBreakpoint !== o) { var d = o in l ? l[o] : void 0; d && ["slidesPerView", "spaceBetween", "slidesPerGroup", "slidesPerGroupSkip", "slidesPerColumn"].forEach((function (e) { var t = d[e]; void 0 !== t && (d[e] = "slidesPerView" !== e || "AUTO" !== t && "auto" !== t ? "slidesPerView" === e ? parseFloat(t) : parseInt(t, 10) : "auto") })); var p = d || e.originalParams, u = r.slidesPerColumn > 1, c = p.slidesPerColumn > 1; u && !c ? (n.removeClass(r.containerModifierClass + "multirow " + r.containerModifierClass + "multirow-column"), e.emitContainerClasses()) : !u && c && (n.addClass(r.containerModifierClass + "multirow"), "column" === p.slidesPerColumnFill && n.addClass(r.containerModifierClass + "multirow-column"), e.emitContainerClasses()); var h = p.direction && p.direction !== r.direction, v = r.loop && (p.slidesPerView !== r.slidesPerView || h); h && a && e.changeDirection(), S(e.params, p), S(e, { allowTouchMove: e.params.allowTouchMove, allowSlideNext: e.params.allowSlideNext, allowSlidePrev: e.params.allowSlidePrev }), e.currentBreakpoint = o, e.emit("_beforeBreakpoint", p), v && a && (e.loopDestroy(), e.loopCreate(), e.updateSlides(), e.slideTo(t - s + e.loopedSlides, 0, !1)), e.emit("breakpoint", p) } } }, getBreakpoint: function (e) { var t = l(); if (e) { var a = !1, i = Object.keys(e).map((function (e) { if ("string" == typeof e && 0 === e.indexOf("@")) { var a = parseFloat(e.substr(1)); return { value: t.innerHeight * a, point: e } } return { value: e, point: e } })); i.sort((function (e, t) { return parseInt(e.value, 10) - parseInt(t.value, 10) })); for (var s = 0; s < i.length; s += 1) { var r = i[s], n = r.point; r.value <= t.innerWidth && (a = n) } return a || "max" } } }, checkOverflow: { checkOverflow: function () { var e = this, t = e.params, a = e.isLocked, i = e.slides.length > 0 && t.slidesOffsetBefore + t.spaceBetween * (e.slides.length - 1) + e.slides[0].offsetWidth * e.slides.length; t.slidesOffsetBefore && t.slidesOffsetAfter && i ? e.isLocked = i <= e.size : e.isLocked = 1 === e.snapGrid.length, e.allowSlideNext = !e.isLocked, e.allowSlidePrev = !e.isLocked, a !== e.isLocked && e.emit(e.isLocked ? "lock" : "unlock"), a && a !== e.isLocked && (e.isEnd = !1, e.navigation && e.navigation.update()) } }, classes: { addClasses: function () { var e = this, t = e.classNames, a = e.params, i = e.rtl, s = e.$el, r = e.device, n = e.support, l = []; l.push("initialized"), l.push(a.direction), n.pointerEvents && !n.touch && l.push("pointer-events"), a.freeMode && l.push("free-mode"), a.autoHeight && l.push("autoheight"), i && l.push("rtl"), a.slidesPerColumn > 1 && (l.push("multirow"), "column" === a.slidesPerColumnFill && l.push("multirow-column")), r.android && l.push("android"), r.ios && l.push("ios"), a.cssMode && l.push("css-mode"), l.forEach((function (e) { t.push(a.containerModifierClass + e) })), s.addClass(t.join(" ")), e.emitContainerClasses() }, removeClasses: function () { var e = this, t = e.$el, a = e.classNames; t.removeClass(a.join(" ")), e.emitContainerClasses() } }, images: { loadImage: function (e, t, a, i, s, r) { var n, o = l(); function d() { r && r() } m(e).parent("picture")[0] || e.complete && s ? d() : t ? ((n = new o.Image).onload = d, n.onerror = d, i && (n.sizes = i), a && (n.srcset = a), t && (n.src = t)) : d() }, preloadImages: function () { var e = this; function t() { null != e && e && !e.destroyed && (void 0 !== e.imagesLoaded && (e.imagesLoaded += 1), e.imagesLoaded === e.imagesToLoad.length && (e.params.updateOnImagesReady && e.update(), e.emit("imagesReady"))) } e.imagesToLoad = e.$el.find("img"); for (var a = 0; a < e.imagesToLoad.length; a += 1) { var i = e.imagesToLoad[a]; e.loadImage(i, i.currentSrc || i.getAttribute("src"), i.srcset || i.getAttribute("srcset"), i.sizes || i.getAttribute("sizes"), !0, t) } } } }, F = {}, R = function () { function t() { for (var e, a, i = arguments.length, s = new Array(i), r = 0; r < i; r++)s[r] = arguments[r]; if (1 === s.length && s[0].constructor && s[0].constructor === Object ? a = s[0] : (e = s[0], a = s[1]), a || (a = {}), a = S({}, a), e && !a.el && (a.el = e), a.el && m(a.el).length > 1) { var n = []; return m(a.el).each((function (e) { var i = S({}, a, { el: e }); n.push(new t(i)) })), n } var l = this; l.support = z(), l.device = P({ userAgent: a.userAgent }), l.browser = k(), l.eventsListeners = {}, l.eventsAnyListeners = [], void 0 === l.modules && (l.modules = {}), Object.keys(l.modules).forEach((function (e) { var t = l.modules[e]; if (t.params) { var i = Object.keys(t.params)[0], s = t.params[i]; if ("object" != typeof s || null === s) return; if (!(i in a) || !("enabled" in s)) return; !0 === a[i] && (a[i] = { enabled: !0 }), "object" != typeof a[i] || "enabled" in a[i] || (a[i].enabled = !0), a[i] || (a[i] = { enabled: !1 }) } })); var o, d, p = S({}, Y); return l.useParams(p), l.params = S({}, p, F, a), l.originalParams = S({}, l.params), l.passedParams = S({}, a), l.params && l.params.on && Object.keys(l.params.on).forEach((function (e) { l.on(e, l.params.on[e]) })), l.params && l.params.onAny && l.onAny(l.params.onAny), l.$ = m, S(l, { el: e, classNames: [], slides: m(), slidesGrid: [], snapGrid: [], slidesSizesGrid: [], isHorizontal: function () { return "horizontal" === l.params.direction }, isVertical: function () { return "vertical" === l.params.direction }, activeIndex: 0, realIndex: 0, isBeginning: !0, isEnd: !1, translate: 0, previousTranslate: 0, progress: 0, velocity: 0, animating: !1, allowSlideNext: l.params.allowSlideNext, allowSlidePrev: l.params.allowSlidePrev, touchEvents: (o = ["touchstart", "touchmove", "touchend", "touchcancel"], d = ["mousedown", "mousemove", "mouseup"], l.support.pointerEvents && (d = ["pointerdown", "pointermove", "pointerup"]), l.touchEventsTouch = { start: o[0], move: o[1], end: o[2], cancel: o[3] }, l.touchEventsDesktop = { start: d[0], move: d[1], end: d[2] }, l.support.touch || !l.params.simulateTouch ? l.touchEventsTouch : l.touchEventsDesktop), touchEventsData: { isTouched: void 0, isMoved: void 0, allowTouchCallbacks: void 0, touchStartTime: void 0, isScrolling: void 0, currentTranslate: void 0, startTranslate: void 0, allowThresholdMove: void 0, formElements: "input, select, option, textarea, button, video, label", lastClickTime: x(), clickTimeout: void 0, velocities: [], allowMomentumBounce: void 0, isTouchEvent: void 0, startMoving: void 0 }, allowClick: !0, allowTouchMove: l.params.allowTouchMove, touches: { startX: 0, startY: 0, currentX: 0, currentY: 0, diff: 0 }, imagesToLoad: [], imagesLoaded: 0 }), l.useModules(), l.emit("_swiper"), l.params.init && l.init(), l } var a, i, s, r = t.prototype; return r.emitContainerClasses = function () { var e = this; if (e.params._emitClasses && e.el) { var t = e.el.className.split(" ").filter((function (t) { return 0 === t.indexOf("swiper-container") || 0 === t.indexOf(e.params.containerModifierClass) })); e.emit("_containerClasses", t.join(" ")) } }, r.getSlideClasses = function (e) { var t = this; return e.className.split(" ").filter((function (e) { return 0 === e.indexOf("swiper-slide") || 0 === e.indexOf(t.params.slideClass) })).join(" ") }, r.emitSlidesClasses = function () { var e = this; if (e.params._emitClasses && e.el) { var t = []; e.slides.each((function (a) { var i = e.getSlideClasses(a); t.push({ slideEl: a, classNames: i }), e.emit("_slideClass", a, i) })), e.emit("_slideClasses", t) } }, r.slidesPerViewDynamic = function () { var e = this, t = e.params, a = e.slides, i = e.slidesGrid, s = e.size, r = e.activeIndex, n = 1; if (t.centeredSlides) { for (var l, o = a[r].swiperSlideSize, d = r + 1; d < a.length; d += 1)a[d] && !l && (n += 1, (o += a[d].swiperSlideSize) > s && (l = !0)); for (var p = r - 1; p >= 0; p -= 1)a[p] && !l && (n += 1, (o += a[p].swiperSlideSize) > s && (l = !0)) } else for (var u = r + 1; u < a.length; u += 1)i[u] - i[r] < s && (n += 1); return n }, r.update = function () { var e = this; if (e && !e.destroyed) { var t = e.snapGrid, a = e.params; a.breakpoints && e.setBreakpoint(), e.updateSize(), e.updateSlides(), e.updateProgress(), e.updateSlidesClasses(), e.params.freeMode ? (i(), e.params.autoHeight && e.updateAutoHeight()) : (("auto" === e.params.slidesPerView || e.params.slidesPerView > 1) && e.isEnd && !e.params.centeredSlides ? e.slideTo(e.slides.length - 1, 0, !1, !0) : e.slideTo(e.activeIndex, 0, !1, !0)) || i(), a.watchOverflow && t !== e.snapGrid && e.checkOverflow(), e.emit("update") } function i() { var t = e.rtlTranslate ? -1 * e.translate : e.translate, a = Math.min(Math.max(t, e.maxTranslate()), e.minTranslate()); e.setTranslate(a), e.updateActiveIndex(), e.updateSlidesClasses() } }, r.changeDirection = function (e, t) { void 0 === t && (t = !0); var a = this, i = a.params.direction; return e || (e = "horizontal" === i ? "vertical" : "horizontal"), e === i || "horizontal" !== e && "vertical" !== e || (a.$el.removeClass("" + a.params.containerModifierClass + i).addClass("" + a.params.containerModifierClass + e), a.emitContainerClasses(), a.params.direction = e, a.slides.each((function (t) { "vertical" === e ? t.style.width = "" : t.style.height = "" })), a.emit("changeDirection"), t && a.update()), a }, r.mount = function (e) { var t = this; if (t.mounted) return !0; var a, i = m(e || t.params.el); return !!(e = i[0]) && (e.swiper = t, e && e.shadowRoot && e.shadowRoot.querySelector ? (a = m(e.shadowRoot.querySelector("." + t.params.wrapperClass))).children = function (e) { return i.children(e) } : a = i.children("." + t.params.wrapperClass), S(t, { $el: i, el: e, $wrapperEl: a, wrapperEl: a[0], mounted: !0, rtl: "rtl" === e.dir.toLowerCase() || "rtl" === i.css("direction"), rtlTranslate: "horizontal" === t.params.direction && ("rtl" === e.dir.toLowerCase() || "rtl" === i.css("direction")), wrongRTL: "-webkit-box" === a.css("display") }), !0) }, r.init = function (e) { var t = this; return t.initialized || !1 === t.mount(e) || (t.emit("beforeInit"), t.params.breakpoints && t.setBreakpoint(), t.addClasses(), t.params.loop && t.loopCreate(), t.updateSize(), t.updateSlides(), t.params.watchOverflow && t.checkOverflow(), t.params.grabCursor && t.setGrabCursor(), t.params.preloadImages && t.preloadImages(), t.params.loop ? t.slideTo(t.params.initialSlide + t.loopedSlides, 0, t.params.runCallbacksOnInit) : t.slideTo(t.params.initialSlide, 0, t.params.runCallbacksOnInit), t.attachEvents(), t.initialized = !0, t.emit("init"), t.emit("afterInit")), t }, r.destroy = function (e, t) { void 0 === e && (e = !0), void 0 === t && (t = !0); var a, i = this, s = i.params, r = i.$el, n = i.$wrapperEl, l = i.slides; return void 0 === i.params || i.destroyed || (i.emit("beforeDestroy"), i.initialized = !1, i.detachEvents(), s.loop && i.loopDestroy(), t && (i.removeClasses(), r.removeAttr("style"), n.removeAttr("style"), l && l.length && l.removeClass([s.slideVisibleClass, s.slideActiveClass, s.slideNextClass, s.slidePrevClass].join(" ")).removeAttr("style").removeAttr("data-swiper-slide-index")), i.emit("destroy"), Object.keys(i.eventsListeners).forEach((function (e) { i.off(e) })), !1 !== e && (i.$el[0].swiper = null, a = i, Object.keys(a).forEach((function (e) { try { a[e] = null } catch (e) { } try { delete a[e] } catch (e) { } }))), i.destroyed = !0), null }, t.extendDefaults = function (e) { S(F, e) }, t.installModule = function (e) { t.prototype.modules || (t.prototype.modules = {}); var a = e.name || Object.keys(t.prototype.modules).length + "_" + x(); t.prototype.modules[a] = e }, t.use = function (e) { return Array.isArray(e) ? (e.forEach((function (e) { return t.installModule(e) })), t) : (t.installModule(e), t) }, a = t, s = [{ key: "extendedDefaults", get: function () { return F } }, { key: "defaults", get: function () { return Y } }], (i = null) && e(a.prototype, i), s && e(a, s), t }(); Object.keys(V).forEach((function (e) { Object.keys(V[e]).forEach((function (t) { R.prototype[t] = V[e][t] })) })), R.use([L, I]); var W = { update: function (e) { var t = this, a = t.params, i = a.slidesPerView, s = a.slidesPerGroup, r = a.centeredSlides, n = t.params.virtual, l = n.addSlidesBefore, o = n.addSlidesAfter, d = t.virtual, p = d.from, u = d.to, c = d.slides, h = d.slidesGrid, v = d.renderSlide, f = d.offset; t.updateActiveIndex(); var m, g, y, w = t.activeIndex || 0; m = t.rtlTranslate ? "right" : t.isHorizontal() ? "left" : "top", r ? (g = Math.floor(i / 2) + s + o, y = Math.floor(i / 2) + s + l) : (g = i + (s - 1) + o, y = s + l); var b = Math.max((w || 0) - y, 0), E = Math.min((w || 0) + g, c.length - 1), x = (t.slidesGrid[b] || 0) - (t.slidesGrid[0] || 0); function T() { t.updateSlides(), t.updateProgress(), t.updateSlidesClasses(), t.lazy && t.params.lazy.enabled && t.lazy.load() } if (S(t.virtual, { from: b, to: E, offset: x, slidesGrid: t.slidesGrid }), p === b && u === E && !e) return t.slidesGrid !== h && x !== f && t.slides.css(m, x + "px"), void t.updateProgress(); if (t.params.virtual.renderExternal) return t.params.virtual.renderExternal.call(t, { offset: x, from: b, to: E, slides: function () { for (var e = [], t = b; t <= E; t += 1)e.push(c[t]); return e }() }), void (t.params.virtual.renderExternalUpdate && T()); var C = [], M = []; if (e) t.$wrapperEl.find("." + t.params.slideClass).remove(); else for (var z = p; z <= u; z += 1)(z < b || z > E) && t.$wrapperEl.find("." + t.params.slideClass + '[data-swiper-slide-index="' + z + '"]').remove(); for (var P = 0; P < c.length; P += 1)P >= b && P <= E && (void 0 === u || e ? M.push(P) : (P > u && M.push(P), P < p && C.push(P))); M.forEach((function (e) { t.$wrapperEl.append(v(c[e], e)) })), C.sort((function (e, t) { return t - e })).forEach((function (e) { t.$wrapperEl.prepend(v(c[e], e)) })), t.$wrapperEl.children(".swiper-slide").css(m, x + "px"), T() }, renderSlide: function (e, t) { var a = this, i = a.params.virtual; if (i.cache && a.virtual.cache[t]) return a.virtual.cache[t]; var s = i.renderSlide ? m(i.renderSlide.call(a, e, t)) : m('
' + e + "
"); return s.attr("data-swiper-slide-index") || s.attr("data-swiper-slide-index", t), i.cache && (a.virtual.cache[t] = s), s }, appendSlide: function (e) { var t = this; if ("object" == typeof e && "length" in e) for (var a = 0; a < e.length; a += 1)e[a] && t.virtual.slides.push(e[a]); else t.virtual.slides.push(e); t.virtual.update(!0) }, prependSlide: function (e) { var t = this, a = t.activeIndex, i = a + 1, s = 1; if (Array.isArray(e)) { for (var r = 0; r < e.length; r += 1)e[r] && t.virtual.slides.unshift(e[r]); i = a + e.length, s = e.length } else t.virtual.slides.unshift(e); if (t.params.virtual.cache) { var n = t.virtual.cache, l = {}; Object.keys(n).forEach((function (e) { var t = n[e], a = t.attr("data-swiper-slide-index"); a && t.attr("data-swiper-slide-index", parseInt(a, 10) + 1), l[parseInt(e, 10) + s] = t })), t.virtual.cache = l } t.virtual.update(!0), t.slideTo(i, 0) }, removeSlide: function (e) { var t = this; if (null != e) { var a = t.activeIndex; if (Array.isArray(e)) for (var i = e.length - 1; i >= 0; i -= 1)t.virtual.slides.splice(e[i], 1), t.params.virtual.cache && delete t.virtual.cache[e[i]], e[i] < a && (a -= 1), a = Math.max(a, 0); else t.virtual.slides.splice(e, 1), t.params.virtual.cache && delete t.virtual.cache[e], e < a && (a -= 1), a = Math.max(a, 0); t.virtual.update(!0), t.slideTo(a, 0) } }, removeAllSlides: function () { var e = this; e.virtual.slides = [], e.params.virtual.cache && (e.virtual.cache = {}), e.virtual.update(!0), e.slideTo(0, 0) } }, q = { name: "virtual", params: { virtual: { enabled: !1, slides: [], cache: !0, renderSlide: null, renderExternal: null, renderExternalUpdate: !0, addSlidesBefore: 0, addSlidesAfter: 0 } }, create: function () { M(this, { virtual: t({}, W, { slides: this.params.virtual.slides, cache: {} }) }) }, on: { beforeInit: function (e) { if (e.params.virtual.enabled) { e.classNames.push(e.params.containerModifierClass + "virtual"); var t = { watchSlidesProgress: !0 }; S(e.params, t), S(e.originalParams, t), e.params.initialSlide || e.virtual.update() } }, setTranslate: function (e) { e.params.virtual.enabled && e.virtual.update() } } }, j = { handle: function (e) { var t = this, a = l(), i = r(), s = t.rtlTranslate, n = e; n.originalEvent && (n = n.originalEvent); var o = n.keyCode || n.charCode, d = t.params.keyboard.pageUpDown, p = d && 33 === o, u = d && 34 === o, c = 37 === o, h = 39 === o, v = 38 === o, f = 40 === o; if (!t.allowSlideNext && (t.isHorizontal() && h || t.isVertical() && f || u)) return !1; if (!t.allowSlidePrev && (t.isHorizontal() && c || t.isVertical() && v || p)) return !1; if (!(n.shiftKey || n.altKey || n.ctrlKey || n.metaKey || i.activeElement && i.activeElement.nodeName && ("input" === i.activeElement.nodeName.toLowerCase() || "textarea" === i.activeElement.nodeName.toLowerCase()))) { if (t.params.keyboard.onlyInViewport && (p || u || c || h || v || f)) { var m = !1; if (t.$el.parents("." + t.params.slideClass).length > 0 && 0 === t.$el.parents("." + t.params.slideActiveClass).length) return; var g = a.innerWidth, y = a.innerHeight, w = t.$el.offset(); s && (w.left -= t.$el[0].scrollLeft); for (var b = [[w.left, w.top], [w.left + t.width, w.top], [w.left, w.top + t.height], [w.left + t.width, w.top + t.height]], E = 0; E < b.length; E += 1) { var x = b[E]; if (x[0] >= 0 && x[0] <= g && x[1] >= 0 && x[1] <= y) { if (0 === x[0] && 0 === x[1]) continue; m = !0 } } if (!m) return } t.isHorizontal() ? ((p || u || c || h) && (n.preventDefault ? n.preventDefault() : n.returnValue = !1), ((u || h) && !s || (p || c) && s) && t.slideNext(), ((p || c) && !s || (u || h) && s) && t.slidePrev()) : ((p || u || v || f) && (n.preventDefault ? n.preventDefault() : n.returnValue = !1), (u || f) && t.slideNext(), (p || v) && t.slidePrev()), t.emit("keyPress", o) } }, enable: function () { var e = this, t = r(); e.keyboard.enabled || (m(t).on("keydown", e.keyboard.handle), e.keyboard.enabled = !0) }, disable: function () { var e = this, t = r(); e.keyboard.enabled && (m(t).off("keydown", e.keyboard.handle), e.keyboard.enabled = !1) } }, _ = { name: "keyboard", params: { keyboard: { enabled: !1, onlyInViewport: !0, pageUpDown: !0 } }, create: function () { M(this, { keyboard: t({ enabled: !1 }, j) }) }, on: { init: function (e) { e.params.keyboard.enabled && e.keyboard.enable() }, destroy: function (e) { e.keyboard.enabled && e.keyboard.disable() } } }; var U = { lastScrollTime: x(), lastEventBeforeSnap: void 0, recentWheelEvents: [], event: function () { return l().navigator.userAgent.indexOf("firefox") > -1 ? "DOMMouseScroll" : function () { var e = r(), t = "onwheel", a = t in e; if (!a) { var i = e.createElement("div"); i.setAttribute(t, "return;"), a = "function" == typeof i.onwheel } return !a && e.implementation && e.implementation.hasFeature && !0 !== e.implementation.hasFeature("", "") && (a = e.implementation.hasFeature("Events.wheel", "3.0")), a }() ? "wheel" : "mousewheel" }, normalize: function (e) { var t = 0, a = 0, i = 0, s = 0; return "detail" in e && (a = e.detail), "wheelDelta" in e && (a = -e.wheelDelta / 120), "wheelDeltaY" in e && (a = -e.wheelDeltaY / 120), "wheelDeltaX" in e && (t = -e.wheelDeltaX / 120), "axis" in e && e.axis === e.HORIZONTAL_AXIS && (t = a, a = 0), i = 10 * t, s = 10 * a, "deltaY" in e && (s = e.deltaY), "deltaX" in e && (i = e.deltaX), e.shiftKey && !i && (i = s, s = 0), (i || s) && e.deltaMode && (1 === e.deltaMode ? (i *= 40, s *= 40) : (i *= 800, s *= 800)), i && !t && (t = i < 1 ? -1 : 1), s && !a && (a = s < 1 ? -1 : 1), { spinX: t, spinY: a, pixelX: i, pixelY: s } }, handleMouseEnter: function () { this.mouseEntered = !0 }, handleMouseLeave: function () { this.mouseEntered = !1 }, handle: function (e) { var t = e, a = this, i = a.params.mousewheel; a.params.cssMode && t.preventDefault(); var s = a.$el; if ("container" !== a.params.mousewheel.eventsTarget && (s = m(a.params.mousewheel.eventsTarget)), !a.mouseEntered && !s[0].contains(t.target) && !i.releaseOnEdges) return !0; t.originalEvent && (t = t.originalEvent); var r = 0, n = a.rtlTranslate ? -1 : 1, l = U.normalize(t); if (i.forceToAxis) if (a.isHorizontal()) { if (!(Math.abs(l.pixelX) > Math.abs(l.pixelY))) return !0; r = -l.pixelX * n } else { if (!(Math.abs(l.pixelY) > Math.abs(l.pixelX))) return !0; r = -l.pixelY } else r = Math.abs(l.pixelX) > Math.abs(l.pixelY) ? -l.pixelX * n : -l.pixelY; if (0 === r) return !0; i.invert && (r = -r); var o = a.getTranslate() + r * i.sensitivity; if (o >= a.minTranslate() && (o = a.minTranslate()), o <= a.maxTranslate() && (o = a.maxTranslate()), (!!a.params.loop || !(o === a.minTranslate() || o === a.maxTranslate())) && a.params.nested && t.stopPropagation(), a.params.freeMode) { var d = { time: x(), delta: Math.abs(r), direction: Math.sign(r) }, p = a.mousewheel.lastEventBeforeSnap, u = p && d.time < p.time + 500 && d.delta <= p.delta && d.direction === p.direction; if (!u) { a.mousewheel.lastEventBeforeSnap = void 0, a.params.loop && a.loopFix(); var c = a.getTranslate() + r * i.sensitivity, h = a.isBeginning, v = a.isEnd; if (c >= a.minTranslate() && (c = a.minTranslate()), c <= a.maxTranslate() && (c = a.maxTranslate()), a.setTransition(0), a.setTranslate(c), a.updateProgress(), a.updateActiveIndex(), a.updateSlidesClasses(), (!h && a.isBeginning || !v && a.isEnd) && a.updateSlidesClasses(), a.params.freeModeSticky) { clearTimeout(a.mousewheel.timeout), a.mousewheel.timeout = void 0; var f = a.mousewheel.recentWheelEvents; f.length >= 15 && f.shift(); var g = f.length ? f[f.length - 1] : void 0, y = f[0]; if (f.push(d), g && (d.delta > g.delta || d.direction !== g.direction)) f.splice(0); else if (f.length >= 15 && d.time - y.time < 500 && y.delta - d.delta >= 1 && d.delta <= 6) { var w = r > 0 ? .8 : .2; a.mousewheel.lastEventBeforeSnap = d, f.splice(0), a.mousewheel.timeout = E((function () { a.slideToClosest(a.params.speed, !0, void 0, w) }), 0) } a.mousewheel.timeout || (a.mousewheel.timeout = E((function () { a.mousewheel.lastEventBeforeSnap = d, f.splice(0), a.slideToClosest(a.params.speed, !0, void 0, .5) }), 500)) } if (u || a.emit("scroll", t), a.params.autoplay && a.params.autoplayDisableOnInteraction && a.autoplay.stop(), c === a.minTranslate() || c === a.maxTranslate()) return !0 } } else { var b = { time: x(), delta: Math.abs(r), direction: Math.sign(r), raw: e }, T = a.mousewheel.recentWheelEvents; T.length >= 2 && T.shift(); var C = T.length ? T[T.length - 1] : void 0; if (T.push(b), C ? (b.direction !== C.direction || b.delta > C.delta || b.time > C.time + 150) && a.mousewheel.animateSlider(b) : a.mousewheel.animateSlider(b), a.mousewheel.releaseScroll(b)) return !0 } return t.preventDefault ? t.preventDefault() : t.returnValue = !1, !1 }, animateSlider: function (e) { var t = this, a = l(); return !(this.params.mousewheel.thresholdDelta && e.delta < this.params.mousewheel.thresholdDelta) && (!(this.params.mousewheel.thresholdTime && x() - t.mousewheel.lastScrollTime < this.params.mousewheel.thresholdTime) && (e.delta >= 6 && x() - t.mousewheel.lastScrollTime < 60 || (e.direction < 0 ? t.isEnd && !t.params.loop || t.animating || (t.slideNext(), t.emit("scroll", e.raw)) : t.isBeginning && !t.params.loop || t.animating || (t.slidePrev(), t.emit("scroll", e.raw)), t.mousewheel.lastScrollTime = (new a.Date).getTime(), !1))) }, releaseScroll: function (e) { var t = this, a = t.params.mousewheel; if (e.direction < 0) { if (t.isEnd && !t.params.loop && a.releaseOnEdges) return !0 } else if (t.isBeginning && !t.params.loop && a.releaseOnEdges) return !0; return !1 }, enable: function () { var e = this, t = U.event(); if (e.params.cssMode) return e.wrapperEl.removeEventListener(t, e.mousewheel.handle), !0; if (!t) return !1; if (e.mousewheel.enabled) return !1; var a = e.$el; return "container" !== e.params.mousewheel.eventsTarget && (a = m(e.params.mousewheel.eventsTarget)), a.on("mouseenter", e.mousewheel.handleMouseEnter), a.on("mouseleave", e.mousewheel.handleMouseLeave), a.on(t, e.mousewheel.handle), e.mousewheel.enabled = !0, !0 }, disable: function () { var e = this, t = U.event(); if (e.params.cssMode) return e.wrapperEl.addEventListener(t, e.mousewheel.handle), !0; if (!t) return !1; if (!e.mousewheel.enabled) return !1; var a = e.$el; return "container" !== e.params.mousewheel.eventsTarget && (a = m(e.params.mousewheel.eventsTarget)), a.off(t, e.mousewheel.handle), e.mousewheel.enabled = !1, !0 } }, K = { update: function () { var e = this, t = e.params.navigation; if (!e.params.loop) { var a = e.navigation, i = a.$nextEl, s = a.$prevEl; s && s.length > 0 && (e.isBeginning ? s.addClass(t.disabledClass) : s.removeClass(t.disabledClass), s[e.params.watchOverflow && e.isLocked ? "addClass" : "removeClass"](t.lockClass)), i && i.length > 0 && (e.isEnd ? i.addClass(t.disabledClass) : i.removeClass(t.disabledClass), i[e.params.watchOverflow && e.isLocked ? "addClass" : "removeClass"](t.lockClass)) } }, onPrevClick: function (e) { var t = this; e.preventDefault(), t.isBeginning && !t.params.loop || t.slidePrev() }, onNextClick: function (e) { var t = this; e.preventDefault(), t.isEnd && !t.params.loop || t.slideNext() }, init: function () { var e, t, a = this, i = a.params.navigation; (i.nextEl || i.prevEl) && (i.nextEl && (e = m(i.nextEl), a.params.uniqueNavElements && "string" == typeof i.nextEl && e.length > 1 && 1 === a.$el.find(i.nextEl).length && (e = a.$el.find(i.nextEl))), i.prevEl && (t = m(i.prevEl), a.params.uniqueNavElements && "string" == typeof i.prevEl && t.length > 1 && 1 === a.$el.find(i.prevEl).length && (t = a.$el.find(i.prevEl))), e && e.length > 0 && e.on("click", a.navigation.onNextClick), t && t.length > 0 && t.on("click", a.navigation.onPrevClick), S(a.navigation, { $nextEl: e, nextEl: e && e[0], $prevEl: t, prevEl: t && t[0] })) }, destroy: function () { var e = this, t = e.navigation, a = t.$nextEl, i = t.$prevEl; a && a.length && (a.off("click", e.navigation.onNextClick), a.removeClass(e.params.navigation.disabledClass)), i && i.length && (i.off("click", e.navigation.onPrevClick), i.removeClass(e.params.navigation.disabledClass)) } }, Z = { update: function () { var e = this, t = e.rtl, a = e.params.pagination; if (a.el && e.pagination.el && e.pagination.$el && 0 !== e.pagination.$el.length) { var i, s = e.virtual && e.params.virtual.enabled ? e.virtual.slides.length : e.slides.length, r = e.pagination.$el, n = e.params.loop ? Math.ceil((s - 2 * e.loopedSlides) / e.params.slidesPerGroup) : e.snapGrid.length; if (e.params.loop ? ((i = Math.ceil((e.activeIndex - e.loopedSlides) / e.params.slidesPerGroup)) > s - 1 - 2 * e.loopedSlides && (i -= s - 2 * e.loopedSlides), i > n - 1 && (i -= n), i < 0 && "bullets" !== e.params.paginationType && (i = n + i)) : i = void 0 !== e.snapIndex ? e.snapIndex : e.activeIndex || 0, "bullets" === a.type && e.pagination.bullets && e.pagination.bullets.length > 0) { var l, o, d, p = e.pagination.bullets; if (a.dynamicBullets && (e.pagination.bulletSize = p.eq(0)[e.isHorizontal() ? "outerWidth" : "outerHeight"](!0), r.css(e.isHorizontal() ? "width" : "height", e.pagination.bulletSize * (a.dynamicMainBullets + 4) + "px"), a.dynamicMainBullets > 1 && void 0 !== e.previousIndex && (e.pagination.dynamicBulletIndex += i - e.previousIndex, e.pagination.dynamicBulletIndex > a.dynamicMainBullets - 1 ? e.pagination.dynamicBulletIndex = a.dynamicMainBullets - 1 : e.pagination.dynamicBulletIndex < 0 && (e.pagination.dynamicBulletIndex = 0)), l = i - e.pagination.dynamicBulletIndex, d = ((o = l + (Math.min(p.length, a.dynamicMainBullets) - 1)) + l) / 2), p.removeClass(a.bulletActiveClass + " " + a.bulletActiveClass + "-next " + a.bulletActiveClass + "-next-next " + a.bulletActiveClass + "-prev " + a.bulletActiveClass + "-prev-prev " + a.bulletActiveClass + "-main"), r.length > 1) p.each((function (e) { var t = m(e), s = t.index(); s === i && t.addClass(a.bulletActiveClass), a.dynamicBullets && (s >= l && s <= o && t.addClass(a.bulletActiveClass + "-main"), s === l && t.prev().addClass(a.bulletActiveClass + "-prev").prev().addClass(a.bulletActiveClass + "-prev-prev"), s === o && t.next().addClass(a.bulletActiveClass + "-next").next().addClass(a.bulletActiveClass + "-next-next")) })); else { var u = p.eq(i), c = u.index(); if (u.addClass(a.bulletActiveClass), a.dynamicBullets) { for (var h = p.eq(l), v = p.eq(o), f = l; f <= o; f += 1)p.eq(f).addClass(a.bulletActiveClass + "-main"); if (e.params.loop) if (c >= p.length - a.dynamicMainBullets) { for (var g = a.dynamicMainBullets; g >= 0; g -= 1)p.eq(p.length - g).addClass(a.bulletActiveClass + "-main"); p.eq(p.length - a.dynamicMainBullets - 1).addClass(a.bulletActiveClass + "-prev") } else h.prev().addClass(a.bulletActiveClass + "-prev").prev().addClass(a.bulletActiveClass + "-prev-prev"), v.next().addClass(a.bulletActiveClass + "-next").next().addClass(a.bulletActiveClass + "-next-next"); else h.prev().addClass(a.bulletActiveClass + "-prev").prev().addClass(a.bulletActiveClass + "-prev-prev"), v.next().addClass(a.bulletActiveClass + "-next").next().addClass(a.bulletActiveClass + "-next-next") } } if (a.dynamicBullets) { var y = Math.min(p.length, a.dynamicMainBullets + 4), w = (e.pagination.bulletSize * y - e.pagination.bulletSize) / 2 - d * e.pagination.bulletSize, b = t ? "right" : "left"; p.css(e.isHorizontal() ? b : "top", w + "px") } } if ("fraction" === a.type && (r.find("." + a.currentClass).text(a.formatFractionCurrent(i + 1)), r.find("." + a.totalClass).text(a.formatFractionTotal(n))), "progressbar" === a.type) { var E; E = a.progressbarOpposite ? e.isHorizontal() ? "vertical" : "horizontal" : e.isHorizontal() ? "horizontal" : "vertical"; var x = (i + 1) / n, T = 1, C = 1; "horizontal" === E ? T = x : C = x, r.find("." + a.progressbarFillClass).transform("translate3d(0,0,0) scaleX(" + T + ") scaleY(" + C + ")").transition(e.params.speed) } "custom" === a.type && a.renderCustom ? (r.html(a.renderCustom(e, i + 1, n)), e.emit("paginationRender", r[0])) : e.emit("paginationUpdate", r[0]), r[e.params.watchOverflow && e.isLocked ? "addClass" : "removeClass"](a.lockClass) } }, render: function () { var e = this, t = e.params.pagination; if (t.el && e.pagination.el && e.pagination.$el && 0 !== e.pagination.$el.length) { var a = e.virtual && e.params.virtual.enabled ? e.virtual.slides.length : e.slides.length, i = e.pagination.$el, s = ""; if ("bullets" === t.type) { var r = e.params.loop ? Math.ceil((a - 2 * e.loopedSlides) / e.params.slidesPerGroup) : e.snapGrid.length; e.params.freeMode && !e.params.loop && r > a && (r = a); for (var n = 0; n < r; n += 1)t.renderBullet ? s += t.renderBullet.call(e, n, t.bulletClass) : s += "<" + t.bulletElement + ' class="' + t.bulletClass + '">"; i.html(s), e.pagination.bullets = i.find("." + t.bulletClass.replace(/ /g, ".")) } "fraction" === t.type && (s = t.renderFraction ? t.renderFraction.call(e, t.currentClass, t.totalClass) : ' / ', i.html(s)), "progressbar" === t.type && (s = t.renderProgressbar ? t.renderProgressbar.call(e, t.progressbarFillClass) : '', i.html(s)), "custom" !== t.type && e.emit("paginationRender", e.pagination.$el[0]) } }, init: function () { var e = this, t = e.params.pagination; if (t.el) { var a = m(t.el); 0 !== a.length && (e.params.uniqueNavElements && "string" == typeof t.el && a.length > 1 && (a = e.$el.find(t.el)), "bullets" === t.type && t.clickable && a.addClass(t.clickableClass), a.addClass(t.modifierClass + t.type), "bullets" === t.type && t.dynamicBullets && (a.addClass("" + t.modifierClass + t.type + "-dynamic"), e.pagination.dynamicBulletIndex = 0, t.dynamicMainBullets < 1 && (t.dynamicMainBullets = 1)), "progressbar" === t.type && t.progressbarOpposite && a.addClass(t.progressbarOppositeClass), t.clickable && a.on("click", "." + t.bulletClass.replace(/ /g, "."), (function (t) { t.preventDefault(); var a = m(this).index() * e.params.slidesPerGroup; e.params.loop && (a += e.loopedSlides), e.slideTo(a) })), S(e.pagination, { $el: a, el: a[0] })) } }, destroy: function () { var e = this, t = e.params.pagination; if (t.el && e.pagination.el && e.pagination.$el && 0 !== e.pagination.$el.length) { var a = e.pagination.$el; a.removeClass(t.hiddenClass), a.removeClass(t.modifierClass + t.type), e.pagination.bullets && e.pagination.bullets.removeClass(t.bulletActiveClass), t.clickable && a.off("click", "." + t.bulletClass.replace(/ /g, ".")) } } }, J = { setTranslate: function () { var e = this; if (e.params.scrollbar.el && e.scrollbar.el) { var t = e.scrollbar, a = e.rtlTranslate, i = e.progress, s = t.dragSize, r = t.trackSize, n = t.$dragEl, l = t.$el, o = e.params.scrollbar, d = s, p = (r - s) * i; a ? (p = -p) > 0 ? (d = s - p, p = 0) : -p + s > r && (d = r + p) : p < 0 ? (d = s + p, p = 0) : p + s > r && (d = r - p), e.isHorizontal() ? (n.transform("translate3d(" + p + "px, 0, 0)"), n[0].style.width = d + "px") : (n.transform("translate3d(0px, " + p + "px, 0)"), n[0].style.height = d + "px"), o.hide && (clearTimeout(e.scrollbar.timeout), l[0].style.opacity = 1, e.scrollbar.timeout = setTimeout((function () { l[0].style.opacity = 0, l.transition(400) }), 1e3)) } }, setTransition: function (e) { var t = this; t.params.scrollbar.el && t.scrollbar.el && t.scrollbar.$dragEl.transition(e) }, updateSize: function () { var e = this; if (e.params.scrollbar.el && e.scrollbar.el) { var t = e.scrollbar, a = t.$dragEl, i = t.$el; a[0].style.width = "", a[0].style.height = ""; var s, r = e.isHorizontal() ? i[0].offsetWidth : i[0].offsetHeight, n = e.size / e.virtualSize, l = n * (r / e.size); s = "auto" === e.params.scrollbar.dragSize ? r * n : parseInt(e.params.scrollbar.dragSize, 10), e.isHorizontal() ? a[0].style.width = s + "px" : a[0].style.height = s + "px", i[0].style.display = n >= 1 ? "none" : "", e.params.scrollbar.hide && (i[0].style.opacity = 0), S(t, { trackSize: r, Froggyr: n, moveFroggyr: l, dragSize: s }), t.$el[e.params.watchOverflow && e.isLocked ? "addClass" : "removeClass"](e.params.scrollbar.lockClass) } }, getPointerPosition: function (e) { return this.isHorizontal() ? "touchstart" === e.type || "touchmove" === e.type ? e.targetTouches[0].clientX : e.clientX : "touchstart" === e.type || "touchmove" === e.type ? e.targetTouches[0].clientY : e.clientY }, setDragPosition: function (e) { var t, a = this, i = a.scrollbar, s = a.rtlTranslate, r = i.$el, n = i.dragSize, l = i.trackSize, o = i.dragStartPos; t = (i.getPointerPosition(e) - r.offset()[a.isHorizontal() ? "left" : "top"] - (null !== o ? o : n / 2)) / (l - n), t = Math.max(Math.min(t, 1), 0), s && (t = 1 - t); var d = a.minTranslate() + (a.maxTranslate() - a.minTranslate()) * t; a.updateProgress(d), a.setTranslate(d), a.updateActiveIndex(), a.updateSlidesClasses() }, onDragStart: function (e) { var t = this, a = t.params.scrollbar, i = t.scrollbar, s = t.$wrapperEl, r = i.$el, n = i.$dragEl; t.scrollbar.isTouched = !0, t.scrollbar.dragStartPos = e.target === n[0] || e.target === n ? i.getPointerPosition(e) - e.target.getBoundingClientRect()[t.isHorizontal() ? "left" : "top"] : null, e.preventDefault(), e.stopPropagation(), s.transition(100), n.transition(100), i.setDragPosition(e), clearTimeout(t.scrollbar.dragTimeout), r.transition(0), a.hide && r.css("opacity", 1), t.params.cssMode && t.$wrapperEl.css("scroll-snap-type", "none"), t.emit("scrollbarDragStart", e) }, onDragMove: function (e) { var t = this, a = t.scrollbar, i = t.$wrapperEl, s = a.$el, r = a.$dragEl; t.scrollbar.isTouched && (e.preventDefault ? e.preventDefault() : e.returnValue = !1, a.setDragPosition(e), i.transition(0), s.transition(0), r.transition(0), t.emit("scrollbarDragMove", e)) }, onDragEnd: function (e) { var t = this, a = t.params.scrollbar, i = t.scrollbar, s = t.$wrapperEl, r = i.$el; t.scrollbar.isTouched && (t.scrollbar.isTouched = !1, t.params.cssMode && (t.$wrapperEl.css("scroll-snap-type", ""), s.transition("")), a.hide && (clearTimeout(t.scrollbar.dragTimeout), t.scrollbar.dragTimeout = E((function () { r.css("opacity", 0), r.transition(400) }), 1e3)), t.emit("scrollbarDragEnd", e), a.snapOnRelease && t.slideToClosest()) }, enableDraggable: function () { var e = this; if (e.params.scrollbar.el) { var t = r(), a = e.scrollbar, i = e.touchEventsTouch, s = e.touchEventsDesktop, n = e.params, l = e.support, o = a.$el[0], d = !(!l.passiveListener || !n.passiveListeners) && { passive: !1, capture: !1 }, p = !(!l.passiveListener || !n.passiveListeners) && { passive: !0, capture: !1 }; o && (l.touch ? (o.addEventListener(i.start, e.scrollbar.onDragStart, d), o.addEventListener(i.move, e.scrollbar.onDragMove, d), o.addEventListener(i.end, e.scrollbar.onDragEnd, p)) : (o.addEventListener(s.start, e.scrollbar.onDragStart, d), t.addEventListener(s.move, e.scrollbar.onDragMove, d), t.addEventListener(s.end, e.scrollbar.onDragEnd, p))) } }, disableDraggable: function () { var e = this; if (e.params.scrollbar.el) { var t = r(), a = e.scrollbar, i = e.touchEventsTouch, s = e.touchEventsDesktop, n = e.params, l = e.support, o = a.$el[0], d = !(!l.passiveListener || !n.passiveListeners) && { passive: !1, capture: !1 }, p = !(!l.passiveListener || !n.passiveListeners) && { passive: !0, capture: !1 }; o && (l.touch ? (o.removeEventListener(i.start, e.scrollbar.onDragStart, d), o.removeEventListener(i.move, e.scrollbar.onDragMove, d), o.removeEventListener(i.end, e.scrollbar.onDragEnd, p)) : (o.removeEventListener(s.start, e.scrollbar.onDragStart, d), t.removeEventListener(s.move, e.scrollbar.onDragMove, d), t.removeEventListener(s.end, e.scrollbar.onDragEnd, p))) } }, init: function () { var e = this; if (e.params.scrollbar.el) { var t = e.scrollbar, a = e.$el, i = e.params.scrollbar, s = m(i.el); e.params.uniqueNavElements && "string" == typeof i.el && s.length > 1 && 1 === a.find(i.el).length && (s = a.find(i.el)); var r = s.find("." + e.params.scrollbar.dragClass); 0 === r.length && (r = m('
'), s.append(r)), S(t, { $el: s, el: s[0], $dragEl: r, dragEl: r[0] }), i.draggable && t.enableDraggable() } }, destroy: function () { this.scrollbar.disableDraggable() } }, Q = { setTransform: function (e, t) { var a = this.rtl, i = m(e), s = a ? -1 : 1, r = i.attr("data-swiper-parallax") || "0", n = i.attr("data-swiper-parallax-x"), l = i.attr("data-swiper-parallax-y"), o = i.attr("data-swiper-parallax-scale"), d = i.attr("data-swiper-parallax-opacity"); if (n || l ? (n = n || "0", l = l || "0") : this.isHorizontal() ? (n = r, l = "0") : (l = r, n = "0"), n = n.indexOf("%") >= 0 ? parseInt(n, 10) * t * s + "%" : n * t * s + "px", l = l.indexOf("%") >= 0 ? parseInt(l, 10) * t + "%" : l * t + "px", null != d) { var p = d - (d - 1) * (1 - Math.abs(t)); i[0].style.opacity = p } if (null == o) i.transform("translate3d(" + n + ", " + l + ", 0px)"); else { var u = o - (o - 1) * (1 - Math.abs(t)); i.transform("translate3d(" + n + ", " + l + ", 0px) scale(" + u + ")") } }, setTranslate: function () { var e = this, t = e.$el, a = e.slides, i = e.progress, s = e.snapGrid; t.children("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((function (t) { e.parallax.setTransform(t, i) })), a.each((function (t, a) { var r = t.progress; e.params.slidesPerGroup > 1 && "auto" !== e.params.slidesPerView && (r += Math.ceil(a / 2) - i * (s.length - 1)), r = Math.min(Math.max(r, -1), 1), m(t).find("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((function (t) { e.parallax.setTransform(t, r) })) })) }, setTransition: function (e) { void 0 === e && (e = this.params.speed); this.$el.find("[data-swiper-parallax], [data-swiper-parallax-x], [data-swiper-parallax-y], [data-swiper-parallax-opacity], [data-swiper-parallax-scale]").each((function (t) { var a = m(t), i = parseInt(a.attr("data-swiper-parallax-duration"), 10) || e; 0 === e && (i = 0), a.transition(i) })) } }, ee = { getDistanceBetweenTouches: function (e) { if (e.targetTouches.length < 2) return 1; var t = e.targetTouches[0].pageX, a = e.targetTouches[0].pageY, i = e.targetTouches[1].pageX, s = e.targetTouches[1].pageY; return Math.sqrt(Math.pow(i - t, 2) + Math.pow(s - a, 2)) }, onGestureStart: function (e) { var t = this, a = t.support, i = t.params.zoom, s = t.zoom, r = s.gesture; if (s.fakeGestureTouched = !1, s.fakeGestureMoved = !1, !a.gestures) { if ("touchstart" !== e.type || "touchstart" === e.type && e.targetTouches.length < 2) return; s.fakeGestureTouched = !0, r.scaleStart = ee.getDistanceBetweenTouches(e) } r.$slideEl && r.$slideEl.length || (r.$slideEl = m(e.target).closest("." + t.params.slideClass), 0 === r.$slideEl.length && (r.$slideEl = t.slides.eq(t.activeIndex)), r.$imageEl = r.$slideEl.find("img, svg, canvas, picture, .swiper-zoom-target"), r.$imageWrapEl = r.$imageEl.parent("." + i.containerClass), r.maxRatio = r.$imageWrapEl.attr("data-swiper-zoom") || i.maxRatio, 0 !== r.$imageWrapEl.length) ? (r.$imageEl && r.$imageEl.transition(0), t.zoom.isScaling = !0) : r.$imageEl = void 0 }, onGestureChange: function (e) { var t = this, a = t.support, i = t.params.zoom, s = t.zoom, r = s.gesture; if (!a.gestures) { if ("touchmove" !== e.type || "touchmove" === e.type && e.targetTouches.length < 2) return; s.fakeGestureMoved = !0, r.scaleMove = ee.getDistanceBetweenTouches(e) } r.$imageEl && 0 !== r.$imageEl.length ? (a.gestures ? s.scale = e.scale * s.currentScale : s.scale = r.scaleMove / r.scaleStart * s.currentScale, s.scale > r.maxRatio && (s.scale = r.maxRatio - 1 + Math.pow(s.scale - r.maxRatio + 1, .5)), s.scale < i.minRatio && (s.scale = i.minRatio + 1 - Math.pow(i.minRatio - s.scale + 1, .5)), r.$imageEl.transform("translate3d(0,0,0) scale(" + s.scale + ")")) : "gesturechange" === e.type && s.onGestureStart(e) }, onGestureEnd: function (e) { var t = this, a = t.device, i = t.support, s = t.params.zoom, r = t.zoom, n = r.gesture; if (!i.gestures) { if (!r.fakeGestureTouched || !r.fakeGestureMoved) return; if ("touchend" !== e.type || "touchend" === e.type && e.changedTouches.length < 2 && !a.android) return; r.fakeGestureTouched = !1, r.fakeGestureMoved = !1 } n.$imageEl && 0 !== n.$imageEl.length && (r.scale = Math.max(Math.min(r.scale, n.maxRatio), s.minRatio), n.$imageEl.transition(t.params.speed).transform("translate3d(0,0,0) scale(" + r.scale + ")"), r.currentScale = r.scale, r.isScaling = !1, 1 === r.scale && (n.$slideEl = void 0)) }, onTouchStart: function (e) { var t = this.device, a = this.zoom, i = a.gesture, s = a.image; i.$imageEl && 0 !== i.$imageEl.length && (s.isTouched || (t.android && e.cancelable && e.preventDefault(), s.isTouched = !0, s.touchesStart.x = "touchstart" === e.type ? e.targetTouches[0].pageX : e.pageX, s.touchesStart.y = "touchstart" === e.type ? e.targetTouches[0].pageY : e.pageY)) }, onTouchMove: function (e) { var t = this, a = t.zoom, i = a.gesture, s = a.image, r = a.velocity; if (i.$imageEl && 0 !== i.$imageEl.length && (t.allowClick = !1, s.isTouched && i.$slideEl)) { s.isMoved || (s.width = i.$imageEl[0].offsetWidth, s.height = i.$imageEl[0].offsetHeight, s.startX = T(i.$imageWrapEl[0], "x") || 0, s.startY = T(i.$imageWrapEl[0], "y") || 0, i.slideWidth = i.$slideEl[0].offsetWidth, i.slideHeight = i.$slideEl[0].offsetHeight, i.$imageWrapEl.transition(0), t.rtl && (s.startX = -s.startX, s.startY = -s.startY)); var n = s.width * a.scale, l = s.height * a.scale; if (!(n < i.slideWidth && l < i.slideHeight)) { if (s.minX = Math.min(i.slideWidth / 2 - n / 2, 0), s.maxX = -s.minX, s.minY = Math.min(i.slideHeight / 2 - l / 2, 0), s.maxY = -s.minY, s.touchesCurrent.x = "touchmove" === e.type ? e.targetTouches[0].pageX : e.pageX, s.touchesCurrent.y = "touchmove" === e.type ? e.targetTouches[0].pageY : e.pageY, !s.isMoved && !a.isScaling) { if (t.isHorizontal() && (Math.floor(s.minX) === Math.floor(s.startX) && s.touchesCurrent.x < s.touchesStart.x || Math.floor(s.maxX) === Math.floor(s.startX) && s.touchesCurrent.x > s.touchesStart.x)) return void (s.isTouched = !1); if (!t.isHorizontal() && (Math.floor(s.minY) === Math.floor(s.startY) && s.touchesCurrent.y < s.touchesStart.y || Math.floor(s.maxY) === Math.floor(s.startY) && s.touchesCurrent.y > s.touchesStart.y)) return void (s.isTouched = !1) } e.cancelable && e.preventDefault(), e.stopPropagation(), s.isMoved = !0, s.currentX = s.touchesCurrent.x - s.touchesStart.x + s.startX, s.currentY = s.touchesCurrent.y - s.touchesStart.y + s.startY, s.currentX < s.minX && (s.currentX = s.minX + 1 - Math.pow(s.minX - s.currentX + 1, .8)), s.currentX > s.maxX && (s.currentX = s.maxX - 1 + Math.pow(s.currentX - s.maxX + 1, .8)), s.currentY < s.minY && (s.currentY = s.minY + 1 - Math.pow(s.minY - s.currentY + 1, .8)), s.currentY > s.maxY && (s.currentY = s.maxY - 1 + Math.pow(s.currentY - s.maxY + 1, .8)), r.prevPositionX || (r.prevPositionX = s.touchesCurrent.x), r.prevPositionY || (r.prevPositionY = s.touchesCurrent.y), r.prevTime || (r.prevTime = Date.now()), r.x = (s.touchesCurrent.x - r.prevPositionX) / (Date.now() - r.prevTime) / 2, r.y = (s.touchesCurrent.y - r.prevPositionY) / (Date.now() - r.prevTime) / 2, Math.abs(s.touchesCurrent.x - r.prevPositionX) < 2 && (r.x = 0), Math.abs(s.touchesCurrent.y - r.prevPositionY) < 2 && (r.y = 0), r.prevPositionX = s.touchesCurrent.x, r.prevPositionY = s.touchesCurrent.y, r.prevTime = Date.now(), i.$imageWrapEl.transform("translate3d(" + s.currentX + "px, " + s.currentY + "px,0)") } } }, onTouchEnd: function () { var e = this.zoom, t = e.gesture, a = e.image, i = e.velocity; if (t.$imageEl && 0 !== t.$imageEl.length) { if (!a.isTouched || !a.isMoved) return a.isTouched = !1, void (a.isMoved = !1); a.isTouched = !1, a.isMoved = !1; var s = 300, r = 300, n = i.x * s, l = a.currentX + n, o = i.y * r, d = a.currentY + o; 0 !== i.x && (s = Math.abs((l - a.currentX) / i.x)), 0 !== i.y && (r = Math.abs((d - a.currentY) / i.y)); var p = Math.max(s, r); a.currentX = l, a.currentY = d; var u = a.width * e.scale, c = a.height * e.scale; a.minX = Math.min(t.slideWidth / 2 - u / 2, 0), a.maxX = -a.minX, a.minY = Math.min(t.slideHeight / 2 - c / 2, 0), a.maxY = -a.minY, a.currentX = Math.max(Math.min(a.currentX, a.maxX), a.minX), a.currentY = Math.max(Math.min(a.currentY, a.maxY), a.minY), t.$imageWrapEl.transition(p).transform("translate3d(" + a.currentX + "px, " + a.currentY + "px,0)") } }, onTransitionEnd: function () { var e = this, t = e.zoom, a = t.gesture; a.$slideEl && e.previousIndex !== e.activeIndex && (a.$imageEl && a.$imageEl.transform("translate3d(0,0,0) scale(1)"), a.$imageWrapEl && a.$imageWrapEl.transform("translate3d(0,0,0)"), t.scale = 1, t.currentScale = 1, a.$slideEl = void 0, a.$imageEl = void 0, a.$imageWrapEl = void 0) }, toggle: function (e) { var t = this.zoom; t.scale && 1 !== t.scale ? t.out() : t.in(e) }, in: function (e) { var t, a, i, s, r, n, o, d, p, u, c, h, v, f, m, g, y = this, w = l(), b = y.zoom, E = y.params.zoom, x = b.gesture, T = b.image; (x.$slideEl || (y.params.virtual && y.params.virtual.enabled && y.virtual ? x.$slideEl = y.$wrapperEl.children("." + y.params.slideActiveClass) : x.$slideEl = y.slides.eq(y.activeIndex), x.$imageEl = x.$slideEl.find("img, svg, canvas, picture, .swiper-zoom-target"), x.$imageWrapEl = x.$imageEl.parent("." + E.containerClass)), x.$imageEl && 0 !== x.$imageEl.length) && (x.$slideEl.addClass("" + E.zoomedSlideClass), void 0 === T.touchesStart.x && e ? (t = "touchend" === e.type ? e.changedTouches[0].pageX : e.pageX, a = "touchend" === e.type ? e.changedTouches[0].pageY : e.pageY) : (t = T.touchesStart.x, a = T.touchesStart.y), b.scale = x.$imageWrapEl.attr("data-swiper-zoom") || E.maxRatio, b.currentScale = x.$imageWrapEl.attr("data-swiper-zoom") || E.maxRatio, e ? (m = x.$slideEl[0].offsetWidth, g = x.$slideEl[0].offsetHeight, i = x.$slideEl.offset().left + w.scrollX + m / 2 - t, s = x.$slideEl.offset().top + w.scrollY + g / 2 - a, o = x.$imageEl[0].offsetWidth, d = x.$imageEl[0].offsetHeight, p = o * b.scale, u = d * b.scale, v = -(c = Math.min(m / 2 - p / 2, 0)), f = -(h = Math.min(g / 2 - u / 2, 0)), (r = i * b.scale) < c && (r = c), r > v && (r = v), (n = s * b.scale) < h && (n = h), n > f && (n = f)) : (r = 0, n = 0), x.$imageWrapEl.transition(300).transform("translate3d(" + r + "px, " + n + "px,0)"), x.$imageEl.transition(300).transform("translate3d(0,0,0) scale(" + b.scale + ")")) }, out: function () { var e = this, t = e.zoom, a = e.params.zoom, i = t.gesture; i.$slideEl || (e.params.virtual && e.params.virtual.enabled && e.virtual ? i.$slideEl = e.$wrapperEl.children("." + e.params.slideActiveClass) : i.$slideEl = e.slides.eq(e.activeIndex), i.$imageEl = i.$slideEl.find("img, svg, canvas, picture, .swiper-zoom-target"), i.$imageWrapEl = i.$imageEl.parent("." + a.containerClass)), i.$imageEl && 0 !== i.$imageEl.length && (t.scale = 1, t.currentScale = 1, i.$imageWrapEl.transition(300).transform("translate3d(0,0,0)"), i.$imageEl.transition(300).transform("translate3d(0,0,0) scale(1)"), i.$slideEl.removeClass("" + a.zoomedSlideClass), i.$slideEl = void 0) }, toggleGestures: function (e) { var t = this, a = t.zoom, i = a.slideSelector, s = a.passiveListener; t.$wrapperEl[e]("gesturestart", i, a.onGestureStart, s), t.$wrapperEl[e]("gesturechange", i, a.onGestureChange, s), t.$wrapperEl[e]("gestureend", i, a.onGestureEnd, s) }, enableGestures: function () { this.zoom.gesturesEnabled || (this.zoom.gesturesEnabled = !0, this.zoom.toggleGestures("on")) }, disableGestures: function () { this.zoom.gesturesEnabled && (this.zoom.gesturesEnabled = !1, this.zoom.toggleGestures("off")) }, enable: function () { var e = this, t = e.support, a = e.zoom; if (!a.enabled) { a.enabled = !0; var i = !("touchstart" !== e.touchEvents.start || !t.passiveListener || !e.params.passiveListeners) && { passive: !0, capture: !1 }, s = !t.passiveListener || { passive: !1, capture: !0 }, r = "." + e.params.slideClass; e.zoom.passiveListener = i, e.zoom.slideSelector = r, t.gestures ? (e.$wrapperEl.on(e.touchEvents.start, e.zoom.enableGestures, i), e.$wrapperEl.on(e.touchEvents.end, e.zoom.disableGestures, i)) : "touchstart" === e.touchEvents.start && (e.$wrapperEl.on(e.touchEvents.start, r, a.onGestureStart, i), e.$wrapperEl.on(e.touchEvents.move, r, a.onGestureChange, s), e.$wrapperEl.on(e.touchEvents.end, r, a.onGestureEnd, i), e.touchEvents.cancel && e.$wrapperEl.on(e.touchEvents.cancel, r, a.onGestureEnd, i)), e.$wrapperEl.on(e.touchEvents.move, "." + e.params.zoom.containerClass, a.onTouchMove, s) } }, disable: function () { var e = this, t = e.zoom; if (t.enabled) { var a = e.support; e.zoom.enabled = !1; var i = !("touchstart" !== e.touchEvents.start || !a.passiveListener || !e.params.passiveListeners) && { passive: !0, capture: !1 }, s = !a.passiveListener || { passive: !1, capture: !0 }, r = "." + e.params.slideClass; a.gestures ? (e.$wrapperEl.off(e.touchEvents.start, e.zoom.enableGestures, i), e.$wrapperEl.off(e.touchEvents.end, e.zoom.disableGestures, i)) : "touchstart" === e.touchEvents.start && (e.$wrapperEl.off(e.touchEvents.start, r, t.onGestureStart, i), e.$wrapperEl.off(e.touchEvents.move, r, t.onGestureChange, s), e.$wrapperEl.off(e.touchEvents.end, r, t.onGestureEnd, i), e.touchEvents.cancel && e.$wrapperEl.off(e.touchEvents.cancel, r, t.onGestureEnd, i)), e.$wrapperEl.off(e.touchEvents.move, "." + e.params.zoom.containerClass, t.onTouchMove, s) } } }, te = { loadInSlide: function (e, t) { void 0 === t && (t = !0); var a = this, i = a.params.lazy; if (void 0 !== e && 0 !== a.slides.length) { var s = a.virtual && a.params.virtual.enabled ? a.$wrapperEl.children("." + a.params.slideClass + '[data-swiper-slide-index="' + e + '"]') : a.slides.eq(e), r = s.find("." + i.elementClass + ":not(." + i.loadedClass + "):not(." + i.loadingClass + ")"); !s.hasClass(i.elementClass) || s.hasClass(i.loadedClass) || s.hasClass(i.loadingClass) || r.push(s[0]), 0 !== r.length && r.each((function (e) { var r = m(e); r.addClass(i.loadingClass); var n = r.attr("data-background"), l = r.attr("data-src"), o = r.attr("data-srcset"), d = r.attr("data-sizes"), p = r.parent("picture"); a.loadImage(r[0], l || n, o, d, !1, (function () { if (null != a && a && (!a || a.params) && !a.destroyed) { if (n ? (r.css("background-image", 'url("' + n + '")'), r.removeAttr("data-background")) : (o && (r.attr("srcset", o), r.removeAttr("data-srcset")), d && (r.attr("sizes", d), r.removeAttr("data-sizes")), p.length && p.children("source").each((function (e) { var t = m(e); t.attr("data-srcset") && (t.attr("srcset", t.attr("data-srcset")), t.removeAttr("data-srcset")) })), l && (r.attr("src", l), r.removeAttr("data-src"))), r.addClass(i.loadedClass).removeClass(i.loadingClass), s.find("." + i.preloaderClass).remove(), a.params.loop && t) { var e = s.attr("data-swiper-slide-index"); if (s.hasClass(a.params.slideDuplicateClass)) { var u = a.$wrapperEl.children('[data-swiper-slide-index="' + e + '"]:not(.' + a.params.slideDuplicateClass + ")"); a.lazy.loadInSlide(u.index(), !1) } else { var c = a.$wrapperEl.children("." + a.params.slideDuplicateClass + '[data-swiper-slide-index="' + e + '"]'); a.lazy.loadInSlide(c.index(), !1) } } a.emit("lazyImageReady", s[0], r[0]), a.params.autoHeight && a.updateAutoHeight() } })), a.emit("lazyImageLoad", s[0], r[0]) })) } }, load: function () { var e = this, t = e.$wrapperEl, a = e.params, i = e.slides, s = e.activeIndex, r = e.virtual && a.virtual.enabled, n = a.lazy, l = a.slidesPerView; function o(e) { if (r) { if (t.children("." + a.slideClass + '[data-swiper-slide-index="' + e + '"]').length) return !0 } else if (i[e]) return !0; return !1 } function d(e) { return r ? m(e).attr("data-swiper-slide-index") : m(e).index() } if ("auto" === l && (l = 0), e.lazy.initialImageLoaded || (e.lazy.initialImageLoaded = !0), e.params.watchSlidesVisibility) t.children("." + a.slideVisibleClass).each((function (t) { var a = r ? m(t).attr("data-swiper-slide-index") : m(t).index(); e.lazy.loadInSlide(a) })); else if (l > 1) for (var p = s; p < s + l; p += 1)o(p) && e.lazy.loadInSlide(p); else e.lazy.loadInSlide(s); if (n.loadPrevNext) if (l > 1 || n.loadPrevNextAmount && n.loadPrevNextAmount > 1) { for (var u = n.loadPrevNextAmount, c = l, h = Math.min(s + c + Math.max(u, c), i.length), v = Math.max(s - Math.max(c, u), 0), f = s + l; f < h; f += 1)o(f) && e.lazy.loadInSlide(f); for (var g = v; g < s; g += 1)o(g) && e.lazy.loadInSlide(g) } else { var y = t.children("." + a.slideNextClass); y.length > 0 && e.lazy.loadInSlide(d(y)); var w = t.children("." + a.slidePrevClass); w.length > 0 && e.lazy.loadInSlide(d(w)) } }, checkInViewOnLoad: function () { var e = l(), t = this; if (t && !t.destroyed) { var a = t.params.lazy.scrollingElement ? m(t.params.lazy.scrollingElement) : m(e), i = a[0] === e, s = i ? e.innerWidth : a[0].offsetWidth, r = i ? e.innerHeight : a[0].offsetHeight, n = t.$el.offset(), o = !1; t.rtlTranslate && (n.left -= t.$el[0].scrollLeft); for (var d = [[n.left, n.top], [n.left + t.width, n.top], [n.left, n.top + t.height], [n.left + t.width, n.top + t.height]], p = 0; p < d.length; p += 1) { var u = d[p]; if (u[0] >= 0 && u[0] <= s && u[1] >= 0 && u[1] <= r) { if (0 === u[0] && 0 === u[1]) continue; o = !0 } } o ? (t.lazy.load(), a.off("scroll", t.lazy.checkInViewOnLoad)) : t.lazy.scrollHandlerAttached || (t.lazy.scrollHandlerAttached = !0, a.on("scroll", t.lazy.checkInViewOnLoad)) } } }, ae = { LinearSpline: function (e, t) { var a, i, s, r, n, l = function (e, t) { for (i = -1, a = e.length; a - i > 1;)e[s = a + i >> 1] <= t ? i = s : a = s; return a }; return this.x = e, this.y = t, this.lastIndex = e.length - 1, this.interpolate = function (e) { return e ? (n = l(this.x, e), r = n - 1, (e - this.x[r]) * (this.y[n] - this.y[r]) / (this.x[n] - this.x[r]) + this.y[r]) : 0 }, this }, getInterpolateFunction: function (e) { var t = this; t.controller.spline || (t.controller.spline = t.params.loop ? new ae.LinearSpline(t.slidesGrid, e.slidesGrid) : new ae.LinearSpline(t.snapGrid, e.snapGrid)) }, setTranslate: function (e, t) { var a, i, s = this, r = s.controller.control, n = s.constructor; function l(e) { var t = s.rtlTranslate ? -s.translate : s.translate; "slide" === s.params.controller.by && (s.controller.getInterpolateFunction(e), i = -s.controller.spline.interpolate(-t)), i && "container" !== s.params.controller.by || (a = (e.maxTranslate() - e.minTranslate()) / (s.maxTranslate() - s.minTranslate()), i = (t - s.minTranslate()) * a + e.minTranslate()), s.params.controller.inverse && (i = e.maxTranslate() - i), e.updateProgress(i), e.setTranslate(i, s), e.updateActiveIndex(), e.updateSlidesClasses() } if (Array.isArray(r)) for (var o = 0; o < r.length; o += 1)r[o] !== t && r[o] instanceof n && l(r[o]); else r instanceof n && t !== r && l(r) }, setTransition: function (e, t) { var a, i = this, s = i.constructor, r = i.controller.control; function n(t) { t.setTransition(e, i), 0 !== e && (t.transitionStart(), t.params.autoHeight && E((function () { t.updateAutoHeight() })), t.$wrapperEl.transitionEnd((function () { r && (t.params.loop && "slide" === i.params.controller.by && t.loopFix(), t.transitionEnd()) }))) } if (Array.isArray(r)) for (a = 0; a < r.length; a += 1)r[a] !== t && r[a] instanceof s && n(r[a]); else r instanceof s && t !== r && n(r) } }, ie = { getRandomNumber: function (e) { void 0 === e && (e = 16); return "x".repeat(e).replace(/x/g, (function () { return Math.round(16 * Math.random()).toString(16) })) }, makeElFocusable: function (e) { return e.attr("tabIndex", "0"), e }, makeElNotFocusable: function (e) { return e.attr("tabIndex", "-1"), e }, addElRole: function (e, t) { return e.attr("role", t), e }, addElRoleDescription: function (e, t) { return e.attr("aria-role-description", t), e }, addElControls: function (e, t) { return e.attr("aria-controls", t), e }, addElLabel: function (e, t) { return e.attr("aria-label", t), e }, addElId: function (e, t) { return e.attr("id", t), e }, addElLive: function (e, t) { return e.attr("aria-live", t), e }, disableEl: function (e) { return e.attr("aria-disabled", !0), e }, enableEl: function (e) { return e.attr("aria-disabled", !1), e }, onEnterKey: function (e) { var t = this, a = t.params.a11y; if (13 === e.keyCode) { var i = m(e.target); t.navigation && t.navigation.$nextEl && i.is(t.navigation.$nextEl) && (t.isEnd && !t.params.loop || t.slideNext(), t.isEnd ? t.a11y.notify(a.lastSlideMessage) : t.a11y.notify(a.nextSlideMessage)), t.navigation && t.navigation.$prevEl && i.is(t.navigation.$prevEl) && (t.isBeginning && !t.params.loop || t.slidePrev(), t.isBeginning ? t.a11y.notify(a.firstSlideMessage) : t.a11y.notify(a.prevSlideMessage)), t.pagination && i.is("." + t.params.pagination.bulletClass.replace(/ /g, ".")) && i[0].click() } }, notify: function (e) { var t = this.a11y.liveRegion; 0 !== t.length && (t.html(""), t.html(e)) }, updateNavigation: function () { var e = this; if (!e.params.loop && e.navigation) { var t = e.navigation, a = t.$nextEl, i = t.$prevEl; i && i.length > 0 && (e.isBeginning ? (e.a11y.disableEl(i), e.a11y.makeElNotFocusable(i)) : (e.a11y.enableEl(i), e.a11y.makeElFocusable(i))), a && a.length > 0 && (e.isEnd ? (e.a11y.disableEl(a), e.a11y.makeElNotFocusable(a)) : (e.a11y.enableEl(a), e.a11y.makeElFocusable(a))) } }, updatePagination: function () { var e = this, t = e.params.a11y; e.pagination && e.params.pagination.clickable && e.pagination.bullets && e.pagination.bullets.length && e.pagination.bullets.each((function (a) { var i = m(a); e.a11y.makeElFocusable(i), e.params.pagination.renderBullet || (e.a11y.addElRole(i, "button"), e.a11y.addElLabel(i, t.paginationBulletMessage.replace(/\{\{index\}\}/, i.index() + 1))) })) }, init: function () { var e = this, t = e.params.a11y; e.$el.append(e.a11y.liveRegion); var a = e.$el; t.containerRoleDescriptionMessage && e.a11y.addElRoleDescription(a, t.containerRoleDescriptionMessage), t.containerMessage && e.a11y.addElLabel(a, t.containerMessage); var i, s, r, n = e.$wrapperEl, l = n.attr("id") || "swiper-wrapper-" + e.a11y.getRandomNumber(16); e.a11y.addElId(n, l), i = e.params.autoplay && e.params.autoplay.enabled ? "off" : "polite", e.a11y.addElLive(n, i), t.itemRoleDescriptionMessage && e.a11y.addElRoleDescription(m(e.slides), t.itemRoleDescriptionMessage), e.a11y.addElRole(m(e.slides), "group"), e.slides.each((function (t) { var a = m(t); e.a11y.addElLabel(a, a.index() + 1 + " / " + e.slides.length) })), e.navigation && e.navigation.$nextEl && (s = e.navigation.$nextEl), e.navigation && e.navigation.$prevEl && (r = e.navigation.$prevEl), s && s.length && (e.a11y.makeElFocusable(s), "BUTTON" !== s[0].tagName && (e.a11y.addElRole(s, "button"), s.on("keydown", e.a11y.onEnterKey)), e.a11y.addElLabel(s, t.nextSlideMessage), e.a11y.addElControls(s, l)), r && r.length && (e.a11y.makeElFocusable(r), "BUTTON" !== r[0].tagName && (e.a11y.addElRole(r, "button"), r.on("keydown", e.a11y.onEnterKey)), e.a11y.addElLabel(r, t.prevSlideMessage), e.a11y.addElControls(r, l)), e.pagination && e.params.pagination.clickable && e.pagination.bullets && e.pagination.bullets.length && e.pagination.$el.on("keydown", "." + e.params.pagination.bulletClass.replace(/ /g, "."), e.a11y.onEnterKey) }, destroy: function () { var e, t, a = this; a.a11y.liveRegion && a.a11y.liveRegion.length > 0 && a.a11y.liveRegion.remove(), a.navigation && a.navigation.$nextEl && (e = a.navigation.$nextEl), a.navigation && a.navigation.$prevEl && (t = a.navigation.$prevEl), e && e.off("keydown", a.a11y.onEnterKey), t && t.off("keydown", a.a11y.onEnterKey), a.pagination && a.params.pagination.clickable && a.pagination.bullets && a.pagination.bullets.length && a.pagination.$el.off("keydown", "." + a.params.pagination.bulletClass.replace(/ /g, "."), a.a11y.onEnterKey) } }, se = { init: function () { var e = this, t = l(); if (e.params.history) { if (!t.history || !t.history.pushState) return e.params.history.enabled = !1, void (e.params.hashNavigation.enabled = !0); var a = e.history; a.initialized = !0, a.paths = se.getPathValues(e.params.url), (a.paths.key || a.paths.value) && (a.scrollToSlide(0, a.paths.value, e.params.runCallbacksOnInit), e.params.history.replaceState || t.addEventListener("popstate", e.history.setHistoryPopState)) } }, destroy: function () { var e = l(); this.params.history.replaceState || e.removeEventListener("popstate", this.history.setHistoryPopState) }, setHistoryPopState: function () { var e = this; e.history.paths = se.getPathValues(e.params.url), e.history.scrollToSlide(e.params.speed, e.history.paths.value, !1) }, getPathValues: function (e) { var t = l(), a = (e ? new URL(e) : t.location).pathname.slice(1).split("/").filter((function (e) { return "" !== e })), i = a.length; return { key: a[i - 2], value: a[i - 1] } }, setHistory: function (e, t) { var a = this, i = l(); if (a.history.initialized && a.params.history.enabled) { var s; s = a.params.url ? new URL(a.params.url) : i.location; var r = a.slides.eq(t), n = se.slugify(r.attr("data-history")); s.pathname.includes(e) || (n = e + "/" + n); var o = i.history.state; o && o.value === n || (a.params.history.replaceState ? i.history.replaceState({ value: n }, null, n) : i.history.pushState({ value: n }, null, n)) } }, slugify: function (e) { return e.toString().replace(/\s+/g, "-").replace(/[^\w-]+/g, "").replace(/--+/g, "-").replace(/^-+/, "").replace(/-+$/, "") }, scrollToSlide: function (e, t, a) { var i = this; if (t) for (var s = 0, r = i.slides.length; s < r; s += 1) { var n = i.slides.eq(s); if (se.slugify(n.attr("data-history")) === t && !n.hasClass(i.params.slideDuplicateClass)) { var l = n.index(); i.slideTo(l, e, a) } } else i.slideTo(0, e, a) } }, re = { onHashCange: function () { var e = this, t = r(); e.emit("hashChange"); var a = t.location.hash.replace("#", ""); if (a !== e.slides.eq(e.activeIndex).attr("data-hash")) { var i = e.$wrapperEl.children("." + e.params.slideClass + '[data-hash="' + a + '"]').index(); if (void 0 === i) return; e.slideTo(i) } }, setHash: function () { var e = this, t = l(), a = r(); if (e.hashNavigation.initialized && e.params.hashNavigation.enabled) if (e.params.hashNavigation.replaceState && t.history && t.history.replaceState) t.history.replaceState(null, null, "#" + e.slides.eq(e.activeIndex).attr("data-hash") || ""), e.emit("hashSet"); else { var i = e.slides.eq(e.activeIndex), s = i.attr("data-hash") || i.attr("data-history"); a.location.hash = s || "", e.emit("hashSet") } }, init: function () { var e = this, t = r(), a = l(); if (!(!e.params.hashNavigation.enabled || e.params.history && e.params.history.enabled)) { e.hashNavigation.initialized = !0; var i = t.location.hash.replace("#", ""); if (i) for (var s = 0, n = e.slides.length; s < n; s += 1) { var o = e.slides.eq(s); if ((o.attr("data-hash") || o.attr("data-history")) === i && !o.hasClass(e.params.slideDuplicateClass)) { var d = o.index(); e.slideTo(d, 0, e.params.runCallbacksOnInit, !0) } } e.params.hashNavigation.watchState && m(a).on("hashchange", e.hashNavigation.onHashCange) } }, destroy: function () { var e = l(); this.params.hashNavigation.watchState && m(e).off("hashchange", this.hashNavigation.onHashCange) } }, ne = { run: function () { var e = this, t = e.slides.eq(e.activeIndex), a = e.params.autoplay.delay; t.attr("data-swiper-autoplay") && (a = t.attr("data-swiper-autoplay") || e.params.autoplay.delay), clearTimeout(e.autoplay.timeout), e.autoplay.timeout = E((function () { var t; e.params.autoplay.reverseDirection ? e.params.loop ? (e.loopFix(), t = e.slidePrev(e.params.speed, !0, !0), e.emit("autoplay")) : e.isBeginning ? e.params.autoplay.stopOnLastSlide ? e.autoplay.stop() : (t = e.slideTo(e.slides.length - 1, e.params.speed, !0, !0), e.emit("autoplay")) : (t = e.slidePrev(e.params.speed, !0, !0), e.emit("autoplay")) : e.params.loop ? (e.loopFix(), t = e.slideNext(e.params.speed, !0, !0), e.emit("autoplay")) : e.isEnd ? e.params.autoplay.stopOnLastSlide ? e.autoplay.stop() : (t = e.slideTo(0, e.params.speed, !0, !0), e.emit("autoplay")) : (t = e.slideNext(e.params.speed, !0, !0), e.emit("autoplay")), (e.params.cssMode && e.autoplay.running || !1 === t) && e.autoplay.run() }), a) }, start: function () { var e = this; return void 0 === e.autoplay.timeout && (!e.autoplay.running && (e.autoplay.running = !0, e.emit("autoplayStart"), e.autoplay.run(), !0)) }, stop: function () { var e = this; return !!e.autoplay.running && (void 0 !== e.autoplay.timeout && (e.autoplay.timeout && (clearTimeout(e.autoplay.timeout), e.autoplay.timeout = void 0), e.autoplay.running = !1, e.emit("autoplayStop"), !0)) }, pause: function (e) { var t = this; t.autoplay.running && (t.autoplay.paused || (t.autoplay.timeout && clearTimeout(t.autoplay.timeout), t.autoplay.paused = !0, 0 !== e && t.params.autoplay.waitForTransition ? (t.$wrapperEl[0].addEventListener("transitionend", t.autoplay.onTransitionEnd), t.$wrapperEl[0].addEventListener("webkitTransitionEnd", t.autoplay.onTransitionEnd)) : (t.autoplay.paused = !1, t.autoplay.run()))) }, onVisibilityChange: function () { var e = this, t = r(); "hidden" === t.visibilityState && e.autoplay.running && e.autoplay.pause(), "visible" === t.visibilityState && e.autoplay.paused && (e.autoplay.run(), e.autoplay.paused = !1) }, onTransitionEnd: function (e) { var t = this; t && !t.destroyed && t.$wrapperEl && e.target === t.$wrapperEl[0] && (t.$wrapperEl[0].removeEventListener("transitionend", t.autoplay.onTransitionEnd), t.$wrapperEl[0].removeEventListener("webkitTransitionEnd", t.autoplay.onTransitionEnd), t.autoplay.paused = !1, t.autoplay.running ? t.autoplay.run() : t.autoplay.stop()) } }, le = { setTranslate: function () { for (var e = this, t = e.slides, a = 0; a < t.length; a += 1) { var i = e.slides.eq(a), s = -i[0].swiperSlideOffset; e.params.virtualTranslate || (s -= e.translate); var r = 0; e.isHorizontal() || (r = s, s = 0); var n = e.params.fadeEffect.crossFade ? Math.max(1 - Math.abs(i[0].progress), 0) : 1 + Math.min(Math.max(i[0].progress, -1), 0); i.css({ opacity: n }).transform("translate3d(" + s + "px, " + r + "px, 0px)") } }, setTransition: function (e) { var t = this, a = t.slides, i = t.$wrapperEl; if (a.transition(e), t.params.virtualTranslate && 0 !== e) { var s = !1; a.transitionEnd((function () { if (!s && t && !t.destroyed) { s = !0, t.animating = !1; for (var e = ["webkitTransitionEnd", "transitionend"], a = 0; a < e.length; a += 1)i.trigger(e[a]) } })) } } }, oe = { setTranslate: function () { var e, t = this, a = t.$el, i = t.$wrapperEl, s = t.slides, r = t.width, n = t.height, l = t.rtlTranslate, o = t.size, d = t.browser, p = t.params.cubeEffect, u = t.isHorizontal(), c = t.virtual && t.params.virtual.enabled, h = 0; p.shadow && (u ? (0 === (e = i.find(".swiper-cube-shadow")).length && (e = m('
'), i.append(e)), e.css({ height: r + "px" })) : 0 === (e = a.find(".swiper-cube-shadow")).length && (e = m('
'), a.append(e))); for (var v = 0; v < s.length; v += 1) { var f = s.eq(v), g = v; c && (g = parseInt(f.attr("data-swiper-slide-index"), 10)); var y = 90 * g, w = Math.floor(y / 360); l && (y = -y, w = Math.floor(-y / 360)); var b = Math.max(Math.min(f[0].progress, 1), -1), E = 0, x = 0, T = 0; g % 4 == 0 ? (E = 4 * -w * o, T = 0) : (g - 1) % 4 == 0 ? (E = 0, T = 4 * -w * o) : (g - 2) % 4 == 0 ? (E = o + 4 * w * o, T = o) : (g - 3) % 4 == 0 && (E = -o, T = 3 * o + 4 * o * w), l && (E = -E), u || (x = E, E = 0); var C = "rotateX(" + (u ? 0 : -y) + "deg) rotateY(" + (u ? y : 0) + "deg) translate3d(" + E + "px, " + x + "px, " + T + "px)"; if (b <= 1 && b > -1 && (h = 90 * g + 90 * b, l && (h = 90 * -g - 90 * b)), f.transform(C), p.slideShadows) { var S = u ? f.find(".swiper-slide-shadow-left") : f.find(".swiper-slide-shadow-top"), M = u ? f.find(".swiper-slide-shadow-right") : f.find(".swiper-slide-shadow-bottom"); 0 === S.length && (S = m('
'), f.append(S)), 0 === M.length && (M = m('
'), f.append(M)), S.length && (S[0].style.opacity = Math.max(-b, 0)), M.length && (M[0].style.opacity = Math.max(b, 0)) } } if (i.css({ "-webkit-transform-origin": "50% 50% -" + o / 2 + "px", "-moz-transform-origin": "50% 50% -" + o / 2 + "px", "-ms-transform-origin": "50% 50% -" + o / 2 + "px", "transform-origin": "50% 50% -" + o / 2 + "px" }), p.shadow) if (u) e.transform("translate3d(0px, " + (r / 2 + p.shadowOffset) + "px, " + -r / 2 + "px) rotateX(90deg) rotateZ(0deg) scale(" + p.shadowScale + ")"); else { var z = Math.abs(h) - 90 * Math.floor(Math.abs(h) / 90), P = 1.5 - (Math.sin(2 * z * Math.PI / 360) / 2 + Math.cos(2 * z * Math.PI / 360) / 2), k = p.shadowScale, L = p.shadowScale / P, $ = p.shadowOffset; e.transform("scale3d(" + k + ", 1, " + L + ") translate3d(0px, " + (n / 2 + $) + "px, " + -n / 2 / L + "px) rotateX(-90deg)") } var I = d.isSafari || d.isWebView ? -o / 2 : 0; i.transform("translate3d(0px,0," + I + "px) rotateX(" + (t.isHorizontal() ? 0 : h) + "deg) rotateY(" + (t.isHorizontal() ? -h : 0) + "deg)") }, setTransition: function (e) { var t = this, a = t.$el; t.slides.transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e), t.params.cubeEffect.shadow && !t.isHorizontal() && a.find(".swiper-cube-shadow").transition(e) } }, de = { setTranslate: function () { for (var e = this, t = e.slides, a = e.rtlTranslate, i = 0; i < t.length; i += 1) { var s = t.eq(i), r = s[0].progress; e.params.flipEffect.limitRotation && (r = Math.max(Math.min(s[0].progress, 1), -1)); var n = -180 * r, l = 0, o = -s[0].swiperSlideOffset, d = 0; if (e.isHorizontal() ? a && (n = -n) : (d = o, o = 0, l = -n, n = 0), s[0].style.zIndex = -Math.abs(Math.round(r)) + t.length, e.params.flipEffect.slideShadows) { var p = e.isHorizontal() ? s.find(".swiper-slide-shadow-left") : s.find(".swiper-slide-shadow-top"), u = e.isHorizontal() ? s.find(".swiper-slide-shadow-right") : s.find(".swiper-slide-shadow-bottom"); 0 === p.length && (p = m('
'), s.append(p)), 0 === u.length && (u = m('
'), s.append(u)), p.length && (p[0].style.opacity = Math.max(-r, 0)), u.length && (u[0].style.opacity = Math.max(r, 0)) } s.transform("translate3d(" + o + "px, " + d + "px, 0px) rotateX(" + l + "deg) rotateY(" + n + "deg)") } }, setTransition: function (e) { var t = this, a = t.slides, i = t.activeIndex, s = t.$wrapperEl; if (a.transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e), t.params.virtualTranslate && 0 !== e) { var r = !1; a.eq(i).transitionEnd((function () { if (!r && t && !t.destroyed) { r = !0, t.animating = !1; for (var e = ["webkitTransitionEnd", "transitionend"], a = 0; a < e.length; a += 1)s.trigger(e[a]) } })) } } }, pe = { setTranslate: function () { for (var e = this, t = e.width, a = e.height, i = e.slides, s = e.slidesSizesGrid, r = e.params.coverflowEffect, n = e.isHorizontal(), l = e.translate, o = n ? t / 2 - l : a / 2 - l, d = n ? r.rotate : -r.rotate, p = r.depth, u = 0, c = i.length; u < c; u += 1) { var h = i.eq(u), v = s[u], f = (o - h[0].swiperSlideOffset - v / 2) / v * r.modifier, g = n ? d * f : 0, y = n ? 0 : d * f, w = -p * Math.abs(f), b = r.stretch; "string" == typeof b && -1 !== b.indexOf("%") && (b = parseFloat(r.stretch) / 100 * v); var E = n ? 0 : b * f, x = n ? b * f : 0, T = 1 - (1 - r.scale) * Math.abs(f); Math.abs(x) < .001 && (x = 0), Math.abs(E) < .001 && (E = 0), Math.abs(w) < .001 && (w = 0), Math.abs(g) < .001 && (g = 0), Math.abs(y) < .001 && (y = 0), Math.abs(T) < .001 && (T = 0); var C = "translate3d(" + x + "px," + E + "px," + w + "px) rotateX(" + y + "deg) rotateY(" + g + "deg) scale(" + T + ")"; if (h.transform(C), h[0].style.zIndex = 1 - Math.abs(Math.round(f)), r.slideShadows) { var S = n ? h.find(".swiper-slide-shadow-left") : h.find(".swiper-slide-shadow-top"), M = n ? h.find(".swiper-slide-shadow-right") : h.find(".swiper-slide-shadow-bottom"); 0 === S.length && (S = m('
'), h.append(S)), 0 === M.length && (M = m('
'), h.append(M)), S.length && (S[0].style.opacity = f > 0 ? f : 0), M.length && (M[0].style.opacity = -f > 0 ? -f : 0) } } }, setTransition: function (e) { this.slides.transition(e).find(".swiper-slide-shadow-top, .swiper-slide-shadow-right, .swiper-slide-shadow-bottom, .swiper-slide-shadow-left").transition(e) } }, ue = { init: function () { var e = this, t = e.params.thumbs; if (e.thumbs.initialized) return !1; e.thumbs.initialized = !0; var a = e.constructor; return t.swiper instanceof a ? (e.thumbs.swiper = t.swiper, S(e.thumbs.swiper.originalParams, { watchSlidesProgress: !0, slideToClickedSlide: !1 }), S(e.thumbs.swiper.params, { watchSlidesProgress: !0, slideToClickedSlide: !1 })) : C(t.swiper) && (e.thumbs.swiper = new a(S({}, t.swiper, { watchSlidesVisibility: !0, watchSlidesProgress: !0, slideToClickedSlide: !1 })), e.thumbs.swiperCreated = !0), e.thumbs.swiper.$el.addClass(e.params.thumbs.thumbsContainerClass), e.thumbs.swiper.on("tap", e.thumbs.onThumbClick), !0 }, onThumbClick: function () { var e = this, t = e.thumbs.swiper; if (t) { var a = t.clickedIndex, i = t.clickedSlide; if (!(i && m(i).hasClass(e.params.thumbs.slideThumbActiveClass) || null == a)) { var s; if (s = t.params.loop ? parseInt(m(t.clickedSlide).attr("data-swiper-slide-index"), 10) : a, e.params.loop) { var r = e.activeIndex; e.slides.eq(r).hasClass(e.params.slideDuplicateClass) && (e.loopFix(), e._clientLeft = e.$wrapperEl[0].clientLeft, r = e.activeIndex); var n = e.slides.eq(r).prevAll('[data-swiper-slide-index="' + s + '"]').eq(0).index(), l = e.slides.eq(r).nextAll('[data-swiper-slide-index="' + s + '"]').eq(0).index(); s = void 0 === n ? l : void 0 === l ? n : l - r < r - n ? l : n } e.slideTo(s) } } }, update: function (e) { var t = this, a = t.thumbs.swiper; if (a) { var i = "auto" === a.params.slidesPerView ? a.slidesPerViewDynamic() : a.params.slidesPerView, s = t.params.thumbs.autoScrollOffset, r = s && !a.params.loop; if (t.realIndex !== a.realIndex || r) { var n, l, o = a.activeIndex; if (a.params.loop) { a.slides.eq(o).hasClass(a.params.slideDuplicateClass) && (a.loopFix(), a._clientLeft = a.$wrapperEl[0].clientLeft, o = a.activeIndex); var d = a.slides.eq(o).prevAll('[data-swiper-slide-index="' + t.realIndex + '"]').eq(0).index(), p = a.slides.eq(o).nextAll('[data-swiper-slide-index="' + t.realIndex + '"]').eq(0).index(); n = void 0 === d ? p : void 0 === p ? d : p - o == o - d ? o : p - o < o - d ? p : d, l = t.activeIndex > t.previousIndex ? "next" : "prev" } else l = (n = t.realIndex) > t.previousIndex ? "next" : "prev"; r && (n += "next" === l ? s : -1 * s), a.visibleSlidesIndexes && a.visibleSlidesIndexes.indexOf(n) < 0 && (a.params.centeredSlides ? n = n > o ? n - Math.floor(i / 2) + 1 : n + Math.floor(i / 2) - 1 : n > o && (n = n - i + 1), a.slideTo(n, e ? 0 : void 0)) } var u = 1, c = t.params.thumbs.slideThumbActiveClass; if (t.params.slidesPerView > 1 && !t.params.centeredSlides && (u = t.params.slidesPerView), t.params.thumbs.multipleActiveThumbs || (u = 1), u = Math.floor(u), a.slides.removeClass(c), a.params.loop || a.params.virtual && a.params.virtual.enabled) for (var h = 0; h < u; h += 1)a.$wrapperEl.children('[data-swiper-slide-index="' + (t.realIndex + h) + '"]').addClass(c); else for (var v = 0; v < u; v += 1)a.slides.eq(t.realIndex + v).addClass(c) } } }, ce = [q, _, { name: "mousewheel", params: { mousewheel: { enabled: !1, releaseOnEdges: !1, invert: !1, forceToAxis: !1, sensitivity: 1, eventsTarget: "container", thresholdDelta: null, thresholdTime: null } }, create: function () { M(this, { mousewheel: { enabled: !1, lastScrollTime: x(), lastEventBeforeSnap: void 0, recentWheelEvents: [], enable: U.enable, disable: U.disable, handle: U.handle, handleMouseEnter: U.handleMouseEnter, handleMouseLeave: U.handleMouseLeave, animateSlider: U.animateSlider, releaseScroll: U.releaseScroll } }) }, on: { init: function (e) { !e.params.mousewheel.enabled && e.params.cssMode && e.mousewheel.disable(), e.params.mousewheel.enabled && e.mousewheel.enable() }, destroy: function (e) { e.params.cssMode && e.mousewheel.enable(), e.mousewheel.enabled && e.mousewheel.disable() } } }, { name: "navigation", params: { navigation: { nextEl: null, prevEl: null, hideOnClick: !1, disabledClass: "swiper-button-disabled", hiddenClass: "swiper-button-hidden", lockClass: "swiper-button-lock" } }, create: function () { M(this, { navigation: t({}, K) }) }, on: { init: function (e) { e.navigation.init(), e.navigation.update() }, toEdge: function (e) { e.navigation.update() }, fromEdge: function (e) { e.navigation.update() }, destroy: function (e) { e.navigation.destroy() }, click: function (e, t) { var a, i = e.navigation, s = i.$nextEl, r = i.$prevEl; !e.params.navigation.hideOnClick || m(t.target).is(r) || m(t.target).is(s) || (s ? a = s.hasClass(e.params.navigation.hiddenClass) : r && (a = r.hasClass(e.params.navigation.hiddenClass)), !0 === a ? e.emit("navigationShow") : e.emit("navigationHide"), s && s.toggleClass(e.params.navigation.hiddenClass), r && r.toggleClass(e.params.navigation.hiddenClass)) } } }, { name: "pagination", params: { pagination: { el: null, bulletElement: "span", clickable: !1, hideOnClick: !1, renderBullet: null, renderProgressbar: null, renderFraction: null, renderCustom: null, progressbarOpposite: !1, type: "bullets", dynamicBullets: !1, dynamicMainBullets: 1, formatFractionCurrent: function (e) { return e }, formatFractionTotal: function (e) { return e }, bulletClass: "swiper-pagination-bullet", bulletActiveClass: "swiper-pagination-bullet-active", modifierClass: "swiper-pagination-", currentClass: "swiper-pagination-current", totalClass: "swiper-pagination-total", hiddenClass: "swiper-pagination-hidden", progressbarFillClass: "swiper-pagination-progressbar-fill", progressbarOppositeClass: "swiper-pagination-progressbar-opposite", clickableClass: "swiper-pagination-clickable", lockClass: "swiper-pagination-lock" } }, create: function () { M(this, { pagination: t({ dynamicBulletIndex: 0 }, Z) }) }, on: { init: function (e) { e.pagination.init(), e.pagination.render(), e.pagination.update() }, activeIndexChange: function (e) { (e.params.loop || void 0 === e.snapIndex) && e.pagination.update() }, snapIndexChange: function (e) { e.params.loop || e.pagination.update() }, slidesLengthChange: function (e) { e.params.loop && (e.pagination.render(), e.pagination.update()) }, snapGridLengthChange: function (e) { e.params.loop || (e.pagination.render(), e.pagination.update()) }, destroy: function (e) { e.pagination.destroy() }, click: function (e, t) { e.params.pagination.el && e.params.pagination.hideOnClick && e.pagination.$el.length > 0 && !m(t.target).hasClass(e.params.pagination.bulletClass) && (!0 === e.pagination.$el.hasClass(e.params.pagination.hiddenClass) ? e.emit("paginationShow") : e.emit("paginationHide"), e.pagination.$el.toggleClass(e.params.pagination.hiddenClass)) } } }, { name: "scrollbar", params: { scrollbar: { el: null, dragSize: "auto", hide: !1, draggable: !1, snapOnRelease: !0, lockClass: "swiper-scrollbar-lock", dragClass: "swiper-scrollbar-drag" } }, create: function () { M(this, { scrollbar: t({ isTouched: !1, timeout: null, dragTimeout: null }, J) }) }, on: { init: function (e) { e.scrollbar.init(), e.scrollbar.updateSize(), e.scrollbar.setTranslate() }, update: function (e) { e.scrollbar.updateSize() }, resize: function (e) { e.scrollbar.updateSize() }, observerUpdate: function (e) { e.scrollbar.updateSize() }, setTranslate: function (e) { e.scrollbar.setTranslate() }, setTransition: function (e, t) { e.scrollbar.setTransition(t) }, destroy: function (e) { e.scrollbar.destroy() } } }, { name: "parallax", params: { parallax: { enabled: !1 } }, create: function () { M(this, { parallax: t({}, Q) }) }, on: { beforeInit: function (e) { e.params.parallax.enabled && (e.params.watchSlidesProgress = !0, e.originalParams.watchSlidesProgress = !0) }, init: function (e) { e.params.parallax.enabled && e.parallax.setTranslate() }, setTranslate: function (e) { e.params.parallax.enabled && e.parallax.setTranslate() }, setTransition: function (e, t) { e.params.parallax.enabled && e.parallax.setTransition(t) } } }, { name: "zoom", params: { zoom: { enabled: !1, maxRatio: 3, minRatio: 1, toggle: !0, containerClass: "swiper-zoom-container", zoomedSlideClass: "swiper-slide-zoomed" } }, create: function () { var e = this; M(e, { zoom: t({ enabled: !1, scale: 1, currentScale: 1, isScaling: !1, gesture: { $slideEl: void 0, slideWidth: void 0, slideHeight: void 0, $imageEl: void 0, $imageWrapEl: void 0, maxRatio: 3 }, image: { isTouched: void 0, isMoved: void 0, currentX: void 0, currentY: void 0, minX: void 0, minY: void 0, maxX: void 0, maxY: void 0, width: void 0, height: void 0, startX: void 0, startY: void 0, touchesStart: {}, touchesCurrent: {} }, velocity: { x: void 0, y: void 0, prevPositionX: void 0, prevPositionY: void 0, prevTime: void 0 } }, ee) }); var a = 1; Object.defineProperty(e.zoom, "scale", { get: function () { return a }, set: function (t) { if (a !== t) { var i = e.zoom.gesture.$imageEl ? e.zoom.gesture.$imageEl[0] : void 0, s = e.zoom.gesture.$slideEl ? e.zoom.gesture.$slideEl[0] : void 0; e.emit("zoomChange", t, i, s) } a = t } }) }, on: { init: function (e) { e.params.zoom.enabled && e.zoom.enable() }, destroy: function (e) { e.zoom.disable() }, touchStart: function (e, t) { e.zoom.enabled && e.zoom.onTouchStart(t) }, touchEnd: function (e, t) { e.zoom.enabled && e.zoom.onTouchEnd(t) }, doubleTap: function (e, t) { e.params.zoom.enabled && e.zoom.enabled && e.params.zoom.toggle && e.zoom.toggle(t) }, transitionEnd: function (e) { e.zoom.enabled && e.params.zoom.enabled && e.zoom.onTransitionEnd() }, slideChange: function (e) { e.zoom.enabled && e.params.zoom.enabled && e.params.cssMode && e.zoom.onTransitionEnd() } } }, { name: "lazy", params: { lazy: { checkInView: !1, enabled: !1, loadPrevNext: !1, loadPrevNextAmount: 1, loadOnTransitionStart: !1, scrollingElement: "", elementClass: "swiper-lazy", loadingClass: "swiper-lazy-loading", loadedClass: "swiper-lazy-loaded", preloaderClass: "swiper-lazy-preloader" } }, create: function () { M(this, { lazy: t({ initialImageLoaded: !1 }, te) }) }, on: { beforeInit: function (e) { e.params.lazy.enabled && e.params.preloadImages && (e.params.preloadImages = !1) }, init: function (e) { e.params.lazy.enabled && !e.params.loop && 0 === e.params.initialSlide && (e.params.lazy.checkInView ? e.lazy.checkInViewOnLoad() : e.lazy.load()) }, scroll: function (e) { e.params.freeMode && !e.params.freeModeSticky && e.lazy.load() }, resize: function (e) { e.params.lazy.enabled && e.lazy.load() }, scrollbarDragMove: function (e) { e.params.lazy.enabled && e.lazy.load() }, transitionStart: function (e) { e.params.lazy.enabled && (e.params.lazy.loadOnTransitionStart || !e.params.lazy.loadOnTransitionStart && !e.lazy.initialImageLoaded) && e.lazy.load() }, transitionEnd: function (e) { e.params.lazy.enabled && !e.params.lazy.loadOnTransitionStart && e.lazy.load() }, slideChange: function (e) { e.params.lazy.enabled && e.params.cssMode && e.lazy.load() } } }, { name: "controller", params: { controller: { control: void 0, inverse: !1, by: "slide" } }, create: function () { M(this, { controller: t({ control: this.params.controller.control }, ae) }) }, on: { update: function (e) { e.controller.control && e.controller.spline && (e.controller.spline = void 0, delete e.controller.spline) }, resize: function (e) { e.controller.control && e.controller.spline && (e.controller.spline = void 0, delete e.controller.spline) }, observerUpdate: function (e) { e.controller.control && e.controller.spline && (e.controller.spline = void 0, delete e.controller.spline) }, setTranslate: function (e, t, a) { e.controller.control && e.controller.setTranslate(t, a) }, setTransition: function (e, t, a) { e.controller.control && e.controller.setTransition(t, a) } } }, { name: "a11y", params: { a11y: { enabled: !0, notificationClass: "swiper-notification", prevSlideMessage: "Previous slide", nextSlideMessage: "Next slide", firstSlideMessage: "This is the first slide", lastSlideMessage: "This is the last slide", paginationBulletMessage: "Go to slide {{index}}", containerMessage: null, containerRoleDescriptionMessage: null, itemRoleDescriptionMessage: null } }, create: function () { M(this, { a11y: t({}, ie, { liveRegion: m('') }) }) }, on: { afterInit: function (e) { e.params.a11y.enabled && (e.a11y.init(), e.a11y.updateNavigation()) }, toEdge: function (e) { e.params.a11y.enabled && e.a11y.updateNavigation() }, fromEdge: function (e) { e.params.a11y.enabled && e.a11y.updateNavigation() }, paginationUpdate: function (e) { e.params.a11y.enabled && e.a11y.updatePagination() }, destroy: function (e) { e.params.a11y.enabled && e.a11y.destroy() } } }, { name: "history", params: { history: { enabled: !1, replaceState: !1, key: "slides" } }, create: function () { M(this, { history: t({}, se) }) }, on: { init: function (e) { e.params.history.enabled && e.history.init() }, destroy: function (e) { e.params.history.enabled && e.history.destroy() }, transitionEnd: function (e) { e.history.initialized && e.history.setHistory(e.params.history.key, e.activeIndex) }, slideChange: function (e) { e.history.initialized && e.params.cssMode && e.history.setHistory(e.params.history.key, e.activeIndex) } } }, { name: "hash-navigation", params: { hashNavigation: { enabled: !1, replaceState: !1, watchState: !1 } }, create: function () { M(this, { hashNavigation: t({ initialized: !1 }, re) }) }, on: { init: function (e) { e.params.hashNavigation.enabled && e.hashNavigation.init() }, destroy: function (e) { e.params.hashNavigation.enabled && e.hashNavigation.destroy() }, transitionEnd: function (e) { e.hashNavigation.initialized && e.hashNavigation.setHash() }, slideChange: function (e) { e.hashNavigation.initialized && e.params.cssMode && e.hashNavigation.setHash() } } }, { name: "autoplay", params: { autoplay: { enabled: !1, delay: 3e3, waitForTransition: !0, disableOnInteraction: !0, stopOnLastSlide: !1, reverseDirection: !1 } }, create: function () { M(this, { autoplay: t({}, ne, { running: !1, paused: !1 }) }) }, on: { init: function (e) { e.params.autoplay.enabled && (e.autoplay.start(), r().addEventListener("visibilitychange", e.autoplay.onVisibilityChange)) }, beforeTransitionStart: function (e, t, a) { e.autoplay.running && (a || !e.params.autoplay.disableOnInteraction ? e.autoplay.pause(t) : e.autoplay.stop()) }, sliderFirstMove: function (e) { e.autoplay.running && (e.params.autoplay.disableOnInteraction ? e.autoplay.stop() : e.autoplay.pause()) }, touchEnd: function (e) { e.params.cssMode && e.autoplay.paused && !e.params.autoplay.disableOnInteraction && e.autoplay.run() }, destroy: function (e) { e.autoplay.running && e.autoplay.stop(), r().removeEventListener("visibilitychange", e.autoplay.onVisibilityChange) } } }, { name: "effect-fade", params: { fadeEffect: { crossFade: !1 } }, create: function () { M(this, { fadeEffect: t({}, le) }) }, on: { beforeInit: function (e) { if ("fade" === e.params.effect) { e.classNames.push(e.params.containerModifierClass + "fade"); var t = { slidesPerView: 1, slidesPerColumn: 1, slidesPerGroup: 1, watchSlidesProgress: !0, spaceBetween: 0, virtualTranslate: !0 }; S(e.params, t), S(e.originalParams, t) } }, setTranslate: function (e) { "fade" === e.params.effect && e.fadeEffect.setTranslate() }, setTransition: function (e, t) { "fade" === e.params.effect && e.fadeEffect.setTransition(t) } } }, { name: "effect-cube", params: { cubeEffect: { slideShadows: !0, shadow: !0, shadowOffset: 20, shadowScale: .94 } }, create: function () { M(this, { cubeEffect: t({}, oe) }) }, on: { beforeInit: function (e) { if ("cube" === e.params.effect) { e.classNames.push(e.params.containerModifierClass + "cube"), e.classNames.push(e.params.containerModifierClass + "3d"); var t = { slidesPerView: 1, slidesPerColumn: 1, slidesPerGroup: 1, watchSlidesProgress: !0, resistanceRatio: 0, spaceBetween: 0, centeredSlides: !1, virtualTranslate: !0 }; S(e.params, t), S(e.originalParams, t) } }, setTranslate: function (e) { "cube" === e.params.effect && e.cubeEffect.setTranslate() }, setTransition: function (e, t) { "cube" === e.params.effect && e.cubeEffect.setTransition(t) } } }, { name: "effect-flip", params: { flipEffect: { slideShadows: !0, limitRotation: !0 } }, create: function () { M(this, { flipEffect: t({}, de) }) }, on: { beforeInit: function (e) { if ("flip" === e.params.effect) { e.classNames.push(e.params.containerModifierClass + "flip"), e.classNames.push(e.params.containerModifierClass + "3d"); var t = { slidesPerView: 1, slidesPerColumn: 1, slidesPerGroup: 1, watchSlidesProgress: !0, spaceBetween: 0, virtualTranslate: !0 }; S(e.params, t), S(e.originalParams, t) } }, setTranslate: function (e) { "flip" === e.params.effect && e.flipEffect.setTranslate() }, setTransition: function (e, t) { "flip" === e.params.effect && e.flipEffect.setTransition(t) } } }, { name: "effect-coverflow", params: { coverflowEffect: { rotate: 50, stretch: 0, depth: 100, scale: 1, modifier: 1, slideShadows: !0 } }, create: function () { M(this, { coverflowEffect: t({}, pe) }) }, on: { beforeInit: function (e) { "coverflow" === e.params.effect && (e.classNames.push(e.params.containerModifierClass + "coverflow"), e.classNames.push(e.params.containerModifierClass + "3d"), e.params.watchSlidesProgress = !0, e.originalParams.watchSlidesProgress = !0) }, setTranslate: function (e) { "coverflow" === e.params.effect && e.coverflowEffect.setTranslate() }, setTransition: function (e, t) { "coverflow" === e.params.effect && e.coverflowEffect.setTransition(t) } } }, { name: "thumbs", params: { thumbs: { swiper: null, multipleActiveThumbs: !0, autoScrollOffset: 0, slideThumbActiveClass: "swiper-slide-thumb-active", thumbsContainerClass: "swiper-container-thumbs" } }, create: function () { M(this, { thumbs: t({ swiper: null, initialized: !1 }, ue) }) }, on: { beforeInit: function (e) { var t = e.params.thumbs; t && t.swiper && (e.thumbs.init(), e.thumbs.update(!0)) }, slideChange: function (e) { e.thumbs.swiper && e.thumbs.update() }, update: function (e) { e.thumbs.swiper && e.thumbs.update() }, resize: function (e) { e.thumbs.swiper && e.thumbs.update() }, observerUpdate: function (e) { e.thumbs.swiper && e.thumbs.update() }, setTransition: function (e, t) { var a = e.thumbs.swiper; a && a.setTransition(t) }, beforeDestroy: function (e) { var t = e.thumbs.swiper; t && e.thumbs.swiperCreated && t && t.destroy() } } }]; return R.use(ce), R })); \ No newline at end of file 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 194afdbda2..0000000000 --- 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/Content/theme.jpg b/src/Plugins/Theme.Modern/Content/theme.jpg index 79f664e316..891b3b2554 100644 Binary files a/src/Plugins/Theme.Modern/Content/theme.jpg and b/src/Plugins/Theme.Modern/Content/theme.jpg differ diff --git a/src/Plugins/Theme.Modern/Manifest.cs b/src/Plugins/Theme.Modern/Manifest.cs index 7c0629a9ce..b0b1b44ab4 100644 --- a/src/Plugins/Theme.Modern/Manifest.cs +++ b/src/Plugins/Theme.Modern/Manifest.cs @@ -1,9 +1,9 @@ using Grand.Infrastructure.Plugins; [assembly: PluginInfo( - FriendlyName = "Modern theme (beta)", + FriendlyName = "Modern", Group = "Themes", SystemName = "Theme.Modern", Author = "grandnode team", - Version = "1.0.0" + Version = "2.0.0" )] \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/ModernProductCard.cs b/src/Plugins/Theme.Modern/ModernProductCard.cs new file mode 100644 index 0000000000..2e798ddf6d --- /dev/null +++ b/src/Plugins/Theme.Modern/ModernProductCard.cs @@ -0,0 +1,23 @@ +namespace Theme.Modern; + +/// +/// Values the Modern product card shows that the overview model does not carry directly. +/// The client twin (Catalog/Partials/Client/CatalogProductGridView) repeats the same rule +/// in its template; keep the two in step. +/// +public static class ModernProductCard +{ + /// + /// Whole-number discount for the card badge, or null when there is nothing worth showing: + /// no old price, an old price not above the current one, a zero price, or less than 1%. + /// Rounds half away from zero, which is what JavaScript's Math.round does for positives. + /// + public static int? DiscountPercent(double price, double oldPrice) + { + if (price <= 0 || oldPrice <= price) + return null; + + var percent = (int)Math.Round((1 - price / oldPrice) * 100, MidpointRounding.AwayFromZero); + return percent >= 1 ? percent : null; + } +} diff --git a/src/Plugins/Theme.Modern/ModernThemeView.cs b/src/Plugins/Theme.Modern/ModernThemeView.cs index aa3f4bcb11..cf624561fb 100644 --- a/src/Plugins/Theme.Modern/ModernThemeView.cs +++ b/src/Plugins/Theme.Modern/ModernThemeView.cs @@ -7,8 +7,8 @@ public class ModernThemeView : IThemeView public string AreaName => ""; public string ThemeName => "Modern"; - public ThemeInfo ThemeInfo => new("Modern theme (beta)", "~/Plugins/Theme.Modern/Content/theme.jpg", - "Minimal theme (beta)", false); + public ThemeInfo ThemeInfo => new("Modern", "~/Plugins/Theme.Modern/Content/theme.jpg", + "Clean tech storefront theme with a sticky blurred header, card grid and dark mode", false); public IEnumerable GetViewLocations() { diff --git a/src/Plugins/Theme.Modern/Theme.Modern.csproj b/src/Plugins/Theme.Modern/Theme.Modern.csproj index 45362f30c6..043c5e1e09 100644 --- a/src/Plugins/Theme.Modern/Theme.Modern.csproj +++ b/src/Plugins/Theme.Modern/Theme.Modern.csproj @@ -1,56 +1,38 @@ - - - true + + + + true false - - ..\..\Web\Grand.Web\Plugins\Theme.Modern\ - $(OutputPath) - + + ..\..\Web\Grand.Web\Plugins\Theme.Modern\ + $(OutputPath) + + + + ..\..\Web\Grand.Web\Plugins\Theme.Modern\ + $(OutputPath) + - - ..\..\Web\Grand.Web\Plugins\Theme.Modern\ - $(OutputPath) - + + + false + all + + - - - false - - - false - - - false - - - false - - - false - - - false - all - - - false - all - - + + + + + + PreserveNewest + + + PreserveNewest + + - - - - - - PreserveNewest - - - PreserveNewest - - - diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml deleted file mode 100644 index e879c30494..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressAdd.cshtml +++ /dev/null @@ -1,50 +0,0 @@ -@model CustomerAddressEditModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Addresses }) -} - - - -
- -
-
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressEdit.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/AddressEdit.cshtml deleted file mode 100644 index d360ef9d01..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/AddressEdit.cshtml +++ /dev/null @@ -1,50 +0,0 @@ -@model CustomerAddressEditModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Addresses }) -} - - - -
- -
-
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Addresses.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Addresses.cshtml deleted file mode 100644 index 8d55e8cc72..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Addresses.cshtml +++ /dev/null @@ -1,189 +0,0 @@ -@model CustomerAddressListModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Addresses }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Auctions.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Auctions.cshtml deleted file mode 100644 index b1a5aca391..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Auctions.cshtml +++ /dev/null @@ -1,190 +0,0 @@ -@model CustomerAuctionsModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - //title - pagebuilder.AddTitleParts(Loc["Title.Auctions"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Auctions }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/ChangePassword.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/ChangePassword.cshtml deleted file mode 100644 index 6e7b7da644..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/ChangePassword.cshtml +++ /dev/null @@ -1,81 +0,0 @@ -@model ChangePasswordModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.ChangePassword }) -} - - -
- -
-
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Courses.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Courses.cshtml deleted file mode 100644 index 4b84dc3ec9..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Courses.cshtml +++ /dev/null @@ -1,71 +0,0 @@ -@model CoursesModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - //title - pagebuilder.AddTitleParts(Loc["Title.Courses"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Courses }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/DeleteAccount.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/DeleteAccount.cshtml deleted file mode 100644 index cc6cda1824..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/DeleteAccount.cshtml +++ /dev/null @@ -1,59 +0,0 @@ -@model DeleteAccountModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.DeleteAccount }) -} - - -
- -
-
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Documents.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Documents.cshtml deleted file mode 100644 index e1951f6657..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Documents.cshtml +++ /dev/null @@ -1,80 +0,0 @@ -@model DocumentsModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - //title - pagebuilder.AddTitleParts(Loc["Title.Documents"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Documents }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/DownloadableProducts.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/DownloadableProducts.cshtml deleted file mode 100644 index 8e5d4f58c7..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/DownloadableProducts.cshtml +++ /dev/null @@ -1,92 +0,0 @@ -@model CustomerDownloadableProductsModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.DownloadableProducts }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Info.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Info.cshtml deleted file mode 100644 index 6e25aad46b..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Info.cshtml +++ /dev/null @@ -1,692 +0,0 @@ -@model CustomerInfoModel -@inject CustomerSettings customerSettings -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Info }) -} - -@if (Model.CountryEnabled && Model.StateProvinceEnabled) -{ - -} - - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Login.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Login.cshtml deleted file mode 100644 index caa7c45a64..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Login.cshtml +++ /dev/null @@ -1,160 +0,0 @@ -@model LoginModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_SingleColumn"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Login"]); - - //register URL with return URL (if specified) - var registerUrl = Url.RouteUrl("Register", new { returnUrl = Context.Request.Query["returnUrl"] }); -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Notes.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Notes.cshtml deleted file mode 100644 index ca93ef346f..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Notes.cshtml +++ /dev/null @@ -1,66 +0,0 @@ -@model CustomerNotesModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - //title - pagebuilder.AddTitleParts(Loc["Title.Notes"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Notes }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/PasswordRecovery.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/PasswordRecovery.cshtml deleted file mode 100644 index f662bfe152..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/PasswordRecovery.cshtml +++ /dev/null @@ -1,60 +0,0 @@ -@model PasswordRecoveryModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_SingleColumn"; - - //title - pagebuilder.AddTitleParts(Loc["Title.PasswordRecovery"]); -} -
-

-
@Loc["Account.PasswordRecovery"]
-

- @if (!string.IsNullOrEmpty(Model.Result)) - { -
- @Model.Result -
- } - @if (!Model.Send) - { - -
- -
-
- - - - {{ errors[0] }} - - -
- @if (Model.DisplayCaptcha) - { -
-
- -
-
- } -
-
- -
-
- -
- @Loc["Account.PasswordRecovery.Tooltip"] -
-
-
- } -
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Register.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Register.cshtml deleted file mode 100644 index cc78f7bb07..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Register.cshtml +++ /dev/null @@ -1,601 +0,0 @@ -@model RegisterModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_SingleColumn"; - //title - pagebuilder.AddTitleParts(Loc["Title.Register"]); -} -@if (Model.CountryEnabled && Model.StateProvinceEnabled) -{ - -} - - - - -
-
-
-
-
-

@Loc["Account.YourPersonalDetails"]

-
-
- @if (Model.GenderEnabled) - { -
-
- @Loc["Account.Fields.Gender"]: -
-
-
- @Loc["Account.Fields.Gender"] -
- - -
-
- } -
-
- @Loc["account.fields.firstname"] - @if (Model.FirstLastNameRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - } -
-
- @Loc["account.fields.lastname"] - @if (Model.FirstLastNameRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - } -
-
- - @if (Model.DateOfBirthEnabled) - { -
- @Loc["Account.Fields.DateOfBirth"] -
- @Loc["Account.Fields.DateOfBirth"]:* - -
-
- - - -
-
- } -
- @Loc["account.fields.email"] - - - * - - {{ errors[0] }} - - -
- - @if (Model.UsernamesEnabled) - { -
-
- - * -
-
- - - {{ errors[0] }} - - - @if (Model.CheckUsernameAvailabilityEnabled) - { - - } -
-
- } -
-
- -@if (Model.CompanyEnabled) -{ -
-
-

@Loc["Account.CompanyDetails"]

-
-
-
- @if (Model.CompanyRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
-
-
-} -@if (Model.DisplayVatNumber) -{ -
-
-
- - - - @Loc["Account.Fields.VatNumber.Note"] - - -
-
-
-} -@if (Model.StreetAddressEnabled || - Model.StreetAddress2Enabled || - Model.ZipPostalCodeEnabled || - Model.CityEnabled || - Model.CountryEnabled) -{ -
-
-

- @Loc["Account.YourAddress"] -

-
-
-
- @if (Model.StreetAddressEnabled) - { -
- @Loc["account.fields.streetaddress"] - @if (Model.StreetAddressRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.StreetAddress2Enabled) - { -
- @Loc["account.fields.streetaddress2"] - @if (Model.StreetAddress2Required) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.ZipPostalCodeEnabled) - { -
- @Loc["account.fields.zippostalcode"] - @if (Model.ZipPostalCodeRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.CityEnabled) - { -
- @Loc["account.fields.city"] - @if (Model.CityRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.CountryEnabled) - { -
- @Loc["account.fields.country"] - @if (Model.CountryRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.CountryEnabled && Model.StateProvinceEnabled) - { -
- @Loc["account.fields.stateprovince"] - - - * - - {{ errors[0] }} - - - -
- } -
-
-
-} - -@if (Model.PhoneEnabled || Model.FaxEnabled) -{ -
-
-

@Loc["Account.YourContactInformation"]

-
-
-
- @if (Model.PhoneEnabled) - { -
- @Loc["account.fields.phone"] - @if (Model.PhoneRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } - @if (Model.FaxEnabled) - { -
- @Loc["account.fields.fax"] - @if (Model.FaxRequired) - { - - - * - - {{ errors[0] }} - - - } - else - { - - - - } -
- } -
-
-
-} - -@if (Model.NewsletterEnabled || Model.CustomerAttributes.Any()) -{ -
-
-

@Loc["Account.Options"]

-
-
- @if (Model.NewsletterEnabled) - { -
- - -
- - } - @if (Model.CustomerAttributes.Any()) - { - - } -
-
-} -
-
-

@Loc["Account.YourPassword"]

-
-
-
- - - - {{ errors[0] }} - - -
-
- - - - {{ errors[0] }} - - -
- @if (Model.DisplayCaptcha) - { -
- -
- } -
-
- -@if (Model.AcceptPrivacyPolicyEnabled) -{ - -
- - @Loc["Account.Fields.AcceptPrivacyPolicy.Read"] -
- {{ errors[0] }} -
-} -
- -
-
- \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/Reviews.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/Reviews.cshtml deleted file mode 100644 index 3de5dea269..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/Reviews.cshtml +++ /dev/null @@ -1,82 +0,0 @@ -@model CustomerProductReviewsModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.Reviews }) -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountAdd.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountAdd.cshtml deleted file mode 100644 index 7a652f18c7..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountAdd.cshtml +++ /dev/null @@ -1,38 +0,0 @@ -@model SubAccountCreateModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.SubAccounts }) -} - - -
- -
-
\ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountEdit.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountEdit.cshtml deleted file mode 100644 index a6285f874e..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccountEdit.cshtml +++ /dev/null @@ -1,38 +0,0 @@ -@model SubAccountEditModel -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Account"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.SubAccounts }) -} - - -
- -
-
\ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccounts.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccounts.cshtml deleted file mode 100644 index e9509d4426..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Account/SubAccounts.cshtml +++ /dev/null @@ -1,106 +0,0 @@ -@model IList -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.SubAccounts"]); -} - -@section left -{ - @await Component.InvokeAsync("CustomerNavigation", new { selectedTabId = AccountNavigationEnum.SubAccounts }) -} - - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Blog/BlogPost.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Blog/BlogPost.cshtml deleted file mode 100644 index 9e4f4eeb32..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Blog/BlogPost.cshtml +++ /dev/null @@ -1,184 +0,0 @@ -@model BlogPostModel -@inject IContextAccessor contextAccessor -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(!string.IsNullOrEmpty(Model.MetaTitle) ? Model.MetaTitle : Model.Title); - pagebuilder.AddMetaDescriptionParts(Model.MetaDescription); - pagebuilder.AddMetaKeywordParts(Model.MetaKeywords); -} - -@section left { - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_blog_before" }) - @await Component.InvokeAsync("BlogCategories") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_before_blog_archive" }) - @await Component.InvokeAsync("BlogMonths") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_after_blog_archive" }) - @await Component.InvokeAsync("BlogTags") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_blog_after" }) -} - -
-
- -
- - - -
-
- @Loc["catalog.selectors.fiters"] -
-
- @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_top", additionalData = Model.Id }) - - - @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_before_body", additionalData = Model.Id }) - @Html.Raw(Model.Body) - - - @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_before_products", additionalData = Model.Id }) - - @await Component.InvokeAsync("BlogPostProducts", new { blogPostId = Model.Id }) - - @if (Model.Tags.Any()) - { -
-
@Loc["Blog.Tags"]:
- @for (var i = 0; i < Model.Tags.Count; i++) - { - var tag = Model.Tags[i]; - @tag - } -
- } - - @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_before_comments", additionalData = Model.Id }) - @if (Model.AllowComments) - { -
-

@Loc["Blog.Comments.LeaveYourComment"]

-
-
- @{ - var result = TempData["Grand.blog.addcomment.result"] as string; - } - @if (!string.IsNullOrEmpty(result)) - { -
@result
- } -
- -
- -
-
- - - - {{ errors[0] }} - -
- @if (Model.AddNewComment.DisplayCaptcha) - { -
- -
- } -
-
- -
-
-
- -
- - } - @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_after_comments", additionalData = Model.Id }) - @await Component.InvokeAsync("Widget", new { widgetZone = "blogpost_page_bottom", additionalData = Model.Id }) -
-
\ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogCategories/Default.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogCategories/Default.cshtml deleted file mode 100644 index f45c34014a..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogCategories/Default.cshtml +++ /dev/null @@ -1,22 +0,0 @@ -@model IList -@if (Model.Any()) -{ -
-

- @Loc["Blog.Category"] -

-
- -
-
-} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogMonths/Default.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogMonths/Default.cshtml deleted file mode 100644 index 441b9c1b29..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogMonths/Default.cshtml +++ /dev/null @@ -1,30 +0,0 @@ -@model IList -@if (Model.Any()) -{ -
-

- @Loc["Blog.Archive"] -

-
- -
-
-} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogTags/Default.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogTags/Default.cshtml deleted file mode 100644 index 3a5e43549a..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Blog/Components/BlogTags/Default.cshtml +++ /dev/null @@ -1,19 +0,0 @@ -@model BlogPostTagListModel -@if (Model.Tags.Any()) -{ - -} \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Blog/List.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Blog/List.cshtml deleted file mode 100644 index f8e636af53..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Blog/List.cshtml +++ /dev/null @@ -1,139 +0,0 @@ -@model BlogPostListModel -@inject IContextAccessor contextAccessor -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - //title - pagebuilder.AddTitleParts(Loc["Title.Blog"]); -} - -@section left { - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_blog_before" }) - @await Component.InvokeAsync("BlogCategories") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_before_blog_archive" }) - @await Component.InvokeAsync("BlogMonths") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_after_blog_archive" }) - @await Component.InvokeAsync("BlogTags") - @await Component.InvokeAsync("Widget", new { widgetZone = "left_side_column_blog_after" }) -} - -
-
-

- @if (string.IsNullOrEmpty(Model.PagingFilteringContext.Tag)) - { - if (Model.PagingFilteringContext.GetParsedMonth().HasValue) - { - @string.Format(Loc["Blog.FilteredByMonth"], Model.PagingFilteringContext.GetParsedMonth().Value.Year, Model.PagingFilteringContext.GetParsedMonth().Value.ToString("MMMM")) - } - else - { - @Loc["Blog"] - } - } - else - { - @string.Format(Loc["Blog.TaggedWith"], Model.PagingFilteringContext.Tag) - } - @if (!string.IsNullOrEmpty(Model.SearchKeyword)) - { - - } -

-
-
- - - - - - Search store - - @Loc["Blog.Search"] - - -
-
- -
- - - -
-
- @Loc["catalog.selectors.fiters"] -
-
- @await Component.InvokeAsync("Widget", new { widgetZone = "bloglist_page_before_posts" }) - - @foreach (var item in Model.BlogPosts) - { - - - - - - - - - @* - @await Component.InvokeAsync("Widget", new { widgetZone = "bloglist_page_before_post", additionalData = item.Id }) - - - - @await Component.InvokeAsync("Widget", new { widgetZone = "bloglist_page_inside_post", additionalData = item.Id }) - - - @if (item.AllowComments) - { - @string.Format(Loc["Blog.CommentsLink"], item.NumberOfComments) - } - @Loc["Blog.MoreInfo"] - - @await Component.InvokeAsync("Widget", new { widgetZone = "bloglist_page_after_post", additionalData = item.Id }) - *@ - } - - - - @await Component.InvokeAsync("Widget", new { widgetZone = "bloglist_page_after_posts" }) -
-
\ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Catalog/BrandLayout.GridOrLines.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Catalog/BrandLayout.GridOrLines.cshtml deleted file mode 100644 index e56b40399a..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Catalog/BrandLayout.GridOrLines.cshtml +++ /dev/null @@ -1,94 +0,0 @@ -@model BrandModel -@inject SeoSettings seoSettings -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - pagebuilder.AddTitleParts(!string.IsNullOrEmpty(Model.MetaTitle) ? Model.MetaTitle : Model.Name); - pagebuilder.AddMetaDescriptionParts(Model.MetaDescription); - pagebuilder.AddMetaKeywordParts(Model.MetaKeywords); - - var canonicalUrlsEnabled = seoSettings.CanonicalUrlsEnabled; - if (canonicalUrlsEnabled) - { - var collectionUrl = Url.RouteUrl("Brand", new { Model.SeName }, Url.ActionContext.HttpContext.Request.Scheme); - pagebuilder.AddCanonicalUrlParts(collectionUrl); - } - - -} - - - -
- -

-
{{catalog.Model.Name}}
-

- @await Component.InvokeAsync("Widget", new { widgetZone = "branddetails_top", additionalData = Model.Id }) - @*Rendering description section*@ - @await Component.InvokeAsync("Widget", new { widgetZone = "branddetails_before_featured_products", additionalData = Model.Id }) - @*Rendering featured products section*@ - @await Component.InvokeAsync("Widget", new { widgetZone = "branddetails_after_featured_products", additionalData = Model.Id }) -
- -
- @*Rendering bottom description section*@ - - - @await Component.InvokeAsync("Widget", new { widgetZone = "branddetails_bottom", additionalData = Model.Id }) -
-
- -@section filters -{ - @*Rendering filtering section*@ - @*@if (Model.PagingFilteringContext.SpecificationFilter.Enabled) - { - - }*@ -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Catalog/CategoryLayout.GridOrLines.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Catalog/CategoryLayout.GridOrLines.cshtml deleted file mode 100644 index 7ff2a57a68..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Catalog/CategoryLayout.GridOrLines.cshtml +++ /dev/null @@ -1,170 +0,0 @@ -@model CategoryModel -@inject SeoSettings seoSettings -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - - pagebuilder.AddTitleParts(!string.IsNullOrEmpty(Model.MetaTitle) ? Model.MetaTitle : Model.Name); - pagebuilder.AddMetaDescriptionParts(Model.MetaDescription); - pagebuilder.AddMetaKeywordParts(Model.MetaKeywords); - var canonicalUrlsEnabled = seoSettings.CanonicalUrlsEnabled; - if (canonicalUrlsEnabled) - { - var categoryUrl = Url.RouteUrl("Category", new { Model.SeName }, Url.ActionContext.HttpContext.Request.Scheme); - pagebuilder.AddCanonicalUrlParts(categoryUrl); - } -} - -@* Rednering category breadcrumbs*@ - -@section Breadcrumb -{ - @if (Model.DisplayCategoryBreadcrumb) - { - var count = 0; - - - @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_after_breadcrumb", additionalData = Model.Id }) - } -} - -
- -

-
{{catalog.Model.Name}}
-

- @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_top", additionalData = Model.Id }) - @*Rendering category description*@ - @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_before_subcategories", additionalData = Model.Id }) - @*Rendering subcategories*@ - - @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_before_featured_products", additionalData = Model.Id }) - @*Rendering featured products section*@ - - @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_after_featured_products", additionalData = Model.Id }) - @await Component.InvokeAsync("Widget", new { widgetZone = "categorydetails_before_filters", additionalData = Model.Id }) -
- -
- @*Rendering bottom description*@ - - @*Rendering rccently viewed products section*@ - @await Component.InvokeAsync("RecentlyViewedProductsBlock", new { productThumbPictureSize = 235 }) -
- -@section filters -{ - @*Rendering filtering section*@ - @if (Model.PagingFilteringContext.SpecificationFilter.Enabled) - { - - } -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Catalog/CollectionLayout.GridOrLines.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Catalog/CollectionLayout.GridOrLines.cshtml deleted file mode 100644 index 662dcaa74c..0000000000 --- a/src/Plugins/Theme.Modern/Views/Modern/Catalog/CollectionLayout.GridOrLines.cshtml +++ /dev/null @@ -1,107 +0,0 @@ -@model CollectionModel -@inject SeoSettings seoSettings -@inject IPageHeadBuilder pagebuilder -@{ - Layout = "_TwoColumns"; - pagebuilder.AddTitleParts(!string.IsNullOrEmpty(Model.MetaTitle) ? Model.MetaTitle : Model.Name); - pagebuilder.AddMetaDescriptionParts(Model.MetaDescription); - pagebuilder.AddMetaKeywordParts(Model.MetaKeywords); - - var canonicalUrlsEnabled = seoSettings.CanonicalUrlsEnabled; - if (canonicalUrlsEnabled) - { - var collectionUrl = Url.RouteUrl("Collection", new { Model.SeName }, Url.ActionContext.HttpContext.Request.Scheme); - pagebuilder.AddCanonicalUrlParts(collectionUrl); - } -} - - - -
- @*Rendering description section*@ - -

-
{{catalog.Model.Name}}
-

- @await Component.InvokeAsync("Widget", new { widgetZone = "collectiondetails_top", additionalData = Model.Id }) - @await Component.InvokeAsync("Widget", new { widgetZone = "collectiondetails_before_featured_products", additionalData = Model.Id }) - @*Rendering featured products section*@ - - @await Component.InvokeAsync("Widget", new { widgetZone = "collectiondetails_after_featured_products", additionalData = Model.Id }) -
- @await Component.InvokeAsync("Widget", new { widgetZone = "collectiondetails_before_filters", additionalData = Model.Id }) - -
- - @*Rendering bottom description section*@ - - - @await Component.InvokeAsync("Widget", new { widgetZone = "collectiondetails_bottom", additionalData = Model.Id }) -
-
- -@section filters -{ - @*Rendering filtering section*@ - @if (Model.PagingFilteringContext.SpecificationFilter.Enabled) - { - - } -} - - \ No newline at end of file diff --git a/src/Plugins/Theme.Modern/Views/Modern/Catalog/Partials/Client/CatalogProductGridView.cshtml b/src/Plugins/Theme.Modern/Views/Modern/Catalog/Partials/Client/CatalogProductGridView.cshtml new file mode 100644 index 0000000000..ffd2e2f416 --- /dev/null +++ b/src/Plugins/Theme.Modern/Views/Modern/Catalog/Partials/Client/CatalogProductGridView.cshtml @@ -0,0 +1,221 @@ +@* + Client-side *template* for a product card - not a partial with a model. It is + inlined into a `