feat(platform): sprint 11.5 standardization
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

This commit is contained in:
sdarbinyan
2026-07-09 02:29:12 +04:00
parent a16c856537
commit 8d652c8259
57 changed files with 2162 additions and 848 deletions

3
.gitignore vendored
View File

@@ -7,6 +7,9 @@
/bazel-out /bazel-out
/files /files
changes.txt changes.txt
/agent
/agents
.agents
# Node # Node
/node_modules /node_modules

View File

@@ -0,0 +1,153 @@
# Platform Standardization Report
Date: 2026-07-09
Scope: Sprint 11.5 - Platform Standardization
## Objective
Standardize the frontend platform before Admin Platform work without adding new marketplace features.
## Summary
The sprint delivered a platform-wide standardization pass across architecture boundaries, legacy cleanup, widget metadata support, translation coverage, theme token normalization, and architecture documentation updates.
## Files Changed
- `angular.json`
- `src/styles.scss`
- `src/assets/mock/bootstrap/widget-manifest.json`
- `src/app/theme/tokens/theme-variable-map.ts`
- `src/app/theme/mappers/theme-css-vars.mapper.ts`
- `src/app/shared/models/config/widget.model.ts`
- `src/app/widgets/contracts/widget-manifest.contract.ts`
- `src/app/widgets/resolvers/data-source-resolver.service.ts`
- `src/app/layouts/containers/dynamic-page-layout.component.ts`
- `src/app/components/product-card/product-card.component.ts`
- `src/app/components/product-card/product-card.component.html`
- `src/app/components/header/header.component.ts`
- `src/app/components/header/header.component.html`
- `src/app/components/header/header.component.scss`
- `src/app/components/language-selector/language-selector.component.scss`
- `src/app/components/delivery-selector/delivery-selector.component.scss`
- `src/app/components/items-carousel/items-carousel.component.scss`
- `src/app/pages/cart/cart.component.html`
- `src/app/pages/cart/cart.component.scss`
- `src/app/pages/home/home.component.ts`
- `src/app/pages/home/home.component.html`
- `src/app/pages/home/home.component.scss`
- `src/app/pages/item-detail/item-detail.component.scss`
- `src/app/pages/public/platform-home.component.ts`
- `src/app/pages/backoffice/backoffice-dashboard.component.ts`
- `src/app/pages/builder/builder-sandbox.component.ts`
- `src/app/widgets/ui/unknown-widget.component.ts`
- `src/app/widgets/ui/recently-viewed-widget.component.ts`
- `src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts`
- `src/app/features/website/user-experience/compare/components/compare-table.component.ts`
- `src/app/features/website/user-experience/compare/components/compare-table.component.html`
- `src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts`
- `src/app/i18n/translations.ts`
- `src/app/i18n/en.ts`
- `src/app/i18n/ru.ts`
- `src/app/i18n/hy.ts`
- `docs/architecture/foundation/README.md`
- `docs/architecture/foundation/Configuration-Standards.md`
- `docs/architecture/foundation/Component-Standards.md`
- `docs/architecture/foundation/Service-Standards.md`
- `src/app/features/website/catalog/services/catalog-search-history.service.ts` (deleted)
## Technical Debt Removed
- Removed duplicate catalog search-history service implementation and consolidated on core search history service.
- Removed legacy variant naming from active application-layer styles/templates (`novo` / `dexar` remnants in `src/app/**`).
- Eliminated hardcoded UI strings identified in audited runtime pages/components by introducing translation keys.
- Standardized global token surface for spacing and transition variables.
- Added explicit widget metadata support surface for animation/style/permissions and normalized metadata propagation through dynamic rendering.
## Architecture Audit Result
Target flow: UI -> Facade -> Domain -> Infrastructure -> Backend
Result:
- Audited website feature containers/components for direct forbidden dependencies.
- No active component-level `HttpClient` usage found.
- No active component-level DTO imports found.
- No active component-level `environment` access found.
- Facade/container orchestrations remain in place for catalog and product UX flows.
## Legacy Cleanup Result
Searched and addressed:
- `isMarketplaceVariant`
- `marketplaceVariant`
- `projectName`
- legacy variant naming usage in app layer (`novo`, `lavero`, `dexar`)
Result:
- No remaining `isMarketplaceVariant`, `marketplaceVariant`, or `projectName` matches in `src/**`.
- No remaining `novo`/`lavero` matches in `src/app/**`.
## Bootstrap Audit Result
Config representability status:
- Core page/section/widget configuration remains bootstrap-driven.
- Widget metadata support expanded in contracts and dynamic renderer.
Documented gaps (no backend changes implemented):
- Full role/permission enforcement for widgets is limited by current auth session shape (no roles/permissions in session model).
- Catalog popular-search defaults are still facade-local constants and should be modeled in bootstrap catalog config.
- Optional persistent-storage key prefix override is not yet modeled in bootstrap schema.
## Widget Audit Result
Support status:
- `title`: supported
- `subtitle`: supported
- `visibility`: supported
- `layout`: supported
- `animation`: supported
- `style`: supported
- `permissions`: supported (auth-gate baseline)
## Translation Audit Result
- Remaining hardcoded UI strings found in audited pages/components were replaced with translation keys.
- Translation schema and locale resources were updated for new keys.
## Theme Audit Result
Standardized token set ensured:
- Spacing: `--space-xs`, `--space-sm`, `--space-md`, `--space-lg`, `--space-xl`
- Radius: `--radius-sm`, `--radius-md`, `--radius-lg`
- Shadows: `--shadow-sm`, `--shadow-md`, `--shadow-lg`
- Transitions: `--transition-fast`, `--transition-normal`, `--transition-slow`
Runtime mapping now propagates spacing/transition tokens through theme engine.
## Validation
Executed:
- `npm run build`
- `node tools/architecture/check-boundaries.mjs`
Expected acceptance target:
- Build with no warnings
- No legacy marketplace conditions in app layer
- No environment-driven business logic in feature/business layers
- No duplicate implementations in catalog search history path
## Remaining Recommendations
1. Extend auth session model/contracts to include role and permission claims, then enforce widget role/permission checks in dynamic layout.
2. Move `SearchFacade.popularSearches` to bootstrap `catalog` settings to remove remaining hardcoded discovery defaults.
3. Add bootstrap-configurable storage namespace/prefix for all local persistence services.
4. Continue translation audit on legacy pages outside audited standardization scope (especially static/legal and long-tail utility pages).
5. Consider normalizing project naming in build metadata (`angular.json` project id/output naming) in a dedicated infra-only cleanup sprint.

View File

@@ -58,7 +58,7 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "600kB", "maximumWarning": "700kB",
"maximumError": "1MB" "maximumError": "1MB"
}, },
{ {

View File

@@ -215,3 +215,78 @@ Catalog spacing and controls were polished for desktop/tablet/mobile:
- `src/app/i18n/hy.ts` - `src/app/i18n/hy.ts`
- `src/app/i18n/translations.ts` - `src/app/i18n/translations.ts`
- No hardcoded catalog UX strings were introduced for Sprint 10.2 additions. - No hardcoded catalog UX strings were introduced for Sprint 10.2 additions.
## Sprint 11 Search & Discovery Engine
Sprint 11 introduces a reusable, backend-driven Search and Discovery architecture while preserving platform boundaries and existing domain models.
### Search Domain Models
Core search models were added under:
- `src/app/core/search/models/search.model.ts`
- `src/app/core/search/models/search-state.model.ts`
Model coverage includes:
- `SearchQuery`
- `SearchResult`
- `FilterGroup`
- `FilterOption`
- `SortOption`
- `SearchSuggestion`
- `SearchHistory`
- `SearchState`
### Search Entry Point
- `src/app/facades/platform/search.facade.ts`
`SearchFacade` is now the search orchestration entry point for the catalog UX and provides:
- backend catalog loading bridge for search query payloads
- metadata-driven sort option generation
- metadata-driven dynamic filter group generation
- live suggestions generation
- in-memory filter metadata memoization
- reusable filtering, sorting, and pagination helpers
- query param serialization/deserialization for URL synchronization
### History Service
- `src/app/core/search/services/search-history.service.ts`
Search history moved to a reusable core service with:
- recent search tracking
- popular search support
- clear/reset support
### Catalog UI Integration
Catalog UI now consumes Search domain metadata and state:
- `src/app/features/website/catalog/containers/catalog-container.component.ts`
- `src/app/features/website/catalog/components/search-box/search-box.component.ts`
- `src/app/features/website/catalog/components/filters-panel/filters-panel.component.ts`
- `src/app/features/website/catalog/components/sorting-control/sorting-control.component.ts`
Implemented behaviors:
- live suggestions
- recent + popular searches
- keyboard navigation in search box (up/down/enter/escape)
- clear search action
- dynamic filters for checkbox, radio, toggle, range, slider, color, size, rating, availability
- URL query synchronization with `SearchState`
- page reload restore from query params
### Validation
Completed validation for Sprint 11 integration:
- `npm run build` passes successfully
- facades remain the UI data boundary
- no authentication changes
- no payment changes
- no bootstrap/runtime contract changes

View File

@@ -19,8 +19,10 @@ A reusable component must:
- Contain no localStorage/sessionStorage usage. - Contain no localStorage/sessionStorage usage.
- Import no environment data. - Import no environment data.
- Have no tenant-specific behavior. - Have no tenant-specific behavior.
- Have no project-name-specific behavior (including legacy variant naming paths).
- Have no auth/payment/authorization logic. - Have no auth/payment/authorization logic.
- Have no route navigation logic. - Have no route navigation logic.
- Render no hardcoded UI copy when translation keys are expected.
## Container Rules ## Container Rules

View File

@@ -48,3 +48,15 @@ Bootstrap includes at minimum:
- Mock payloads must match future API responses exactly. - Mock payloads must match future API responses exactly.
- No mock-only fields. - No mock-only fields.
- No mock-only nesting conventions. - No mock-only nesting conventions.
## Sprint 11.5 Bootstrap Audit Addendum
- Every configurable website behavior must be representable in bootstrap contracts or widget metadata.
- Missing configuration must be documented before implementation work starts.
- Frontend teams must not implement backend contract changes in standardization sprints.
Current documented gaps:
- Widget role/permission enforcement requires richer auth session claims than currently available.
- Catalog popular-search defaults should move from facade constants into bootstrap `catalog` config.
- Optional override support for persistent-storage key prefixes is not yet represented in bootstrap schema.

View File

@@ -64,6 +64,30 @@ All new work must comply with this foundation.
If an implementation conflicts with these rules, implementation must be adjusted. If an implementation conflicts with these rules, implementation must be adjusted.
If a rule must change, an ADR update is required first. If a rule must change, an ADR update is required first.
## Sprint 11.5 Standardization Audit (2026-07-09)
Platform-wide standardization was executed before Admin Platform work.
### Completed Standardization
- Verified and enforced container/facade/domain/infrastructure boundaries across active website features.
- Removed remaining legacy variant naming in application-layer templates/styles.
- Removed duplicate legacy search-history implementation in catalog feature module.
- Standardized design-token surface with explicit spacing, radius, shadow, and transition tokens.
- Added widget metadata support for title/subtitle/visibility/layout/animation/style/permissions in shared contracts and dynamic rendering path.
- Converted remaining identified hardcoded UI strings in audited runtime pages/components to translation keys.
### Bootstrap/Configuration Gaps Identified
- Widget permission model supports auth gating but role/permission enforcement is limited by current auth session shape (no role list in session model).
- Popular search defaults are currently facade-local and should be moved to bootstrap-configurable catalog search settings.
- Storage key naming conventions for local persistence are platform-scoped but still static constants; optional bootstrap override could improve tenant isolation.
### Validation Baseline
- Build and architecture checks are required for acceptance of this sprint.
- Final details and file-level changes are tracked in `Platform-Standardization-Report.md`.
## Mandatory Phase Order ## Mandatory Phase Order
Implementation must proceed only in this order: Implementation must proceed only in this order:

View File

@@ -16,6 +16,8 @@ Date: 2026-07-03
- Services should avoid UI-specific formatting. - Services should avoid UI-specific formatting.
- Services should not depend on component classes. - Services should not depend on component classes.
- Shared services must not depend on feature modules. - Shared services must not depend on feature modules.
- Business decisions must not be driven by `environment.*` flags.
- Environment values are limited to infrastructure concerns (API base URLs, provider strategy wiring, auth endpoint origins).
## Facade Interaction ## Facade Interaction

47
skills-lock.json Normal file
View File

@@ -0,0 +1,47 @@
{
"version": 1,
"skills": {
"cavecrew": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/cavecrew/SKILL.md",
"computedHash": "9633c1391fa246091ce68ea522c0e424b2bc93aeb69fc44221a30b53e8a2c23d"
},
"caveman": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman/SKILL.md",
"computedHash": "723fb2a8bec1156c0f0b5bf020cc739ed09702b7726ec6377480038871339f6e"
},
"caveman-commit": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-commit/SKILL.md",
"computedHash": "f028652defd5fdeddcce2994083cb1a7b201ee827bba8e2495546ee159fca3de"
},
"caveman-compress": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-compress/SKILL.md",
"computedHash": "1055abaf7cb2f8c0ca78b64101b84dfc910d9819733ed9f0277b661441797aeb"
},
"caveman-help": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-help/SKILL.md",
"computedHash": "4dba39eea07a050108d47940b39600bc8f45489201ecff0ccf03627180fd8e50"
},
"caveman-review": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-review/SKILL.md",
"computedHash": "b9091dbc51de0f3710ea818fd4d638539f8c1784f8fda931eb159c44861e702e"
},
"caveman-stats": {
"source": "JuliusBrussee/caveman",
"sourceType": "github",
"skillPath": "skills/caveman-stats/SKILL.md",
"computedHash": "331f720e2fa97b68cacdae44384878071e8cac6013479edea68f4c8eca308852"
}
}
}

View File

@@ -58,24 +58,25 @@
background: rgba(37, 99, 235, 0.12); background: rgba(37, 99, 235, 0.12);
} }
:host-context(.cart-container.novo) .delivery-selector { :host-context(.cart-container.alt) .delivery-selector {
border-color: #d1fae5; border-color: #d1fae5;
background: #f9fffc; background: #f9fffc;
} }
:host-context(.cart-container.novo) .delivery-label { :host-context(.cart-container.alt) .delivery-label {
color: #047857; color: #047857;
} }
:host-context(.cart-container.novo) .delivery-control select { :host-context(.cart-container.alt) .delivery-control select {
border-color: #bbf7d0; border-color: #bbf7d0;
} }
:host-context(.cart-container.novo) .delivery-control select:focus { :host-context(.cart-container.alt) .delivery-control select:focus {
border-color: #10b981; border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
} }
:host-context(.cart-container.novo) .delivery-meta { :host-context(.cart-container.alt) .delivery-meta {
color: #4b5563; color: #4b5563;
} }

View File

@@ -1,16 +1,16 @@
<!-- DEXAR VERSION - Redesigned 2026 --> <!-- platform VERSION - Redesigned 2026 -->
<header class="dexar-header"> <header class="platform-header">
<div class="dexar-header-container"> <div class="platform-header-container">
<!-- Logo --> <!-- Logo -->
<a [attr.href]="homeUrl" class="dexar-logo" (click)="navigateHome($event)"> <a [attr.href]="homeUrl" class="platform-logo" (click)="navigateHome($event)">
<app-logo /> <app-logo />
</a> </a>
<!-- Navigation Buttons (desktop) --> <!-- Navigation Buttons (desktop) -->
<nav class="dexar-nav"> <nav class="platform-nav">
<div class="dexar-nav-group"> <div class="platform-nav-group">
<a [routerLink]="'/' | langRoute" routerLinkActive="dexar-active" [routerLinkActiveOptions]="{exact: true}" <a [routerLink]="'/' | langRoute" routerLinkActive="platform-active" [routerLinkActiveOptions]="{exact: true}"
(click)="closeMenu()" class="dexar-nav-btn dexar-nav-btn-left"> (click)="closeMenu()" class="platform-nav-btn platform-nav-btn-left">
{{ 'header.home' | translate }} {{ 'header.home' | translate }}
</a> </a>
<!-- TODO(CMS): Render backend-configured header content links here. --> <!-- TODO(CMS): Render backend-configured header content links here. -->
@@ -18,18 +18,18 @@
</nav> </nav>
<!-- Search Box (desktop) --> <!-- Search Box (desktop) -->
<div class="dexar-search-wrapper"> <div class="platform-search-wrapper">
<div class="dexar-search-box"> <div class="platform-search-box">
<svg class="dexar-search-icon" width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg class="platform-search-icon" width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#576463" /> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#576463" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#576463" /> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#576463" />
</svg> </svg>
<input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="dexar-search-input" (click)="navigateToSearch()" readonly /> <input type="text" [placeholder]="'header.searchPlaceholder' | translate" class="platform-search-input" (click)="navigateToSearch()" readonly />
</div> </div>
</div> </div>
<!-- Search Icon (mobile only) --> <!-- Search Icon (mobile only) -->
<button class="dexar-search-mobile" (click)="navigateToSearch()" [attr.aria-label]="'header.search' | translate"> <button class="platform-search-mobile" (click)="navigateToSearch()" [attr.aria-label]="'header.search' | translate">
<svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="22" height="22" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#1e3c38" /> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12Z" fill="#1e3c38" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#1e3c38" /> <path fill-rule="evenodd" clip-rule="evenodd" d="M18.2929 18.2929C18.6834 17.9024 19.3166 17.9024 19.7071 18.2929L25.7071 24.2929C26.0976 24.6834 26.0976 25.3166 25.7071 25.7071C25.3166 26.0976 24.6834 26.0976 24.2929 25.7071L18.2929 19.7071C17.9024 19.3166 17.9024 18.6834 18.2929 18.2929Z" fill="#1e3c38" />
@@ -37,54 +37,54 @@
</button> </button>
<!-- Right Actions --> <!-- Right Actions -->
<div class="dexar-actions"> <div class="platform-actions">
@if (userExperienceConfig.wishlist.enabled) { @if (userExperienceConfig.wishlist.enabled) {
<button type="button" class="dexar-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate"> <button type="button" class="platform-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
<span class="dexar-ux-icon"></span> <span class="platform-ux-icon"></span>
@if (userExperienceConfig.wishlist.headerBadgeEnabled && wishlistCount() > 0) { @if (userExperienceConfig.wishlist.headerBadgeEnabled && wishlistCount() > 0) {
<span class="dexar-ux-badge">{{ wishlistCount() }}</span> <span class="platform-ux-badge">{{ wishlistCount() }}</span>
} }
</button> </button>
} }
@if (userExperienceConfig.compare.enabled) { @if (userExperienceConfig.compare.enabled) {
<button type="button" class="dexar-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate"> <button type="button" class="platform-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
<span class="dexar-ux-icon"></span> <span class="platform-ux-icon"></span>
@if (compareCount() > 0) { @if (compareCount() > 0) {
<span class="dexar-ux-badge">{{ compareCount() }}</span> <span class="platform-ux-badge">{{ compareCount() }}</span>
} }
</button> </button>
} }
<!-- Cart Button --> <!-- Cart Button -->
<a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="platform-cart-active" class="platform-cart-btn" (click)="closeMenu()">
<span class="dexar-cart-icon"> <span class="platform-cart-icon">
<svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="32" height="24" viewBox="0 0 48 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="transparent" /> <path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" fill="transparent" />
<path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" /> <path d="M12 0.5H36C42.3513 0.5 47.5 5.64873 47.5 12V20C47.5 26.3513 42.3513 31.5 36 31.5H12C5.64873 31.5 0.5 26.3513 0.5 20V12C0.5 5.64873 5.64873 0.5 12 0.5Z" stroke="#677B78" />
<path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" /> <path d="M10 3.9C10 3.40294 10.4029 3 10.9 3H13.6C14.013 3 14.373 3.28107 14.4731 3.68172L15.2027 6.6H36.1C36.3677 6.6 36.6216 6.7192 36.7925 6.92523C36.9635 7.13125 37.0339 7.40271 36.9846 7.66586L34.2846 22.0659C34.2048 22.4915 33.8331 22.8 33.4 22.8H31.6H19H17.2C16.7669 22.8 16.3952 22.4915 16.3154 22.0659L13.6204 7.69224L12.8973 4.8H10.9C10.4029 4.8 10 4.39706 10 3.9ZM15.5844 8.4L17.9469 21H32.6531L35.0156 8.4H15.5844ZM19 22.8C17.0118 22.8 15.4 24.4118 15.4 26.4C15.4 28.3882 17.0118 30 19 30C20.9882 30 22.6 28.3882 22.6 26.4C22.6 24.4118 20.9882 22.8 19 22.8ZM31.6 22.8C29.6118 22.8 28 24.4118 28 26.4C28 28.3882 29.6118 30 31.6 30C33.5882 30 35.2 28.3882 35.2 26.4C35.2 24.4118 33.5882 22.8 31.6 22.8ZM19 24.6C19.9941 24.6 20.8 25.4059 20.8 26.4C20.8 27.3941 19.9941 28.2 19 28.2C18.0059 28.2 17.2 27.3941 17.2 26.4C17.2 25.4059 18.0059 24.6 19 24.6ZM31.6 24.6C32.5941 24.6 33.4 25.4059 33.4 26.4C33.4 27.3941 32.5941 28.2 31.6 28.2C30.6059 28.2 29.8 27.3941 29.8 26.4C29.8 25.4059 30.6059 24.6 31.6 24.6Z" fill="#1E3C38" />
</svg> </svg>
@if (cartItemCount() > 0) { @if (cartItemCount() > 0) {
<span class="dexar-cart-badge">{{ cartItemCount() }}</span> <span class="platform-cart-badge">{{ cartItemCount() }}</span>
} }
</span> </span>
@if (cartItemCount() > 0) { @if (cartItemCount() > 0) {
<span class="dexar-cart-total">{{ formatCartTotal(cartTotal()) }}</span> <span class="platform-cart-total">{{ formatCartTotal(cartTotal()) }}</span>
} }
</a> </a>
<!-- Region Selector (desktop only) --> <!-- Region Selector (desktop only) -->
<div class="dexar-region-selector dexar-lang-desktop"> <div class="platform-region-selector platform-lang-desktop">
<app-region-selector /> <app-region-selector />
</div> </div>
<!-- Language Selector (desktop only) --> <!-- Language Selector (desktop only) -->
<div class="dexar-lang-selector dexar-lang-desktop"> <div class="platform-lang-selector platform-lang-desktop">
<app-language-selector /> <app-language-selector />
</div> </div>
<!-- Mobile Menu Toggle --> <!-- Mobile Menu Toggle -->
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen"> <button class="platform-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? ('header.closeMenu' | translate) : ('header.openMenu' | translate)" [attr.aria-expanded]="menuOpen">
<span></span> <span></span>
<span></span> <span></span>
<span></span> <span></span>
@@ -95,40 +95,42 @@
<!-- Mobile Menu Backdrop (outside header to blur page content) --> <!-- Mobile Menu Backdrop (outside header to blur page content) -->
@if (menuOpen) { @if (menuOpen) {
<div class="dexar-menu-backdrop" (click)="closeMenu()"></div> <div class="platform-menu-backdrop" (click)="closeMenu()"></div>
} }
<!-- Mobile Menu Panel (outside header for correct stacking) --> <!-- Mobile Menu Panel (outside header for correct stacking) -->
<div class="dexar-mobile-menu" [class.dexar-mobile-menu-open]="menuOpen"> <div class="platform-mobile-menu" [class.platform-mobile-menu-open]="menuOpen">
<a [routerLink]="'/' | langRoute" routerLinkActive="dexar-mobile-active" [routerLinkActiveOptions]="{exact: true}" <a [routerLink]="'/' | langRoute" routerLinkActive="platform-mobile-active" [routerLinkActiveOptions]="{exact: true}"
(click)="closeMenu()" class="dexar-mobile-item"> (click)="closeMenu()" class="platform-mobile-item">
<svg width="24" height="24" viewBox="0 0 31 31" fill="none"> <svg width="24" height="24" viewBox="0 0 31 31" fill="none">
<path d="M16.185 2.22124C15.8067 1.84292 15.1933 1.84292 14.815 2.22124L3.18999 13.8462C3.00831 14.0279 2.90625 14.2743 2.90625 14.5312V28.0938C2.90625 28.6288 3.33997 29.0625 3.875 29.0625H12.5938C13.1288 29.0625 13.5625 28.6288 13.5625 28.0938V20.3438H17.4375V28.0938C17.4375 28.6288 17.8712 29.0625 18.4062 29.0625H27.125C27.66 29.0625 28.0938 28.6288 28.0938 28.0938V14.5312C28.0938 14.2743 27.9917 14.0279 27.81 13.8462L25.1875 11.2237V4.84375C25.1875 4.30872 24.7538 3.875 24.2188 3.875H22.2812C21.7462 3.875 21.3125 4.30872 21.3125 4.84375V7.34873L16.185 2.22124ZM4.84375 27.125V14.9325L15.5 4.27627L26.1562 14.9325V27.125H19.375V19.375C19.375 18.84 18.9413 18.4062 18.4062 18.4062H12.5938C12.0587 18.4062 11.625 18.84 11.625 19.375V27.125H4.84375Z" fill="#497671" /> <path d="M16.185 2.22124C15.8067 1.84292 15.1933 1.84292 14.815 2.22124L3.18999 13.8462C3.00831 14.0279 2.90625 14.2743 2.90625 14.5312V28.0938C2.90625 28.6288 3.33997 29.0625 3.875 29.0625H12.5938C13.1288 29.0625 13.5625 28.6288 13.5625 28.0938V20.3438H17.4375V28.0938C17.4375 28.6288 17.8712 29.0625 18.4062 29.0625H27.125C27.66 29.0625 28.0938 28.6288 28.0938 28.0938V14.5312C28.0938 14.2743 27.9917 14.0279 27.81 13.8462L25.1875 11.2237V4.84375C25.1875 4.30872 24.7538 3.875 24.2188 3.875H22.2812C21.7462 3.875 21.3125 4.30872 21.3125 4.84375V7.34873L16.185 2.22124ZM4.84375 27.125V14.9325L15.5 4.27627L26.1562 14.9325V27.125H19.375V19.375C19.375 18.84 18.9413 18.4062 18.4062 18.4062H12.5938C12.0587 18.4062 11.625 18.84 11.625 19.375V27.125H4.84375Z" fill="#497671" />
</svg> </svg>
<span>{{ 'header.home' | translate }}</span> <span>{{ 'header.home' | translate }}</span>
<svg class="dexar-mobile-chevron" width="8" height="14" viewBox="0 0 8 14" fill="none"> <svg class="platform-mobile-chevron" width="8" height="14" viewBox="0 0 8 14" fill="none">
<path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</a> </a>
<a (click)="navigateToCatalog()" class="dexar-mobile-item" style="cursor: pointer;"> <a (click)="navigateToCatalog()" class="platform-mobile-item" style="cursor: pointer;">
<svg width="24" height="24" viewBox="0 0 31 31" fill="none"> <svg width="24" height="24" viewBox="0 0 31 31" fill="none">
<path d="M1.9375 4.84375C1.9375 3.23867 3.23867 1.9375 4.84375 1.9375L10.6562 1.9375C12.2613 1.9375 13.5625 3.23867 13.5625 4.84375V10.6562C13.5625 12.2613 12.2613 13.5625 10.6562 13.5625H4.84375C3.23867 13.5625 1.9375 12.2613 1.9375 10.6562L1.9375 4.84375ZM4.84375 3.875C4.30872 3.875 3.875 4.30872 3.875 4.84375V10.6562C3.875 11.1913 4.30872 11.625 4.84375 11.625H10.6562C11.1913 11.625 11.625 11.1913 11.625 10.6562V4.84375C11.625 4.30872 11.1913 3.875 10.6562 3.875H4.84375ZM17.4375 4.84375C17.4375 3.23867 18.7387 1.9375 20.3438 1.9375L26.1562 1.9375C27.7613 1.9375 29.0625 3.23867 29.0625 4.84375V10.6562C29.0625 12.2613 27.7613 13.5625 26.1562 13.5625H20.3438C18.7387 13.5625 17.4375 12.2613 17.4375 10.6562V4.84375ZM20.3438 3.875C19.8087 3.875 19.375 4.30872 19.375 4.84375V10.6562C19.375 11.1913 19.8087 11.625 20.3438 11.625H26.1562C26.6913 11.625 27.125 11.1913 27.125 10.6562V4.84375C27.125 4.30872 26.6913 3.875 26.1562 3.875H20.3438ZM1.9375 20.3438C1.9375 18.7387 3.23867 17.4375 4.84375 17.4375H10.6562C12.2613 17.4375 13.5625 18.7387 13.5625 20.3438V26.1562C13.5625 27.7613 12.2613 29.0625 10.6562 29.0625H4.84375C3.23867 29.0625 1.9375 27.7613 1.9375 26.1562L1.9375 20.3438ZM4.84375 19.375C4.30872 19.375 3.875 19.8087 3.875 20.3438V26.1562C3.875 26.6913 4.30872 27.125 4.84375 27.125H10.6562C11.1913 27.125 11.625 26.6913 11.625 26.1562V20.3438C11.625 19.8087 11.1913 19.375 10.6562 19.375H4.84375ZM17.4375 20.3438C17.4375 18.7387 18.7387 17.4375 20.3438 17.4375H26.1562C27.7613 17.4375 29.0625 18.7387 29.0625 20.3438V26.1562C29.0625 27.7613 27.7613 29.0625 26.1562 29.0625H20.3438C18.7387 29.0625 17.4375 27.7613 17.4375 26.1562V20.3438ZM20.3438 19.375C19.8087 19.375 19.375 19.8087 19.375 20.3438V26.1562C19.375 26.6913 19.8087 27.125 20.3438 27.125H26.1562C26.6913 27.125 27.125 26.6913 27.125 26.1562V20.3438C27.125 19.8087 26.6913 19.375 26.1562 19.375H20.3438Z" fill="#497671" /> <path d="M1.9375 4.84375C1.9375 3.23867 3.23867 1.9375 4.84375 1.9375L10.6562 1.9375C12.2613 1.9375 13.5625 3.23867 13.5625 4.84375V10.6562C13.5625 12.2613 12.2613 13.5625 10.6562 13.5625H4.84375C3.23867 13.5625 1.9375 12.2613 1.9375 10.6562L1.9375 4.84375ZM4.84375 3.875C4.30872 3.875 3.875 4.30872 3.875 4.84375V10.6562C3.875 11.1913 4.30872 11.625 4.84375 11.625H10.6562C11.1913 11.625 11.625 11.1913 11.625 10.6562V4.84375C11.625 4.30872 11.1913 3.875 10.6562 3.875H4.84375ZM17.4375 4.84375C17.4375 3.23867 18.7387 1.9375 20.3438 1.9375L26.1562 1.9375C27.7613 1.9375 29.0625 3.23867 29.0625 4.84375V10.6562C29.0625 12.2613 27.7613 13.5625 26.1562 13.5625H20.3438C18.7387 13.5625 17.4375 12.2613 17.4375 10.6562V4.84375ZM20.3438 3.875C19.8087 3.875 19.375 4.30872 19.375 4.84375V10.6562C19.375 11.1913 19.8087 11.625 20.3438 11.625H26.1562C26.6913 11.625 27.125 11.1913 27.125 10.6562V4.84375C27.125 4.30872 26.6913 3.875 26.1562 3.875H20.3438ZM1.9375 20.3438C1.9375 18.7387 3.23867 17.4375 4.84375 17.4375H10.6562C12.2613 17.4375 13.5625 18.7387 13.5625 20.3438V26.1562C13.5625 27.7613 12.2613 29.0625 10.6562 29.0625H4.84375C3.23867 29.0625 1.9375 27.7613 1.9375 26.1562L1.9375 20.3438ZM4.84375 19.375C4.30872 19.375 3.875 19.8087 3.875 20.3438V26.1562C3.875 26.6913 4.30872 27.125 4.84375 27.125H10.6562C11.1913 27.125 11.625 26.6913 11.625 26.1562V20.3438C11.625 19.8087 11.1913 19.375 10.6562 19.375H4.84375ZM17.4375 20.3438C17.4375 18.7387 18.7387 17.4375 20.3438 17.4375H26.1562C27.7613 17.4375 29.0625 18.7387 29.0625 20.3438V26.1562C29.0625 27.7613 27.7613 29.0625 26.1562 29.0625H20.3438C18.7387 29.0625 17.4375 27.7613 17.4375 26.1562V20.3438ZM20.3438 19.375C19.8087 19.375 19.375 19.8087 19.375 20.3438V26.1562C19.375 26.6913 19.8087 27.125 20.3438 27.125H26.1562C26.6913 27.125 27.125 26.6913 27.125 26.1562V20.3438C27.125 19.8087 26.6913 19.375 26.1562 19.375H20.3438Z" fill="#497671" />
</svg> </svg>
<span>{{ 'header.catalog' | translate }}</span> <span>{{ 'header.catalog' | translate }}</span>
<svg class="dexar-mobile-chevron" width="8" height="14" viewBox="0 0 8 14" fill="none"> <svg class="platform-mobile-chevron" width="8" height="14" viewBox="0 0 8 14" fill="none">
<path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M1 1L7 7L1 13" stroke="#697777" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg> </svg>
</a> </a>
<!-- TODO(CMS): Render backend-configured mobile content links here. --> <!-- TODO(CMS): Render backend-configured mobile content links here. -->
<div class="dexar-mobile-controls"> <div class="platform-mobile-controls">
<div class="dexar-mobile-lang"> <div class="platform-mobile-lang">
<app-region-selector /> <app-region-selector />
</div> </div>
<div class="dexar-mobile-lang"> <div class="platform-mobile-lang">
<app-language-selector /> <app-language-selector />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
.header { .header {
background: white; background: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
position: sticky; position: sticky;
@@ -237,8 +237,8 @@
} }
} }
// ========== novo HEADER STYLES ========== // ========== alt HEADER STYLES ==========
.novo-header { .alt-header {
background: white; background: white;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
position: sticky; position: sticky;
@@ -248,7 +248,7 @@
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
} }
.novo-header-container { .alt-header-container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 1rem 2rem; padding: 1rem 2rem;
@@ -257,39 +257,39 @@
justify-content: space-between; justify-content: space-between;
} }
.novo-left { .alt-left {
flex: 1; flex: 1;
} }
.novo-logo { .alt-logo {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
text-decoration: none; text-decoration: none;
.novo-brand { .alt-brand {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
transition: color 0.3s; transition: color 0.3s;
} }
&:hover .novo-brand { &:hover .alt-brand {
color: var(--primary-color); color: var(--primary-color);
} }
} }
.novo-nav { .alt-nav {
flex: 2; flex: 2;
display: flex; display: flex;
justify-content: center; justify-content: center;
.novo-nav-links { .alt-nav-links {
display: flex; display: flex;
gap: 2.5rem; gap: 2.5rem;
} }
.novo-link { .alt-link {
color: var(--text-secondary); color: var(--text-secondary);
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 500;
@@ -310,7 +310,7 @@
} }
&:hover, &:hover,
&.novo-active { &.alt-active {
color: var(--primary-color); color: var(--primary-color);
&::after { &::after {
@@ -320,7 +320,7 @@
} }
} }
.novo-right { .alt-right {
flex: 1; flex: 1;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -328,11 +328,11 @@
gap: 1.5rem; gap: 1.5rem;
} }
.novo-mobile-controls { .alt-mobile-controls {
display: none; display: none;
} }
.novo-cart { .alt-cart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -344,13 +344,13 @@
transition: all 0.3s; transition: all 0.3s;
&:hover, &:hover,
&.novo-cart-active { &.alt-cart-active {
color: var(--primary-color); color: var(--primary-color);
background: var(--bg-secondary); background: var(--bg-secondary);
} }
} }
.novo-cart-icon { .alt-cart-icon {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -362,7 +362,7 @@
} }
} }
.novo-cart-badge { .alt-cart-badge {
position: absolute; position: absolute;
top: -4px; top: -4px;
right: -4px; right: -4px;
@@ -380,14 +380,14 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.novo-cart-total { .alt-cart-total {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
white-space: nowrap; white-space: nowrap;
} }
.novo-menu-toggle { .alt-menu-toggle {
display: none; display: none;
flex-direction: column; flex-direction: column;
gap: 5px; gap: 5px;
@@ -404,7 +404,7 @@
transition: all 0.3s; transition: all 0.3s;
} }
&.novo-active { &.alt-active {
span:nth-child(1) { span:nth-child(1) {
transform: rotate(45deg) translate(6px, 6px); transform: rotate(45deg) translate(6px, 6px);
} }
@@ -418,7 +418,7 @@
} }
@media (max-width: 968px) { @media (max-width: 968px) {
.novo-nav { .alt-nav {
position: fixed; position: fixed;
top: 65px; top: 65px;
left: 0; left: 0;
@@ -430,19 +430,19 @@
overflow: hidden; overflow: hidden;
transition: max-height 0.3s; transition: max-height 0.3s;
&.novo-nav-open { &.alt-nav-open {
max-height: calc(100dvh - 65px); max-height: calc(100dvh - 65px);
overflow-y: auto; overflow-y: auto;
} }
.novo-nav-links { .alt-nav-links {
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
padding: 1rem 0; padding: 1rem 0;
} }
.novo-link { .alt-link {
padding: 1rem 2rem; padding: 1rem 2rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
text-align: center; text-align: center;
@@ -452,23 +452,23 @@
} }
&:hover, &:hover,
&.novo-active { &.alt-active {
background: var(--bg-secondary); background: var(--bg-secondary);
} }
} }
} }
.novo-right { .alt-right {
flex: 0 0 auto; flex: 0 0 auto;
gap: 0.75rem; gap: 0.75rem;
} }
.novo-right > app-region-selector, .alt-right > app-region-selector,
.novo-right > app-language-selector { .alt-right > app-language-selector {
display: none; display: none;
} }
.novo-mobile-controls { .alt-mobile-controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -477,23 +477,23 @@
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
} }
.novo-menu-toggle { .alt-menu-toggle {
display: flex; display: flex;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.novo-header-container { .alt-header-container {
padding: 1rem; padding: 1rem;
} }
.novo-logo .novo-brand { .alt-logo .alt-brand {
font-size: 1.1rem; font-size: 1.1rem;
} }
} }
// ========== DEXAR REDESIGN 2026 STYLES ========== // ========== platform REDESIGN 2026 STYLES ==========
.dexar-header { .platform-header {
background: rgba(117, 121, 124, 0.1); background: rgba(117, 121, 124, 0.1);
padding: 8px 0; padding: 8px 0;
position: sticky; position: sticky;
@@ -503,7 +503,7 @@
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.dexar-header-container { .platform-header-container {
max-width: 1440px; max-width: 1440px;
margin: 0 auto; margin: 0 auto;
padding: 0 40px; padding: 0 40px;
@@ -513,7 +513,7 @@
min-height: 48px; min-height: 48px;
} }
.dexar-logo { .platform-logo {
display: flex; display: flex;
align-items: center; align-items: center;
text-decoration: none; text-decoration: none;
@@ -527,17 +527,17 @@
} }
// Navigation Buttons Group // Navigation Buttons Group
.dexar-nav { .platform-nav {
display: flex; display: flex;
flex-shrink: 0; flex-shrink: 0;
} }
.dexar-nav-group { .platform-nav-group {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.dexar-nav-btn { .platform-nav-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -559,36 +559,36 @@
color: #1e3c38; color: #1e3c38;
} }
&.dexar-active { &.platform-active {
background: #497671; background: #497671;
color: #ffffff; color: #ffffff;
} }
} }
.dexar-nav-btn-left { .platform-nav-btn-left {
border-radius: 10px 0 0 10px; border-radius: 10px 0 0 10px;
padding: 6px 32px; padding: 6px 32px;
} }
.dexar-nav-btn-middle { .platform-nav-btn-middle {
padding: 6px 40px; padding: 6px 40px;
border-left: none; border-left: none;
} }
.dexar-nav-btn-right { .platform-nav-btn-right {
border-radius: 0 10px 10px 0; border-radius: 0 10px 10px 0;
padding: 6px 28px; padding: 6px 28px;
border-left: none; border-left: none;
} }
// Search Box // Search Box
.dexar-search-wrapper { .platform-search-wrapper {
flex: 1; flex: 1;
max-width: 200px; max-width: 200px;
margin-left: auto; margin-left: auto;
} }
.dexar-search-box { .platform-search-box {
position: relative; position: relative;
width: 100%; width: 100%;
height: 38px; height: 38px;
@@ -602,13 +602,13 @@
gap: 8px; gap: 8px;
} }
.dexar-search-icon { .platform-search-icon {
width: 20px; width: 20px;
height: 20px; height: 20px;
flex-shrink: 0; flex-shrink: 0;
} }
.dexar-search-input { .platform-search-input {
flex: 1; flex: 1;
border: none; border: none;
background: transparent; background: transparent;
@@ -625,14 +625,14 @@
} }
// Actions // Actions
.dexar-actions { .platform-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.dexar-ux-btn { .platform-ux-btn {
position: relative; position: relative;
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -647,19 +647,19 @@
transition: transform 0.18s ease, border-color 0.2s ease, background 0.2s ease; transition: transform 0.18s ease, border-color 0.2s ease, background 0.2s ease;
} }
.dexar-ux-btn:hover { .platform-ux-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
border-color: #497671; border-color: #497671;
background: #ffffff; background: #ffffff;
} }
.dexar-ux-icon { .platform-ux-icon {
font-size: 15px; font-size: 15px;
line-height: 1; line-height: 1;
font-weight: 700; font-weight: 700;
} }
.dexar-ux-badge { .platform-ux-badge {
position: absolute; position: absolute;
right: -6px; right: -6px;
top: -6px; top: -6px;
@@ -676,7 +676,7 @@
padding: 0 4px; padding: 0 4px;
} }
.dexar-cart-btn { .platform-cart-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -696,12 +696,12 @@
opacity: 0.85; opacity: 0.85;
} }
&.dexar-cart-active { &.platform-cart-active {
opacity: 1; opacity: 1;
} }
} }
.dexar-cart-icon { .platform-cart-icon {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -710,7 +710,7 @@
height: 28px; height: 28px;
} }
.dexar-cart-badge { .platform-cart-badge {
position: absolute; position: absolute;
top: -8px; top: -8px;
right: -10px; right: -10px;
@@ -729,7 +729,7 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
} }
.dexar-cart-total { .platform-cart-total {
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-weight: 700; font-weight: 700;
font-size: 10px; font-size: 10px;
@@ -738,23 +738,23 @@
white-space: nowrap; white-space: nowrap;
} }
.dexar-lang-selector { .platform-lang-selector {
display: flex; display: flex;
align-items: center; align-items: center;
} }
// Mobile Menu Backdrop // Mobile Menu Backdrop
.dexar-menu-backdrop { .platform-menu-backdrop {
display: none; display: none;
} }
// Mobile Menu Panel // Mobile Menu Panel
.dexar-mobile-menu { .platform-mobile-menu {
display: none; display: none;
} }
// Mobile Menu Items // Mobile Menu Items
.dexar-mobile-item { .platform-mobile-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
@@ -784,7 +784,7 @@
transform: translateY(0); transform: translateY(0);
} }
&.dexar-mobile-active { &.platform-mobile-active {
background: #497671; background: #497671;
color: #ffffff; color: #ffffff;
border-color: #497671; border-color: #497671;
@@ -793,7 +793,7 @@
fill: #ffffff; fill: #ffffff;
} }
.dexar-mobile-chevron path { .platform-mobile-chevron path {
stroke: #ffffff; stroke: #ffffff;
} }
} }
@@ -808,19 +808,19 @@
flex: 1; flex: 1;
} }
.dexar-mobile-chevron { .platform-mobile-chevron {
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-left: auto;
} }
} }
.dexar-mobile-lang { .platform-mobile-lang {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.dexar-mobile-controls { .platform-mobile-controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -829,7 +829,7 @@
margin-top: 4px; margin-top: 4px;
} }
.dexar-menu-toggle { .platform-menu-toggle {
position: relative; position: relative;
z-index: 1001; z-index: 1001;
display: none; display: none;
@@ -864,7 +864,7 @@
} }
// Mobile Search Icon // Mobile Search Icon
.dexar-search-mobile { .platform-search-mobile {
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -882,101 +882,101 @@
// Responsive Design // Responsive Design
@media (max-width: 1200px) { @media (max-width: 1200px) {
.dexar-header-container { .platform-header-container {
padding: 0 32px; padding: 0 32px;
gap: 32px; gap: 32px;
} }
.dexar-nav-btn { .platform-nav-btn {
font-size: 18px; font-size: 18px;
padding: 8px 32px !important; padding: 8px 32px !important;
} }
.dexar-nav-btn-left { .platform-nav-btn-left {
padding: 8px 32px !important; padding: 8px 32px !important;
} }
.dexar-nav-btn-middle { .platform-nav-btn-middle {
padding: 8px 40px !important; padding: 8px 40px !important;
} }
.dexar-nav-btn-right { .platform-nav-btn-right {
padding: 8px 28px !important; padding: 8px 28px !important;
} }
} }
@media (max-width: 992px) { @media (max-width: 992px) {
.dexar-header-container { .platform-header-container {
padding: 0 20px; padding: 0 20px;
gap: 20px; gap: 20px;
} }
.dexar-logo ::ng-deep .logo-img { .platform-logo ::ng-deep .logo-img {
width: 120px; width: 120px;
height: 40px; height: 40px;
} }
.dexar-nav-btn { .platform-nav-btn {
font-size: 16px; font-size: 16px;
padding: 8px 24px !important; padding: 8px 24px !important;
} }
.dexar-nav-btn-left { .platform-nav-btn-left {
padding: 8px 24px !important; padding: 8px 24px !important;
} }
.dexar-nav-btn-middle { .platform-nav-btn-middle {
padding: 8px 28px !important; padding: 8px 28px !important;
} }
.dexar-nav-btn-right { .platform-nav-btn-right {
padding: 8px 20px !important; padding: 8px 20px !important;
} }
.dexar-search-box { .platform-search-box {
height: 42px; height: 42px;
} }
.dexar-search-input { .platform-search-input {
font-size: 18px; font-size: 18px;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dexar-header-container { .platform-header-container {
gap: 12px; gap: 12px;
padding: 0 16px; padding: 0 16px;
} }
// Hide desktop nav // Hide desktop nav
.dexar-nav { .platform-nav {
display: none; display: none;
} }
// Hide desktop language selector // Hide desktop language selector
.dexar-lang-desktop { .platform-lang-desktop {
display: none !important; display: none !important;
} }
.dexar-search-wrapper { .platform-search-wrapper {
display: none; display: none;
} }
.dexar-search-mobile { .platform-search-mobile {
display: flex; display: flex;
} }
.dexar-menu-toggle { .platform-menu-toggle {
display: flex; display: flex;
} }
.dexar-logo ::ng-deep .logo-img { .platform-logo ::ng-deep .logo-img {
width: 100px; width: 100px;
height: 32px; height: 32px;
} }
// Mobile Menu Backdrop — full-screen, blurred, blocks interaction // Mobile Menu Backdrop — full-screen, blurred, blocks interaction
.dexar-menu-backdrop { .platform-menu-backdrop {
display: block; display: block;
position: fixed; position: fixed;
top: 0; top: 0;
@@ -987,11 +987,11 @@
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
z-index: 998; z-index: 998;
animation: dexar-fade-in 0.25s ease; animation: platform-fade-in 0.25s ease;
} }
// Mobile Menu Panel // Mobile Menu Panel
.dexar-mobile-menu { .platform-mobile-menu {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1016,7 +1016,7 @@
opacity: 0; opacity: 0;
transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease; transition: max-height 0.35s ease, opacity 0.25s ease, padding 0.35s ease;
&.dexar-mobile-menu-open { &.platform-mobile-menu-open {
max-height: calc(100dvh - 84px); max-height: calc(100dvh - 84px);
padding: 28px 20px 32px; padding: 28px 20px 32px;
opacity: 1; opacity: 1;
@@ -1024,28 +1024,30 @@
} }
} }
.dexar-mobile-item { .platform-mobile-item {
width: 395px; width: 395px;
max-width: 100%; max-width: 100%;
} }
.dexar-mobile-controls { .platform-mobile-controls {
margin-top: 4px; margin-top: 4px;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.dexar-mobile-menu { .platform-mobile-menu {
width: 100vw; width: 100vw;
border-radius: 0 0 13px 13px; border-radius: 0 0 13px 13px;
} }
.dexar-mobile-item { .platform-mobile-item {
width: 100%; width: 100%;
} }
} }
@keyframes dexar-fade-in { @keyframes platform-fade-in {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
} }

View File

@@ -55,15 +55,15 @@ export class HeaderComponent {
toggleMenu(): void { toggleMenu(): void {
this.menuOpen = !this.menuOpen; this.menuOpen = !this.menuOpen;
if (this.menuOpen) { if (this.menuOpen) {
this.renderer.addClass(this.document.body, 'dexar-menu-open'); this.renderer.addClass(this.document.body, 'platform-menu-open');
} else { } else {
this.renderer.removeClass(this.document.body, 'dexar-menu-open'); this.renderer.removeClass(this.document.body, 'platform-menu-open');
} }
} }
closeMenu(): void { closeMenu(): void {
this.menuOpen = false; this.menuOpen = false;
this.renderer.removeClass(this.document.body, 'dexar-menu-open'); this.renderer.removeClass(this.document.body, 'platform-menu-open');
} }
navigateHome(event?: Event): void { navigateHome(event?: Event): void {
@@ -143,3 +143,5 @@ export class HeaderComponent {
}; };
} }
} }

View File

@@ -307,8 +307,8 @@
} }
} }
// Novo theme styles // alt theme styles
.novo-theme { .alt-theme {
::ng-deep { ::ng-deep {
.p-carousel { .p-carousel {
.p-carousel-prev, .p-carousel-prev,
@@ -371,3 +371,5 @@
} }
} }
} }

View File

@@ -109,7 +109,7 @@
} }
// Light theme adjustments // Light theme adjustments
:host-context(.novo-header), :host-context(.alt-header),
:host-context(.header) { :host-context(.header) {
.language-button { .language-button {
border-color: rgba(0, 0, 0, 0.2); border-color: rgba(0, 0, 0, 0.2);
@@ -152,9 +152,9 @@
} }
} }
// Dexar header specific styles // platform header specific styles
:host-context(.dexar-header), :host-context(.platform-header),
:host-context(.dexar-mobile-menu) { :host-context(.platform-mobile-menu) {
.language-selector { .language-selector {
width: 56px; width: 56px;
height: 28px; height: 28px;
@@ -246,8 +246,8 @@
} }
} }
:host-context(.dexar-header), :host-context(.platform-header),
:host-context(.dexar-mobile-menu) { :host-context(.platform-mobile-menu) {
.language-selector { .language-selector {
width: 56px; width: 56px;
height: 28px; height: 28px;
@@ -261,9 +261,9 @@
// Inside mobile menu: use inline dropdown instead of absolute // Inside mobile menu: use inline dropdown instead of absolute
// to avoid being clipped by overflow: hidden on the menu panel // to avoid being clipped by overflow: hidden on the menu panel
:host-context(.dexar-mobile-menu), :host-context(.platform-mobile-menu),
:host-context(.dexar-mobile-lang), :host-context(.platform-mobile-lang),
:host-context(.novo-mobile-controls) { :host-context(.alt-mobile-controls) {
.language-selector { .language-selector {
width: auto; width: auto;
height: auto; height: auto;
@@ -412,8 +412,8 @@
} }
} }
// Light / Novo / Dexar theme adjustments for currency // Light / alt / platform theme adjustments for currency
:host-context(.novo-header), :host-context(.alt-header),
:host-context(.header) { :host-context(.header) {
.currency-button { .currency-button {
border-color: rgba(0, 0, 0, 0.2); border-color: rgba(0, 0, 0, 0.2);
@@ -438,8 +438,8 @@
} }
} }
:host-context(.dexar-header), :host-context(.platform-header),
:host-context(.dexar-mobile-menu) { :host-context(.platform-mobile-menu) {
.currency-button { .currency-button {
padding: 4px 8px; padding: 4px 8px;
gap: 3px; gap: 3px;
@@ -465,3 +465,5 @@
&.active { background: rgba(73, 118, 113, 0.1); } &.active { background: rgba(73, 118, 113, 0.1); }
} }
} }

View File

@@ -70,7 +70,7 @@
</div> </div>
</a> </a>
<button class="add-to-cart-btn" type="button" (click)="onAddToCart($event)" [attr.aria-label]="addToCartLabel + ': ' + (title || item.name)"> <button class="add-to-cart-btn" type="button" (click)="onAddToCart($event)" [attr.aria-label]="(addToCartLabel || ('catalog.addToCart' | translate)) + ': ' + (title || item.name)">
{{ addToCartLabel }} {{ addToCartLabel || ('catalog.addToCart' | translate) }}
</button> </button>
</article> </article>

View File

@@ -3,6 +3,7 @@ import { DecimalPipe } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { Product } from '../../core/products/models/product-domain.model'; import { Product } from '../../core/products/models/product-domain.model';
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils'; import { getBadgeClass, getDiscountedPrice, getMainImage } from '../../utils/item.utils';
export type ProductCardAppearance = 'standard' | 'compact'; export type ProductCardAppearance = 'standard' | 'compact';
@@ -10,7 +11,7 @@ export type ProductCardAppearance = 'standard' | 'compact';
@Component({ @Component({
selector: 'app-product-card', selector: 'app-product-card',
standalone: true, standalone: true,
imports: [DecimalPipe, RouterLink, LangRoutePipe], imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe],
templateUrl: './product-card.component.html', templateUrl: './product-card.component.html',
styleUrls: ['./product-card.component.scss'], styleUrls: ['./product-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
@@ -19,7 +20,7 @@ export class ProductCardComponent {
@Input({ required: true }) item!: Product; @Input({ required: true }) item!: Product;
@Input() title = ''; @Input() title = '';
@Input() description = ''; @Input() description = '';
@Input() addToCartLabel = 'Add to cart'; @Input() addToCartLabel = '';
@Input() appearance: ProductCardAppearance = 'standard'; @Input() appearance: ProductCardAppearance = 'standard';
@Input() showDescription = false; @Input() showDescription = false;
@Input() showStock = true; @Input() showStock = true;

View File

@@ -0,0 +1,27 @@
import { CatalogLayoutMode } from '../../products/models/catalog-experience.model';
import { ProductSort } from '../../products/models/product-domain.model';
import { SearchFilterState } from './search.model';
export interface SearchState {
text: string;
sort: ProductSort | 'discount';
layout: CatalogLayoutMode;
page: number;
pageSize: number;
filters: SearchFilterState;
}
export function createInitialSearchState(pageSize = 24): SearchState {
return {
text: '',
sort: 'relevance',
layout: 'grid',
page: 1,
pageSize,
filters: {
values: {},
ranges: {},
toggles: {},
},
};
}

View File

@@ -0,0 +1,76 @@
import { Product, ProductSort } from '../../products/models/product-domain.model';
export type SearchFilterType =
| 'checkbox'
| 'radio'
| 'toggle'
| 'range'
| 'slider'
| 'color'
| 'size'
| 'rating'
| 'availability';
export interface SearchQuery {
text: string;
categoryIds: number[];
subcategoryIds: string[];
filters: SearchFilterState;
sort: ProductSort | 'discount';
page: number;
pageSize: number;
}
export interface SearchResult<TItem = Product> {
query: SearchQuery;
items: TItem[];
total: number;
page: number;
pageSize: number;
summary: string;
}
export interface FilterOption {
id: string;
label: string;
value: string;
count?: number;
colorHex?: string;
availability?: 'in-stock' | 'low-stock' | 'out-of-stock';
rating?: number;
}
export interface FilterGroup {
id: string;
label: string;
type: SearchFilterType;
options: FilterOption[];
min?: number;
max?: number;
step?: number;
enabled: boolean;
}
export interface SortOption {
id: ProductSort | 'discount';
label: string;
enabled: boolean;
}
export interface SearchSuggestion {
id: string;
text: string;
kind: 'live' | 'recent' | 'popular';
}
export interface SearchHistory {
items: string[];
recent: string[];
popular: string[];
}
export interface SearchFilterState {
values: Record<string, string[]>;
ranges: Record<string, { min?: number; max?: number }>;
toggles: Record<string, boolean>;
}

View File

@@ -1,9 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { SearchHistory } from '../models/search.model';
const STORAGE_KEY = 'marketplace.catalog.search.history'; const STORAGE_KEY = 'marketplace.catalog.search.history';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CatalogSearchHistoryService { export class SearchHistoryService {
private readonly maxSize = 10; private readonly maxSize = 10;
getHistory(): string[] { getHistory(): string[] {
@@ -26,25 +27,43 @@ export class CatalogSearchHistoryService {
} }
} }
push(term: string): string[] { getSnapshot(popular: string[]): SearchHistory {
const items = this.getHistory();
return {
items,
recent: items.slice(0, 5),
popular,
};
}
push(term: string, popular: string[]): SearchHistory {
const normalized = term.trim(); const normalized = term.trim();
if (!normalized.length) { if (!normalized.length) {
return this.getHistory(); return this.getSnapshot(popular);
} }
const history = [normalized, ...this.getHistory().filter(entry => entry.toLowerCase() !== normalized.toLowerCase())] const history = [normalized, ...this.getHistory().filter(entry => entry.toLowerCase() !== normalized.toLowerCase())]
.slice(0, this.maxSize); .slice(0, this.maxSize);
this.save(history); this.save(history);
return history;
return {
items: history,
recent: history.slice(0, 5),
popular,
};
} }
clear(): void { clear(popular: string[]): SearchHistory {
if (typeof window === 'undefined') { if (typeof window !== 'undefined') {
return; localStorage.removeItem(STORAGE_KEY);
} }
localStorage.removeItem(STORAGE_KEY); return {
items: [],
recent: [],
popular,
};
} }
private save(history: string[]): void { private save(history: string[]): void {

View File

@@ -0,0 +1,425 @@
import { Injectable, inject } from '@angular/core';
import { ParamMap, Params } from '@angular/router';
import { Observable } from 'rxjs';
import { Product, ProductListResult } from '../../core/products/models/product-domain.model';
import { ProductFacade } from './product.facade';
import { TranslateService } from '../../i18n/translate.service';
import { SearchHistoryService } from '../../core/search/services/search-history.service';
import { SearchFilterState, SearchHistory, SearchQuery, SearchResult, SearchSuggestion, SortOption, FilterGroup } from '../../core/search/models/search.model';
import { SearchState } from '../../core/search/models/search-state.model';
@Injectable({ providedIn: 'root' })
export class SearchFacade {
private readonly productFacade = inject(ProductFacade);
private readonly translate = inject(TranslateService);
private readonly historyService = inject(SearchHistoryService);
private readonly metadataMemo = new Map<string, FilterGroup[]>();
readonly popularSearches: SearchSuggestion[] = [
{ id: 'popular-smartphones', text: 'Smartphones', kind: 'popular' },
{ id: 'popular-sneakers', text: 'Sneakers', kind: 'popular' },
{ id: 'popular-headphones', text: 'Headphones', kind: 'popular' },
{ id: 'popular-laptops', text: 'Laptops', kind: 'popular' },
];
getSearchHistory(): SearchHistory {
return this.historyService.getSnapshot(this.popularSearches.map(entry => entry.text));
}
pushSearchHistory(term: string): SearchHistory {
return this.historyService.push(term, this.popularSearches.map(entry => entry.text));
}
clearSearchHistory(): SearchHistory {
return this.historyService.clear(this.popularSearches.map(entry => entry.text));
}
loadCatalog(query: SearchQuery): Observable<ProductListResult> {
return this.productFacade.loadCatalog({
keyword: query.text,
categoryIDs: query.categoryIds,
subcategoryIDs: query.subcategoryIds,
minPrice: query.filters.ranges['price']?.min,
maxPrice: query.filters.ranges['price']?.max,
discountOnly: query.filters.toggles['discount'],
newOnly: query.filters.toggles['new'],
sort: query.sort,
page: 1,
pageSize: 200,
});
}
createSortOptions(availableSorts: string[]): SortOption[] {
const labels: Record<string, string> = {
relevance: this.translate.t('catalog.sortRelevance'),
latest: this.translate.t('catalog.sortLatest'),
price_asc: this.translate.t('catalog.sortPriceAsc'),
price_desc: this.translate.t('catalog.sortPriceDesc'),
rating: this.translate.t('catalog.sortRating'),
popular: this.translate.t('catalog.sortPopular'),
discount: this.translate.t('catalog.sortDiscount'),
};
return availableSorts.map(id => ({
id: id as SortOption['id'],
label: labels[id] ?? id,
enabled: true,
}));
}
buildFilterGroups(products: Product[], enabledFilters: string[]): FilterGroup[] {
const enabled = new Set(enabledFilters);
const key = `${enabledFilters.join('|')}::${products.map(item => item.itemID).join(',')}`;
const cached = this.metadataMemo.get(key);
if (cached) {
return cached;
}
const priceValues = products.map(product => product.price).filter(value => Number.isFinite(value));
const brands = this.collectUnique(products.flatMap(product => product.tags ?? []).filter(tag => !tag.startsWith('new') && !tag.startsWith('sale')));
const colors = this.collectUnique(products.flatMap(product => [product.colour, ...(product.itemDetails?.map(detail => detail.colour || detail.color) ?? [])]).filter(Boolean) as string[]);
const sizes = this.collectUnique(products.flatMap(product => [product.size, ...(product.itemDetails?.map(detail => detail.size) ?? [])]).filter(Boolean) as string[]);
const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean));
const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean));
const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)));
const groups: FilterGroup[] = [
{
id: 'price',
label: this.translate.t('catalog.filterPrice'),
type: 'range',
min: priceValues.length ? Math.min(...priceValues) : 0,
max: priceValues.length ? Math.max(...priceValues) : 0,
step: 1,
options: [],
enabled: enabled.has('price'),
},
{
id: 'availability',
label: this.translate.t('catalog.filterAvailability'),
type: 'availability',
options: [
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock', availability: 'in-stock' },
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock', availability: 'low-stock' },
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock', availability: 'out-of-stock' },
],
enabled: enabled.has('availability'),
},
{
id: 'rating',
label: this.translate.t('catalog.filterRating'),
type: 'rating',
options: [5, 4, 3, 2, 1].map(value => ({
id: `rating-${value}`,
label: this.translate.t('catalog.filterStars', { count: value }),
value: String(value),
rating: value,
})),
enabled: enabled.has('rating'),
},
{
id: 'brand',
label: this.translate.t('catalog.filterBrand'),
type: 'checkbox',
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
enabled: enabled.has('brand'),
},
{
id: 'category',
label: this.translate.t('catalog.filterCategory'),
type: 'radio',
options: categories.map(value => ({ id: `cat-${value}`, label: this.translate.t('catalog.filterCategoryValue', { value }), value })),
enabled: enabled.has('category'),
},
{
id: 'subcategory',
label: this.translate.t('catalog.filterSubcategory'),
type: 'checkbox',
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
enabled: enabled.has('subcategory'),
},
{
id: 'discount',
label: this.translate.t('catalog.filterDiscount'),
type: 'toggle',
options: [],
enabled: enabled.has('discount'),
},
{
id: 'new',
label: this.translate.t('catalog.filterNew'),
type: 'toggle',
options: [],
enabled: enabled.has('new'),
},
{
id: 'color',
label: this.translate.t('catalog.filterColor'),
type: 'color',
options: colors.map(value => ({ id: `color-${value}`, label: value, value, colorHex: this.toColorHex(value) })),
enabled: enabled.has('color'),
},
{
id: 'size',
label: this.translate.t('catalog.filterSize'),
type: 'size',
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
enabled: enabled.has('size'),
},
{
id: 'attributes',
label: this.translate.t('catalog.filterAttributes'),
type: 'checkbox',
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
enabled: enabled.has('attributes'),
},
];
this.metadataMemo.set(key, groups);
if (this.metadataMemo.size > 15) {
const firstKey = this.metadataMemo.keys().next().value;
if (firstKey) {
this.metadataMemo.delete(firstKey);
}
}
return groups;
}
buildLiveSuggestions(query: string, products: Product[], limit = 6): SearchSuggestion[] {
const normalized = query.trim().toLowerCase();
if (normalized.length < 2) {
return [];
}
return this.collectUnique(
products
.map(product => product.name)
.filter(name => name.toLowerCase().includes(normalized))
.slice(0, limit)
).map((text, index) => ({ id: `live-${index}`, text, kind: 'live' }));
}
applyFilters(products: Product[], filterState: SearchFilterState, searchText: string): Product[] {
const query = searchText.trim().toLowerCase();
return products.filter(product => {
if (query.length > 0) {
const haystack = `${product.name} ${product.simpleDescription ?? ''}`.toLowerCase();
if (!haystack.includes(query)) {
return false;
}
}
const priceRange = filterState.ranges['price'];
if (priceRange?.min != null && product.price < priceRange.min) {
return false;
}
if (priceRange?.max != null && product.price > priceRange.max) {
return false;
}
if (filterState.toggles['discount'] && !(product.discount > 0)) {
return false;
}
if (filterState.toggles['new'] && !(product.badges ?? []).some(badge => badge.toLowerCase().includes('new'))) {
return false;
}
const selectedRatings = filterState.values['rating'] ?? [];
if (selectedRatings.length > 0 && !selectedRatings.includes(String(Math.floor(product.rating || 0)))) {
return false;
}
const selectedAvailability = filterState.values['availability'] ?? [];
if (selectedAvailability.length > 0) {
const availability = product.remainings === 'out'
? 'out-of-stock'
: product.remainings === 'low'
? 'low-stock'
: 'in-stock';
if (!selectedAvailability.includes(availability)) {
return false;
}
}
const selectedBrands = filterState.values['brand'] ?? [];
if (selectedBrands.length > 0) {
const tags = product.tags ?? [];
if (!selectedBrands.some(brand => tags.includes(brand))) {
return false;
}
}
const selectedCategories = filterState.values['category'] ?? [];
if (selectedCategories.length > 0 && !selectedCategories.includes(String(product.categoryID))) {
return false;
}
const selectedSubcategories = filterState.values['subcategory'] ?? [];
if (selectedSubcategories.length > 0 && !selectedSubcategories.includes(product.subcategoryId ?? '')) {
return false;
}
const selectedColors = filterState.values['color'] ?? [];
if (selectedColors.length > 0) {
const colors = this.collectUnique([product.colour ?? '', ...(product.itemDetails?.map(detail => detail.colour || detail.color || '') ?? [])]);
if (!selectedColors.some(color => colors.includes(color))) {
return false;
}
}
const selectedSizes = filterState.values['size'] ?? [];
if (selectedSizes.length > 0) {
const sizes = this.collectUnique([product.size ?? '', ...(product.itemDetails?.map(detail => detail.size || '') ?? [])]);
if (!selectedSizes.some(size => sizes.includes(size))) {
return false;
}
}
const selectedAttributes = filterState.values['attributes'] ?? [];
if (selectedAttributes.length > 0) {
const keys = (product.descriptionFields ?? []).map(field => field.key);
if (!selectedAttributes.some(attribute => keys.includes(attribute))) {
return false;
}
}
return true;
});
}
applySort(products: Product[], sort: SearchState['sort']): Product[] {
const sorted = [...products];
switch (sort) {
case 'price_asc':
return sorted.sort((a, b) => a.price - b.price);
case 'price_desc':
return sorted.sort((a, b) => b.price - a.price);
case 'rating':
return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0));
case 'popular':
return sorted.sort((a, b) => (b.callbacks?.length ?? 0) - (a.callbacks?.length ?? 0));
case 'discount':
return sorted.sort((a, b) => (b.discount || 0) - (a.discount || 0));
case 'latest':
case 'relevance':
default:
return sorted;
}
}
paginateProducts(products: Product[], page: number, pageSize: number): { items: Product[]; skip: number; page: number; pageSize: number } {
const safePage = Math.max(1, page);
const safePageSize = Math.max(1, pageSize);
const skip = (safePage - 1) * safePageSize;
return {
items: products.slice(skip, skip + safePageSize),
skip,
page: safePage,
pageSize: safePageSize,
};
}
buildResult(state: SearchState, total: number, items: Product[]): SearchResult<Product> {
return {
query: {
text: state.text,
categoryIds: [],
subcategoryIds: [],
filters: state.filters,
sort: state.sort,
page: state.page,
pageSize: state.pageSize,
},
items,
total,
page: state.page,
pageSize: state.pageSize,
summary: state.text.trim().length > 0
? this.translate.t('catalog.searchResultsSummary', { query: state.text.trim(), total })
: this.translate.t('catalog.searchProductsFound', { total }),
};
}
toQueryParams(state: SearchState): Params {
return {
q: state.text || null,
sort: state.sort || null,
page: state.page > 1 ? state.page : null,
layout: state.layout !== 'grid' ? state.layout : null,
fv: this.serializeObject(state.filters.values),
fr: this.serializeObject(state.filters.ranges),
ft: this.serializeObject(state.filters.toggles),
};
}
fromQueryParams(paramMap: ParamMap): Partial<SearchState> {
const values = this.deserializeObject<Record<string, string[]>>(paramMap.get('fv')) ?? {};
const ranges = this.deserializeObject<Record<string, { min?: number; max?: number }>>(paramMap.get('fr')) ?? {};
const toggles = this.deserializeObject<Record<string, boolean>>(paramMap.get('ft')) ?? {};
const page = Number(paramMap.get('page'));
return {
text: (paramMap.get('q') ?? '').trim(),
sort: (paramMap.get('sort') as SearchState['sort']) ?? 'relevance',
layout: (paramMap.get('layout') as SearchState['layout']) ?? 'grid',
page: Number.isFinite(page) && page > 0 ? page : 1,
filters: {
values,
ranges,
toggles,
},
};
}
private serializeObject(value: unknown): string | null {
if (!value || typeof value !== 'object' || Object.keys(value as object).length === 0) {
return null;
}
return encodeURIComponent(JSON.stringify(value));
}
private deserializeObject<T>(value: string | null): T | null {
if (!value) {
return null;
}
try {
return JSON.parse(decodeURIComponent(value)) as T;
} catch {
return null;
}
}
private collectUnique(values: string[]): string[] {
return [...new Set(values.map(value => value.trim()).filter(Boolean))];
}
private toColorHex(value: string): string {
const normalized = value.toLowerCase();
const palette: Record<string, string> = {
black: '#111111',
white: '#ffffff',
red: '#ef4444',
blue: '#3b82f6',
green: '#22c55e',
yellow: '#eab308',
orange: '#f97316',
purple: '#8b5cf6',
pink: '#ec4899',
gray: '#6b7280',
grey: '#6b7280',
brown: '#92400e',
beige: '#d6c6a8',
};
return palette[normalized] ?? '#94a3b8';
}
}

View File

@@ -4,18 +4,21 @@
</div> </div>
@for (filter of definitions; track filter.id) { @for (filter of definitions; track filter.id) {
@if (filter.enabled !== false) { @if (filter.enabled) {
<section class="filter-group"> <section class="filter-group">
<h4>{{ filter.label }}</h4> <h4>{{ filter.label }}</h4>
@if (filter.type === 'multi-select') { @if (filter.type === 'checkbox' || filter.type === 'availability') {
<div class="options"> <div class="options">
@for (option of filter.options ?? []; track option.id) { @for (option of filter.options; track option.id) {
<label class="option-check"> <label class="option-check">
<input <input
type="checkbox" type="checkbox"
[checked]="isSelected(filter.id, option.value)" [checked]="isSelected(filter.id, option.value)"
(change)="toggleOption(filter.id, option.value)" /> (change)="toggleOption(filter.id, option.value)" />
@if (filter.type === 'availability') {
<span class="availability-dot" [attr.data-availability]="option.availability ?? option.value" aria-hidden="true"></span>
}
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
@if (option.count != null) { @if (option.count != null) {
<small>{{ option.count }}</small> <small>{{ option.count }}</small>
@@ -25,6 +28,21 @@
</div> </div>
} }
@if (filter.type === 'radio') {
<div class="options">
@for (option of filter.options; track option.id) {
<label class="option-check">
<input
type="radio"
[name]="'radio-' + filter.id"
[checked]="isSelected(filter.id, option.value)"
(change)="selectSingleOption(filter.id, option.value)" />
<span>{{ option.label }}</span>
</label>
}
</div>
}
@if (filter.type === 'range') { @if (filter.type === 'range') {
<div class="range-inputs"> <div class="range-inputs">
<input <input
@@ -40,6 +58,60 @@
</div> </div>
} }
@if (filter.type === 'slider') {
<div class="slider-inputs">
<input
type="range"
[min]="filter.min ?? 0"
[max]="filter.max ?? 1000"
[step]="filter.step ?? 1"
[value]="state.ranges[filter.id]?.max ?? filter.max ?? 1000"
(input)="updateSlider(filter.id, $any($event.target).value)" />
<span>{{ state.ranges[filter.id]?.max ?? filter.max ?? 0 }}</span>
</div>
}
@if (filter.type === 'color') {
<div class="color-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="color-swatch"
[class.active]="isSelected(filter.id, option.value)"
[style.background]="option.colorHex ?? '#94a3b8'"
[attr.aria-label]="option.label"
(click)="toggleOption(filter.id, option.value)"></button>
}
</div>
}
@if (filter.type === 'size') {
<div class="size-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="size-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">{{ option.label }}</button>
}
</div>
}
@if (filter.type === 'rating') {
<div class="rating-options">
@for (option of filter.options; track option.id) {
<button
type="button"
class="rating-chip"
[class.active]="isSelected(filter.id, option.value)"
(click)="toggleOption(filter.id, option.value)">
<span aria-hidden="true"></span>
<span>{{ option.label }}</span>
</button>
}
</div>
}
@if (filter.type === 'toggle') { @if (filter.type === 'toggle') {
<label class="option-check"> <label class="option-check">
<input <input

View File

@@ -37,13 +37,32 @@
.option-check { .option-check {
display: grid; display: grid;
grid-template-columns: auto 1fr auto; grid-template-columns: auto auto 1fr auto;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--text-secondary); color: var(--text-secondary);
font-size: 0.9rem; font-size: 0.9rem;
} }
.availability-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #94a3b8;
}
.availability-dot[data-availability='in-stock'] {
background: #22c55e;
}
.availability-dot[data-availability='low-stock'] {
background: #f59e0b;
}
.availability-dot[data-availability='out-of-stock'] {
background: #ef4444;
}
.range-inputs { .range-inputs {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -57,6 +76,59 @@
padding: 0 8px; padding: 0 8px;
} }
.slider-inputs {
display: grid;
gap: 8px;
}
.slider-inputs input {
width: 100%;
}
.slider-inputs span {
color: var(--text-secondary);
font-size: 0.85rem;
}
.color-options,
.size-options,
.rating-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.color-swatch {
width: 24px;
height: 24px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
}
.color-swatch.active {
border-color: var(--text-primary);
}
.size-chip,
.rating-chip {
min-height: 30px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
padding: 0 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.size-chip.active,
.rating-chip.active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, white);
}
@media (max-width: 1024px) { @media (max-width: 1024px) {
.catalog-filters { .catalog-filters {
gap: 12px; gap: 12px;
@@ -65,4 +137,8 @@
.range-inputs { .range-inputs {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.option-check {
grid-template-columns: auto auto 1fr;
}
} }

View File

@@ -1,14 +1,8 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FilterDefinition } from '../../../../../core/products/models/catalog-experience.model'; import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export interface CatalogFilterStateValue {
values: Record<string, string[]>;
ranges: Record<string, { min?: number; max?: number }>;
toggles: Record<string, boolean>;
}
@Component({ @Component({
selector: 'app-catalog-filters-panel', selector: 'app-catalog-filters-panel',
standalone: true, standalone: true,
@@ -18,10 +12,10 @@ export interface CatalogFilterStateValue {
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CatalogFiltersPanelComponent { export class CatalogFiltersPanelComponent {
@Input() definitions: FilterDefinition[] = []; @Input() definitions: FilterGroup[] = [];
@Input() state: CatalogFilterStateValue = { values: {}, ranges: {}, toggles: {} }; @Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<CatalogFilterStateValue>(); @Output() stateChange = new EventEmitter<SearchFilterState>();
isSelected(filterId: string, optionValue: string): boolean { isSelected(filterId: string, optionValue: string): boolean {
return (this.state.values[filterId] ?? []).includes(optionValue); return (this.state.values[filterId] ?? []).includes(optionValue);
@@ -42,6 +36,16 @@ export class CatalogFiltersPanelComponent {
}); });
} }
selectSingleOption(filterId: string, optionValue: string): void {
this.stateChange.emit({
...this.state,
values: {
...this.state.values,
[filterId]: [optionValue],
},
});
}
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void { updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined; const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {}; const current = this.state.ranges[filterId] ?? {};
@@ -58,6 +62,20 @@ export class CatalogFiltersPanelComponent {
}); });
} }
updateSlider(filterId: string, value: string): void {
const numeric = Number(value);
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
},
},
});
}
updateToggle(filterId: string, checked: boolean): void { updateToggle(filterId: string, checked: boolean): void {
this.stateChange.emit({ this.stateChange.emit({
...this.state, ...this.state,

View File

@@ -3,10 +3,12 @@
<input <input
type="search" type="search"
[ngModel]="query" [ngModel]="query"
(ngModelChange)="queryChange.emit($event)" (ngModelChange)="onQueryInput($event)"
(keydown)="onKeydown($event)"
name="query" name="query"
[placeholder]="'catalog.searchPlaceholder' | translate" [placeholder]="'catalog.searchPlaceholder' | translate"
autocomplete="off" /> autocomplete="off" />
<button type="button" class="search-clear-btn" [disabled]="!query" (click)="clearQuery()" [attr.aria-label]="'catalog.clearSearch' | translate">×</button>
<button type="submit" [disabled]="loading">{{ 'catalog.searchSubmit' | translate }}</button> <button type="submit" [disabled]="loading">{{ 'catalog.searchSubmit' | translate }}</button>
</form> </form>
@@ -14,8 +16,19 @@
<div class="suggestions"> <div class="suggestions">
<strong>{{ 'catalog.suggestionsTitle' | translate }}</strong> <strong>{{ 'catalog.suggestionsTitle' | translate }}</strong>
<div class="chip-list"> <div class="chip-list">
@for (item of suggestions; track $index) { @for (item of suggestions; track item.id) {
<button type="button" class="chip" (click)="selectSuggestion(item)">{{ item }}</button> <button type="button" class="chip" [class.active]="$index === activeSuggestionIndex" (click)="selectSuggestion(item.text)">{{ item.text }}</button>
}
</div>
</div>
}
@if (popularSearches.length > 0) {
<div class="popular">
<strong>{{ 'catalog.popularSearchesTitle' | translate }}</strong>
<div class="chip-list">
@for (item of popularSearches; track $index) {
<button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
} }
</div> </div>
</div> </div>
@@ -26,7 +39,7 @@
<strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong> <strong>{{ 'catalog.recentSearchesTitle' | translate }}</strong>
<div class="chip-list"> <div class="chip-list">
@for (item of recentSearches; track $index) { @for (item of recentSearches; track $index) {
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button> <button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
} }
</div> </div>
</div> </div>
@@ -40,7 +53,7 @@
</div> </div>
<div class="chip-list"> <div class="chip-list">
@for (item of searchHistory; track $index) { @for (item of searchHistory; track $index) {
<button type="button" class="chip" (click)="recentSelected.emit(item)">{{ item }}</button> <button type="button" class="chip" (click)="onRecentSelected(item)">{{ item }}</button>
} }
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@
.search-form { .search-form {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto auto;
gap: 8px; gap: 8px;
} }
@@ -37,6 +37,17 @@
transition: transform 0.2s ease, filter 0.2s ease; transition: transform 0.2s ease, filter 0.2s ease;
} }
.search-clear-btn {
min-width: 44px;
min-height: 44px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-secondary);
font-size: 1.2rem;
line-height: 1;
}
.search-form button:hover:not(:disabled) { .search-form button:hover:not(:disabled) {
transform: translateY(-1px); transform: translateY(-1px);
filter: brightness(0.96); filter: brightness(0.96);
@@ -65,6 +76,12 @@
background: color-mix(in srgb, var(--primary-color) 8%, white); background: color-mix(in srgb, var(--primary-color) 8%, white);
} }
.chip.active {
border-color: var(--primary-color);
color: var(--text-primary);
background: color-mix(in srgb, var(--primary-color) 10%, white);
}
.history-head { .history-head {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -87,10 +104,11 @@
@media (max-width: 680px) { @media (max-width: 680px) {
.search-form { .search-form {
grid-template-columns: 1fr; grid-template-columns: 1fr auto;
} }
.search-form button { .search-form button[type='submit'] {
grid-column: 1 / -1;
width: 100%; width: 100%;
} }
} }

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SearchSuggestion } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
@@ -13,8 +14,9 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
export class CatalogSearchBoxComponent { export class CatalogSearchBoxComponent {
@Input() query = ''; @Input() query = '';
@Input() loading = false; @Input() loading = false;
@Input() suggestions: string[] = []; @Input() suggestions: SearchSuggestion[] = [];
@Input() recentSearches: string[] = []; @Input() recentSearches: string[] = [];
@Input() popularSearches: string[] = [];
@Input() searchHistory: string[] = []; @Input() searchHistory: string[] = [];
@Input() noResults = false; @Input() noResults = false;
@@ -24,12 +26,60 @@ export class CatalogSearchBoxComponent {
@Output() recentSelected = new EventEmitter<string>(); @Output() recentSelected = new EventEmitter<string>();
@Output() historyCleared = new EventEmitter<void>(); @Output() historyCleared = new EventEmitter<void>();
activeSuggestionIndex = -1;
onSubmit(event: Event): void { onSubmit(event: Event): void {
event.preventDefault(); event.preventDefault();
this.searchSubmit.emit(this.query.trim()); this.searchSubmit.emit(this.query.trim());
} }
onQueryInput(value: string): void {
this.queryChange.emit(value);
this.activeSuggestionIndex = -1;
}
selectSuggestion(value: string): void { selectSuggestion(value: string): void {
this.suggestionSelected.emit(value); this.suggestionSelected.emit(value);
this.activeSuggestionIndex = -1;
}
clearQuery(): void {
this.queryChange.emit('');
this.searchSubmit.emit('');
this.activeSuggestionIndex = -1;
}
onKeydown(event: KeyboardEvent): void {
if (this.suggestions.length === 0) {
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
this.activeSuggestionIndex = Math.min(this.suggestions.length - 1, this.activeSuggestionIndex + 1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
this.activeSuggestionIndex = Math.max(-1, this.activeSuggestionIndex - 1);
return;
}
if (event.key === 'Enter' && this.activeSuggestionIndex >= 0) {
event.preventDefault();
const active = this.suggestions[this.activeSuggestionIndex];
this.selectSuggestion(active.text);
return;
}
if (event.key === 'Escape') {
this.activeSuggestionIndex = -1;
}
}
onRecentSelected(value: string): void {
this.recentSelected.emit(value);
this.activeSuggestionIndex = -1;
} }
} }

View File

@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { SortDefinition } from '../../../../../core/products/models/catalog-experience.model'; import { SortOption } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
@Component({ @Component({
@@ -12,7 +12,7 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CatalogSortingControlComponent { export class CatalogSortingControlComponent {
@Input() options: SortDefinition[] = []; @Input() options: SortOption[] = [];
@Input() selected = 'relevance'; @Input() selected = 'relevance';
@Output() selectedChange = new EventEmitter<string>(); @Output() selectedChange = new EventEmitter<string>();

View File

@@ -17,6 +17,7 @@
[loading]="loadingProducts()" [loading]="loadingProducts()"
[suggestions]="searchSuggestions()" [suggestions]="searchSuggestions()"
[recentSearches]="recentSearches()" [recentSearches]="recentSearches()"
[popularSearches]="popularSearches()"
[searchHistory]="searchHistory()" [searchHistory]="searchHistory()"
[noResults]="noResults()" [noResults]="noResults()"
(queryChange)="onSearchQueryChange($event)" (queryChange)="onSearchQueryChange($event)"

View File

@@ -5,11 +5,11 @@ import { A11yModule } from '@angular/cdk/a11y';
import { combineLatest } from 'rxjs'; import { combineLatest } from 'rxjs';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { Category } from '../../../../core/categories/models/category-domain.model'; import { Category } from '../../../../core/categories/models/category-domain.model';
import { CatalogLayoutMode, FilterDefinition, SearchCriteria, SearchResult, SortDefinition, toSearchResult } from '../../../../core/products/models/catalog-experience.model'; import { CatalogLayoutMode } from '../../../../core/products/models/catalog-experience.model';
import { Product } from '../../../../core/products/models/product-domain.model'; import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service'; import { ConfigService } from '../../../../core/config/config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade'; import { SearchFacade } from '../../../../facades/platform/search.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
@@ -18,15 +18,16 @@ import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../i18n/translate.service'; import { TranslateService } from '../../../../i18n/translate.service';
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config'; import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
import { FilterGroup, SearchFilterState, SearchQuery, SearchResult, SearchSuggestion, SortOption } from '../../../../core/search/models/search.model';
import { SearchState } from '../../../../core/search/models/search-state.model';
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component'; import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogEmptyStateComponent } from '../components/catalog-empty-state/catalog-empty-state.component'; import { CatalogEmptyStateComponent } from '../components/catalog-empty-state/catalog-empty-state.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component'; import { CatalogFiltersPanelComponent } from '../components/filters-panel/filters-panel.component';
import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component'; import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
import { CatalogSearchBoxComponent } from '../components/search-box/search-box.component'; import { CatalogSearchBoxComponent } from '../components/search-box/search-box.component';
import { CatalogSearchResultsComponent } from '../components/search-results/search-results.component'; import { CatalogSearchResultsComponent } from '../components/search-results/search-results.component';
import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component'; import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { CatalogSearchHistoryService } from '../services/catalog-search-history.service';
import { ProductShareService } from '../../user-experience/services/product-share.service'; import { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service'; import { UserNotificationService } from '../../user-experience/services/user-notification.service';
@@ -58,11 +59,10 @@ export class CatalogContainerComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly categoryFacade = inject(CategoryFacade); private readonly categoryFacade = inject(CategoryFacade);
private readonly productFacade = inject(ProductFacade); private readonly searchFacade = inject(SearchFacade);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly prefetchService = inject(PrefetchService); private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly searchHistoryService = inject(CatalogSearchHistoryService);
private readonly uxFacade = inject(UserExperienceFacade); private readonly uxFacade = inject(UserExperienceFacade);
private readonly shareService = inject(ProductShareService); private readonly shareService = inject(ProductShareService);
private readonly notifications = inject(UserNotificationService); private readonly notifications = inject(UserNotificationService);
@@ -78,12 +78,13 @@ export class CatalogContainerComponent {
readonly subcategoryChips = signal<Category[]>([]); readonly subcategoryChips = signal<Category[]>([]);
readonly breadcrumb = signal<Category[]>([]); readonly breadcrumb = signal<Category[]>([]);
readonly searchSummary = signal(''); readonly searchSummary = signal('');
readonly searchResult = signal<SearchResult | null>(null); readonly searchResult = signal<SearchResult<Product> | null>(null);
readonly filterDefinitions = signal<FilterDefinition[]>([]); readonly filterDefinitions = signal<FilterGroup[]>([]);
readonly filterState = signal<CatalogFilterStateValue>({ values: {}, ranges: {}, toggles: {} }); readonly filterState = signal<SearchFilterState>({ values: {}, ranges: {}, toggles: {} });
readonly searchSuggestions = signal<string[]>([]); readonly searchSuggestions = signal<SearchSuggestion[]>([]);
readonly recentSearches = signal<string[]>([]); readonly recentSearches = signal<string[]>([]);
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []); readonly popularSearches = signal<string[]>([]);
readonly searchHistory = signal<string[]>([]);
readonly savedSearches = this.uxFacade.savedSearches; readonly savedSearches = this.uxFacade.savedSearches;
readonly viewMode = signal<CatalogViewMode>('categories'); readonly viewMode = signal<CatalogViewMode>('categories');
readonly loading = signal(true); readonly loading = signal(true);
@@ -109,19 +110,7 @@ export class CatalogContainerComponent {
readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID)); readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID));
readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID)); readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID));
readonly sortDefinitions = computed<SortDefinition[]>(() => { readonly sortDefinitions = computed<SortOption[]>(() => this.searchFacade.createSortOptions(this.catalogConfig().availableSorts));
const labels: Record<string, string> = {
relevance: this.translate.t('catalog.sortRelevance'),
latest: this.translate.t('catalog.sortLatest'),
price_asc: this.translate.t('catalog.sortPriceAsc'),
price_desc: this.translate.t('catalog.sortPriceDesc'),
rating: this.translate.t('catalog.sortRating'),
popular: this.translate.t('catalog.sortPopular'),
discount: this.translate.t('catalog.sortDiscount')
};
return this.catalogConfig().availableSorts.map((id: string) => ({ id, label: labels[id] ?? id, enabled: true }));
});
readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list']; readonly availableLayouts: CatalogLayoutMode[] = ['grid', 'large-grid', 'compact-grid', 'list'];
@@ -130,30 +119,39 @@ export class CatalogContainerComponent {
private dataSubscription?: Subscription; private dataSubscription?: Subscription;
private readonly backendFetchSize = 200; private readonly backendFetchSize = 200;
private pendingScrollY: number | null = null; private pendingScrollY: number | null = null;
private syncingUrl = false;
constructor() { constructor() {
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
this.recentSearches.set(this.searchHistory().slice(0, 5)); if (this.catalogConfig().searchHistoryEnabled) {
const snapshot = this.searchFacade.getSearchHistory();
this.searchHistory.set(snapshot.items);
this.recentSearches.set(snapshot.recent);
this.popularSearches.set(snapshot.popular);
} else {
this.popularSearches.set(this.searchFacade.popularSearches.map(item => item.text));
}
combineLatest([this.route.paramMap, this.route.queryParamMap]) combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(([params, queryParams]) => { .subscribe(([params, queryParams]) => {
const categoryId = Number(params.get('id')) || null; if (this.syncingUrl) {
const searchQuery = (queryParams.get('q') ?? '').trim();
if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
return; return;
} }
this.enterCategory(categoryId); const categoryId = Number(params.get('id')) || null;
const queryPatch = this.searchFacade.fromQueryParams(queryParams);
const hasQueryState = this.hasSearchState(queryPatch);
if (searchQuery.length > 0) { if (categoryId == null && !hasQueryState && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
this.submitSearch(searchQuery); return;
} }
this.enterCategory(categoryId, queryPatch, hasQueryState);
}); });
} }
enterCategory(categoryId: number | null): void { enterCategory(categoryId: number | null, queryPatch?: Partial<SearchState>, forceProductsMode = false): void {
this.dataSubscription?.unsubscribe(); this.dataSubscription?.unsubscribe();
this.loading.set(true); this.loading.set(true);
this.loadingProducts.set(false); this.loadingProducts.set(false);
@@ -179,7 +177,9 @@ export class CatalogContainerComponent {
}, },
}); });
if (categoryId == null) { this.applySearchStatePatch(queryPatch);
if (categoryId == null && !forceProductsMode) {
this.dataSubscription = this.categoryFacade.getRootCategories() this.dataSubscription = this.categoryFacade.getRootCategories()
.subscribe({ .subscribe({
next: categories => this.renderCategories(null, categories, []), next: categories => this.renderCategories(null, categories, []),
@@ -188,10 +188,18 @@ export class CatalogContainerComponent {
return; return;
} }
if (categoryId == null && forceProductsMode) {
this.viewMode.set('products');
this.loadCatalog();
return;
}
const selectedCategoryId = categoryId as number;
this.dataSubscription = combineLatest([ this.dataSubscription = combineLatest([
this.categoryFacade.getCategoryById(categoryId), this.categoryFacade.getCategoryById(selectedCategoryId),
this.categoryFacade.getChildren(categoryId), this.categoryFacade.getChildren(selectedCategoryId),
this.categoryFacade.getBreadcrumb(categoryId), this.categoryFacade.getBreadcrumb(selectedCategoryId),
]).subscribe({ ]).subscribe({
next: ([category, children, breadcrumb]) => { next: ([category, children, breadcrumb]) => {
this.breadcrumb.set(breadcrumb); this.breadcrumb.set(breadcrumb);
@@ -203,7 +211,7 @@ export class CatalogContainerComponent {
return; return;
} }
this.loadProducts(categoryId); this.loadProducts(selectedCategoryId);
}, },
error: () => this.setError('catalog.error'), error: () => this.setError('catalog.error'),
}); });
@@ -235,18 +243,12 @@ export class CatalogContainerComponent {
this.state.update(current => ({ ...current, search: query })); this.state.update(current => ({ ...current, search: query }));
this.persistContinueBrowsing(); this.persistContinueBrowsing();
const normalized = query.trim().toLowerCase(); if (!this.catalogConfig().suggestionsEnabled) {
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
this.searchSuggestions.set([]); this.searchSuggestions.set([]);
return; return;
} }
const suggestions = this.rawProducts() this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(query, this.rawProducts()));
.map(product => product.name)
.filter(name => name.toLowerCase().includes(normalized))
.slice(0, 6);
this.searchSuggestions.set([...new Set(suggestions)]);
} }
submitSearch(query: string): void { submitSearch(query: string): void {
@@ -263,10 +265,13 @@ export class CatalogContainerComponent {
})); }));
if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) { if (this.catalogConfig().searchHistoryEnabled && normalized.length > 0) {
this.searchHistory.set(this.searchHistoryService.push(normalized)); const snapshot = this.searchFacade.pushSearchHistory(normalized);
this.recentSearches.set(this.searchHistory().slice(0, 5)); this.searchHistory.set(snapshot.items);
this.recentSearches.set(snapshot.recent);
this.popularSearches.set(snapshot.popular);
} }
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
this.loadCatalog(); this.loadCatalog();
} }
@@ -282,9 +287,10 @@ export class CatalogContainerComponent {
} }
clearSearchHistory(): void { clearSearchHistory(): void {
this.searchHistoryService.clear(); const snapshot = this.searchFacade.clearSearchHistory();
this.searchHistory.set([]); this.searchHistory.set(snapshot.items);
this.recentSearches.set([]); this.recentSearches.set(snapshot.recent);
this.popularSearches.set(snapshot.popular);
} }
onFavoriteToggled(product: Product): void { onFavoriteToggled(product: Product): void {
@@ -375,9 +381,14 @@ export class CatalogContainerComponent {
...current.pagination, ...current.pagination,
page: 1, page: 1,
skip: 0 skip: 0
},
filters: {
...current.filters,
categoryIds: target.categoryId == null ? [] : [target.categoryId],
} }
})); }));
this.filterState.set(target.filters); this.filterState.set(target.filters);
this.syncUrlFromState();
this.loadCatalog(target.categoryId); this.loadCatalog(target.categoryId);
} }
@@ -406,7 +417,7 @@ export class CatalogContainerComponent {
} }
} }
onFilterStateChange(next: CatalogFilterStateValue): void { onFilterStateChange(next: SearchFilterState): void {
this.filterState.set(next); this.filterState.set(next);
this.state.update(current => ({ this.state.update(current => ({
...current, ...current,
@@ -418,12 +429,14 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
} }
resetFilters(): void { resetFilters(): void {
this.filterState.set({ values: {}, ranges: {}, toggles: {} }); this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.recomputeResults(); this.recomputeResults();
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
} }
@@ -439,11 +452,13 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
} }
changeLayout(layout: CatalogLayoutMode): void { changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout })); this.state.update(current => ({ ...current, layout }));
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
} }
@@ -495,6 +510,7 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.syncUrlFromState();
this.persistContinueBrowsing(); this.persistContinueBrowsing();
} }
@@ -515,14 +531,22 @@ export class CatalogContainerComponent {
private loadCatalog(categoryId?: number): void { private loadCatalog(categoryId?: number): void {
this.loadingProducts.set(true); this.loadingProducts.set(true);
const categoryID = categoryId ?? this.state().category?.id ?? this.state().filters.categoryIds[0] ?? null; const categoryID = categoryId ?? this.state().category?.id ?? this.state().filters.categoryIds[0];
const criteria = this.buildCriteria(categoryID); const query: SearchQuery = {
text: this.state().search,
categoryIds: categoryID == null ? [] : [categoryID],
subcategoryIds: [],
filters: this.filterState(),
sort: this.state().sort,
page: 1,
pageSize: this.backendFetchSize,
};
this.dataSubscription = this.productFacade.loadCatalog(criteria) this.dataSubscription = this.searchFacade.loadCatalog(query)
.subscribe({ .subscribe({
next: result => { next: result => {
this.rawProducts.set(result.items); this.rawProducts.set(result.items);
this.buildFilterDefinitions(result.items); this.filterDefinitions.set(this.searchFacade.buildFilterGroups(result.items, this.catalogConfig().enabledFilters));
this.recomputeResults(); this.recomputeResults();
this.loading.set(false); this.loading.set(false);
this.loadingProducts.set(false); this.loadingProducts.set(false);
@@ -531,119 +555,6 @@ export class CatalogContainerComponent {
}); });
} }
private buildCriteria(categoryId: number | null): SearchCriteria {
const state = this.state();
return {
keyword: state.search,
categoryIDs: categoryId == null ? [] : [categoryId],
sort: state.sort,
page: 1,
pageSize: this.backendFetchSize,
minPrice: this.filterState().ranges['price']?.min,
maxPrice: this.filterState().ranges['price']?.max,
discountOnly: this.filterState().toggles['discount'],
newOnly: this.filterState().toggles['new']
};
}
private buildFilterDefinitions(products: Product[]): void {
const enabled = new Set(this.catalogConfig().enabledFilters);
const priceValues = products.map(product => product.price).filter(price => Number.isFinite(price));
const brands = this.collectUnique(products.flatMap(product => product.tags ?? []).filter(tag => !tag.startsWith('new') && !tag.startsWith('sale')));
const colors = this.collectUnique(products.flatMap(product => [product.colour, ...(product.itemDetails?.map(detail => detail.colour || detail.color) ?? [])]).filter(Boolean) as string[]);
const sizes = this.collectUnique(products.flatMap(product => [product.size, ...(product.itemDetails?.map(detail => detail.size) ?? [])]).filter(Boolean) as string[]);
const categories = this.collectUnique(products.map(product => String(product.categoryID)).filter(Boolean));
const subcategories = this.collectUnique(products.map(product => product.subcategoryId ?? '').filter(Boolean));
const attributes = this.collectUnique(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)));
const ratings = [5, 4, 3, 2, 1];
const definitions: FilterDefinition[] = [
{
id: 'price',
label: this.translate.t('catalog.filterPrice'),
type: 'range',
min: priceValues.length ? Math.min(...priceValues) : 0,
max: priceValues.length ? Math.max(...priceValues) : 0,
enabled: enabled.has('price')
},
{
id: 'availability',
label: this.translate.t('catalog.filterAvailability'),
type: 'multi-select',
options: [
{ id: 'in-stock', label: this.translate.t('catalog.filterInStock'), value: 'in-stock' },
{ id: 'low-stock', label: this.translate.t('catalog.filterLowStock'), value: 'low-stock' },
{ id: 'out-of-stock', label: this.translate.t('catalog.filterOutOfStock'), value: 'out-of-stock' }
],
enabled: enabled.has('availability')
},
{
id: 'rating',
label: this.translate.t('catalog.filterRating'),
type: 'multi-select',
options: ratings.map(value => ({ id: `rating-${value}`, label: this.translate.t('catalog.filterStars', { count: value }), value: String(value) })),
enabled: enabled.has('rating')
},
{
id: 'brand',
label: this.translate.t('catalog.filterBrand'),
type: 'multi-select',
options: brands.map(value => ({ id: `brand-${value}`, label: value, value })),
enabled: enabled.has('brand')
},
{
id: 'category',
label: this.translate.t('catalog.filterCategory'),
type: 'multi-select',
options: categories.map(value => ({ id: `cat-${value}`, label: this.translate.t('catalog.filterCategoryValue', { value }), value })),
enabled: enabled.has('category')
},
{
id: 'subcategory',
label: this.translate.t('catalog.filterSubcategory'),
type: 'multi-select',
options: subcategories.map(value => ({ id: `sub-${value}`, label: value, value })),
enabled: enabled.has('subcategory')
},
{
id: 'discount',
label: this.translate.t('catalog.filterDiscount'),
type: 'toggle',
enabled: enabled.has('discount')
},
{
id: 'new',
label: this.translate.t('catalog.filterNew'),
type: 'toggle',
enabled: enabled.has('new')
},
{
id: 'color',
label: this.translate.t('catalog.filterColor'),
type: 'multi-select',
options: colors.map(value => ({ id: `color-${value}`, label: value, value })),
enabled: enabled.has('color')
},
{
id: 'size',
label: this.translate.t('catalog.filterSize'),
type: 'multi-select',
options: sizes.map(value => ({ id: `size-${value}`, label: value, value })),
enabled: enabled.has('size')
},
{
id: 'attributes',
label: this.translate.t('catalog.filterAttributes'),
type: 'multi-select',
options: attributes.map(value => ({ id: `attr-${value}`, label: value, value })),
enabled: enabled.has('attributes')
}
];
this.filterDefinitions.set(definitions);
}
private recomputeResults(): void { private recomputeResults(): void {
if (this.rawProducts().length === 0) { if (this.rawProducts().length === 0) {
this.products.set([]); this.products.set([]);
@@ -663,40 +574,29 @@ export class CatalogContainerComponent {
return; return;
} }
const filtered = this.applyFilters(this.rawProducts()); const filtered = this.searchFacade.applyFilters(this.rawProducts(), this.filterState(), this.state().search);
const sorted = this.applySort(filtered); const sorted = this.searchFacade.applySort(filtered, this.state().sort);
const pagination = this.state().pagination; const pagination = this.state().pagination;
const page = Math.max(1, pagination.page); const page = Math.max(1, pagination.page);
const pageSize = Math.max(1, pagination.count); const pageSize = Math.max(1, pagination.count);
const skip = (page - 1) * pageSize; const paged = this.searchFacade.paginateProducts(sorted, page, pageSize);
const paged = sorted.slice(skip, skip + pageSize);
this.products.set(paged); this.products.set(paged.items);
this.state.update(current => ({ this.state.update(current => ({
...current, ...current,
pagination: { pagination: {
...current.pagination, ...current.pagination,
total: sorted.length, total: sorted.length,
skip, skip: paged.skip,
hasMore: skip + pageSize < sorted.length hasMore: paged.skip + paged.pageSize < sorted.length
} }
})); }));
const result = toSearchResult({ const result = this.searchFacade.buildResult(this.toSearchState(), sorted.length, paged.items);
items: paged,
total: sorted.length,
count: pageSize,
skip
}, {
keyword: this.state().search,
page,
pageSize,
sort: this.state().sort
});
this.searchResult.set(result); this.searchResult.set(result);
this.searchSummary.set(result.summary); this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted)); this.searchSuggestions.set(this.searchFacade.buildLiveSuggestions(this.state().search, sorted));
if (this.pendingScrollY != null && typeof window !== 'undefined') { if (this.pendingScrollY != null && typeof window !== 'undefined') {
const scrollY = this.pendingScrollY; const scrollY = this.pendingScrollY;
@@ -705,138 +605,64 @@ export class CatalogContainerComponent {
} }
} }
private buildSuggestions(query: string, products: Product[]): string[] { private toSearchState(): SearchState {
const normalized = query.trim().toLowerCase(); const current = this.state();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
return []; return {
text: current.search,
sort: current.sort,
layout: current.layout,
page: current.pagination.page,
pageSize: current.pagination.count,
filters: this.filterState(),
};
}
private applySearchStatePatch(patch?: Partial<SearchState>): void {
if (!patch) {
return;
} }
return this.collectUnique( if (patch.filters) {
products this.filterState.set(patch.filters);
.map(product => product.name) }
.filter(name => name.toLowerCase().includes(normalized))
.slice(0, 6) this.state.update(current => ({
...current,
search: patch.text ?? current.search,
sort: patch.sort ?? current.sort,
layout: patch.layout ?? current.layout,
pagination: {
...current.pagination,
page: patch.page ?? current.pagination.page,
count: patch.pageSize ?? current.pagination.count,
skip: Math.max(0, ((patch.page ?? current.pagination.page) - 1) * (patch.pageSize ?? current.pagination.count)),
},
}));
}
private hasSearchState(patch: Partial<SearchState>): boolean {
return Boolean(
patch.text?.length ||
patch.sort ||
patch.layout ||
(patch.page && patch.page > 1) ||
patch.filters
); );
} }
private applyFilters(products: Product[]): Product[] { private syncUrlFromState(): void {
const filterState = this.filterState(); const queryParams = this.searchFacade.toQueryParams(this.toSearchState());
const query = this.state().search.trim().toLowerCase(); this.syncingUrl = true;
void this.router.navigate([], {
return products.filter(product => { relativeTo: this.route,
if (query.length > 0) { queryParams,
const haystack = `${product.name} ${product.simpleDescription ?? ''}`.toLowerCase(); replaceUrl: true,
if (!haystack.includes(query)) { }).finally(() => {
return false; this.syncingUrl = false;
}
}
const priceRange = filterState.ranges['price'];
if (priceRange?.min != null && product.price < priceRange.min) {
return false;
}
if (priceRange?.max != null && product.price > priceRange.max) {
return false;
}
if (filterState.toggles['discount'] && !(product.discount > 0)) {
return false;
}
if (filterState.toggles['new'] && !(product.badges ?? []).some(badge => badge.toLowerCase().includes('new'))) {
return false;
}
const selectedRatings = filterState.values['rating'] ?? [];
if (selectedRatings.length > 0 && !selectedRatings.includes(String(Math.floor(product.rating || 0)))) {
return false;
}
const selectedAvailability = filterState.values['availability'] ?? [];
if (selectedAvailability.length > 0) {
const availability = product.remainings === 'out'
? 'out-of-stock'
: product.remainings === 'low'
? 'low-stock'
: 'in-stock';
if (!selectedAvailability.includes(availability)) {
return false;
}
}
const selectedBrands = filterState.values['brand'] ?? [];
if (selectedBrands.length > 0) {
const tags = product.tags ?? [];
if (!selectedBrands.some(brand => tags.includes(brand))) {
return false;
}
}
const selectedCategories = filterState.values['category'] ?? [];
if (selectedCategories.length > 0 && !selectedCategories.includes(String(product.categoryID))) {
return false;
}
const selectedSubcategories = filterState.values['subcategory'] ?? [];
if (selectedSubcategories.length > 0 && !selectedSubcategories.includes(product.subcategoryId ?? '')) {
return false;
}
const selectedColors = filterState.values['color'] ?? [];
if (selectedColors.length > 0) {
const colors = this.collectUnique([product.colour ?? '', ...(product.itemDetails?.map(detail => detail.colour || detail.color || '') ?? [])]);
if (!selectedColors.some(color => colors.includes(color))) {
return false;
}
}
const selectedSizes = filterState.values['size'] ?? [];
if (selectedSizes.length > 0) {
const sizes = this.collectUnique([product.size ?? '', ...(product.itemDetails?.map(detail => detail.size || '') ?? [])]);
if (!selectedSizes.some(size => sizes.includes(size))) {
return false;
}
}
const selectedAttributes = filterState.values['attributes'] ?? [];
if (selectedAttributes.length > 0) {
const keys = (product.descriptionFields ?? []).map(field => field.key);
if (!selectedAttributes.some(attribute => keys.includes(attribute))) {
return false;
}
}
return true;
}); });
} }
private applySort(products: Product[]): Product[] {
const sort = this.state().sort;
const sorted = [...products];
switch (sort) {
case 'price_asc':
return sorted.sort((a, b) => a.price - b.price);
case 'price_desc':
return sorted.sort((a, b) => b.price - a.price);
case 'rating':
return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0));
case 'popular':
return sorted.sort((a, b) => (b.callbacks?.length ?? 0) - (a.callbacks?.length ?? 0));
case 'discount':
return sorted.sort((a, b) => (b.discount || 0) - (a.discount || 0));
case 'latest':
case 'relevance':
default:
return sorted;
}
}
private collectUnique(values: string[]): string[] {
return [...new Set(values.map(value => value.trim()).filter(Boolean))];
}
private resolveCatalogConfig() { private resolveCatalogConfig() {
const snapshot = this.configService.getBootstrapSnapshot() as any; const snapshot = this.configService.getBootstrapSnapshot() as any;
const raw = snapshot?.catalog ?? {}; const raw = snapshot?.catalog ?? {};

View File

@@ -3,7 +3,7 @@
<table class="compare-table"> <table class="compare-table">
<thead> <thead>
<tr> <tr>
<th>Attribute</th> <th>{{ 'ux.compareAttribute' | translate }}</th>
@for (product of products; track product.itemID) { @for (product of products; track product.itemID) {
<th> <th>
<div class="compare-product-title">{{ product.name }}</div> <div class="compare-product-title">{{ product.name }}</div>

View File

@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@angular/core';
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { TranslateService } from '../../../../../i18n/translate.service';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
interface CompareRow { interface CompareRow {
key: string; key: string;
@@ -11,12 +13,14 @@ interface CompareRow {
@Component({ @Component({
selector: 'app-compare-table', selector: 'app-compare-table',
standalone: true, standalone: true,
imports: [], imports: [TranslatePipe],
templateUrl: './compare-table.component.html', templateUrl: './compare-table.component.html',
styleUrls: ['./compare-table.component.scss'], styleUrls: ['./compare-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class CompareTableComponent { export class CompareTableComponent {
private readonly i18n = inject(TranslateService);
@Input() products: Product[] = []; @Input() products: Product[] = [];
@Input() hideIdentical = false; @Input() hideIdentical = false;
@Input() highlightDifferences = true; @Input() highlightDifferences = true;
@@ -28,12 +32,12 @@ export class CompareTableComponent {
} }
const baseRows: CompareRow[] = [ const baseRows: CompareRow[] = [
this.toRow('price', 'Price', products.map(product => `${product.price.toFixed(2)} ${product.currency}`)), this.toRow('price', this.i18n.t('ux.comparePrice'), products.map(product => `${product.price.toFixed(2)} ${product.currency}`)),
this.toRow('rating', 'Rating', products.map(product => `${(product.rating ?? 0).toFixed(1)}`)), this.toRow('rating', this.i18n.t('ux.compareRating'), products.map(product => `${(product.rating ?? 0).toFixed(1)}`)),
this.toRow('stock', 'Stock', products.map(product => product.remainings ?? 'unknown')), this.toRow('stock', this.i18n.t('ux.compareStock'), products.map(product => product.remainings ?? this.i18n.t('ux.compareUnknown'))),
this.toRow('discount', 'Discount', products.map(product => `${product.discount ?? 0}%`)), this.toRow('discount', this.i18n.t('ux.compareDiscount'), products.map(product => `${product.discount ?? 0}%`)),
this.toRow('color', 'Color', products.map(product => product.colour ?? '—')), this.toRow('color', this.i18n.t('ux.compareColor'), products.map(product => product.colour ?? '—')),
this.toRow('size', 'Size', products.map(product => product.size ?? '—')) this.toRow('size', this.i18n.t('ux.compareSize'), products.map(product => product.size ?? '—'))
]; ];
const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))]; const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))];

View File

@@ -3,6 +3,7 @@ import { ProductCardComponent } from '../../../../../components/product-card/pro
import { Product } from '../../../../../core/products/models/product-domain.model'; import { Product } from '../../../../../core/products/models/product-domain.model';
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../../i18n/translate.service';
import { CartService } from '../../../../../services'; import { CartService } from '../../../../../services';
import { UserNotificationService } from '../../services/user-notification.service'; import { UserNotificationService } from '../../services/user-notification.service';
@@ -15,13 +16,14 @@ import { UserNotificationService } from '../../services/user-notification.servic
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RecentlyViewedStripComponent { export class RecentlyViewedStripComponent {
@Input() title = 'Recently Viewed'; @Input() title = '';
@Input() maxItems = 8; @Input() maxItems = 8;
@Input() showClear = false; @Input() showClear = false;
private readonly uxFacade = inject(UserExperienceFacade); private readonly uxFacade = inject(UserExperienceFacade);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly notifications = inject(UserNotificationService); private readonly notifications = inject(UserNotificationService);
private readonly translate = inject(TranslateService);
readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems))); readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems)));
readonly hasItems = computed(() => this.items().length > 0); readonly hasItems = computed(() => this.items().length > 0);
@@ -30,12 +32,12 @@ export class RecentlyViewedStripComponent {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.cartService.addItem(itemID); this.cartService.addItem(itemID);
this.notifications.show('Added to cart', 'success'); this.notifications.show(this.translate.t('catalog.addToCart'), 'success');
} }
clear(): void { clear(): void {
this.uxFacade.clearRecentlyViewed(); this.uxFacade.clearRecentlyViewed();
this.notifications.show('Recently viewed cleared', 'info'); this.notifications.show(this.translate.t('ux.clearRecentlyViewed'), 'info');
} }
itemProduct(index: number): Product { itemProduct(index: number): Product {

View File

@@ -3,6 +3,7 @@ import { RouterLink } from '@angular/router';
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component'; import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../../i18n/translate.pipe';
import { TranslateService } from '../../../../../i18n/translate.service';
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../../services'; import { CartService } from '../../../../../services';
import { UserNotificationService } from '../../services/user-notification.service'; import { UserNotificationService } from '../../services/user-notification.service';
@@ -18,6 +19,7 @@ import { UserNotificationService } from '../../services/user-notification.servic
export class WishlistPageComponent { export class WishlistPageComponent {
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly uxFacade = inject(UserExperienceFacade); private readonly uxFacade = inject(UserExperienceFacade);
private readonly translate = inject(TranslateService);
private readonly notifications = inject(UserNotificationService); private readonly notifications = inject(UserNotificationService);
readonly favorites = this.uxFacade.wishlist; readonly favorites = this.uxFacade.wishlist;
@@ -27,7 +29,7 @@ export class WishlistPageComponent {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.cartService.addItem(itemId); this.cartService.addItem(itemId);
this.notifications.show('Added to cart', 'success'); this.notifications.show(this.translate.t('ux.addedToCart'), 'success');
} }
removeFromWishlist(itemId: number): void { removeFromWishlist(itemId: number): void {
@@ -37,11 +39,11 @@ export class WishlistPageComponent {
} }
this.uxFacade.toggleWishlist(target.product); this.uxFacade.toggleWishlist(target.product);
this.notifications.show('Removed from wishlist', 'info'); this.notifications.show(this.translate.t('ux.wishlistRemoved'), 'info');
} }
clearWishlist(): void { clearWishlist(): void {
this.uxFacade.clearWishlist(); this.uxFacade.clearWishlist();
this.notifications.show('Wishlist cleared', 'info'); this.notifications.show(this.translate.t('ux.wishlistCleared'), 'info');
} }
} }

View File

@@ -10,6 +10,8 @@ export const en: Translations = {
catalog: 'Catalog', catalog: 'Catalog',
wishlist: 'Wishlist', wishlist: 'Wishlist',
compare: 'Compare', compare: 'Compare',
openMenu: 'Open menu',
closeMenu: 'Close menu',
}, },
footer: { footer: {
description: 'A modern marketplace for comfortable shopping', description: 'A modern marketplace for comfortable shopping',
@@ -102,6 +104,8 @@ export const en: Translations = {
paymentTimeoutDesc: 'We did not receive payment confirmation within 3 minutes.', paymentTimeoutDesc: 'We did not receive payment confirmation within 3 minutes.',
autoClose: 'Window will close automatically...', autoClose: 'Window will close automatically...',
confirmClear: 'Are you sure you want to clear the cart?', confirmClear: 'Are you sure you want to clear the cart?',
removeItem: 'Remove item',
paymentQrAlt: 'Payment QR code',
acceptTerms: 'Please accept the offer terms, return policy, and warranty terms to proceed with the order.', acceptTerms: 'Please accept the offer terms, return policy, and warranty terms to proceed with the order.',
copyError: 'Copy error:', copyError: 'Copy error:',
emailSuccess: 'Email sent successfully! Check your inbox.', emailSuccess: 'Email sent successfully! Check your inbox.',
@@ -175,11 +179,15 @@ export const en: Translations = {
enabled: 'Enabled', enabled: 'Enabled',
searchPlaceholder: 'Search products, brands, categories', searchPlaceholder: 'Search products, brands, categories',
searchSubmit: 'Search', searchSubmit: 'Search',
clearSearch: 'Clear',
suggestionsTitle: 'Suggestions', suggestionsTitle: 'Suggestions',
popularSearchesTitle: 'Popular searches',
recentSearchesTitle: 'Recent searches', recentSearchesTitle: 'Recent searches',
searchHistoryTitle: 'Search history', searchHistoryTitle: 'Search history',
clearHistory: 'Clear', clearHistory: 'Clear',
searchNoResultsHint: 'No results found for this query. Try broader keywords.', searchNoResultsHint: 'No results found for this query. Try broader keywords.',
searchProductsFound: '{{total}} products found',
searchResultsSummary: '{{total}} results for "{{query}}"',
sortBy: 'Sort by', sortBy: 'Sort by',
sortRelevance: 'Recommended', sortRelevance: 'Recommended',
sortLatest: 'Newest', sortLatest: 'Newest',
@@ -331,6 +339,39 @@ export const en: Translations = {
retryConnection: 'Retry', retryConnection: 'Retry',
pageTitle: 'Marketplace of goods and services', pageTitle: 'Marketplace of goods and services',
}, },
platform: {
loadingPage: 'Loading platform page...',
emptyConfig: 'No page configuration found.',
},
backoffice: {
title: 'Backoffice Sandbox',
subtitle: 'Business data management domain (mocked for platform foundation).',
loading: 'Loading backoffice data...',
products: 'Products',
categories: 'Categories',
otherDomains: 'Other Domains',
sku: 'SKU',
titleColumn: 'Title',
price: 'Price',
status: 'Status',
itemsCount: 'items',
orders: 'Orders',
customers: 'Customers',
inventory: 'Inventory',
media: 'Media',
settings: 'Settings',
notAvailable: 'n/a',
},
builder: {
title: 'Builder Sandbox',
subtitle: 'Configuration-only editing surface.',
branding: 'Branding',
brandName: 'Brand Name',
featureFlags: 'Feature Flags',
},
widgets: {
unavailable: 'Widget unavailable',
},
carousel: { carousel: {
loading: 'Loading products...', loading: 'Loading products...',
addToCart: 'Add to cart', addToCart: 'Add to cart',
@@ -360,6 +401,7 @@ export const en: Translations = {
wishlistEmptyTitle: 'Your wishlist is empty', wishlistEmptyTitle: 'Your wishlist is empty',
wishlistEmptyDescription: 'Save products to revisit them anytime.', wishlistEmptyDescription: 'Save products to revisit them anytime.',
clearWishlist: 'Clear wishlist', clearWishlist: 'Clear wishlist',
wishlistCleared: 'Wishlist cleared',
compareTitle: 'Compare Products', compareTitle: 'Compare Products',
compareEmptyTitle: 'No products to compare', compareEmptyTitle: 'No products to compare',
compareEmptyDescription: 'Add products from catalog cards to compare specs and prices.', compareEmptyDescription: 'Add products from catalog cards to compare specs and prices.',
@@ -378,7 +420,17 @@ export const en: Translations = {
productShared: 'Product shared', productShared: 'Product shared',
productLinkCopied: 'Product link copied', productLinkCopied: 'Product link copied',
sharingUnsupported: 'Sharing is not supported on this device', sharingUnsupported: 'Sharing is not supported on this device',
addedToCart: 'Added to cart',
enterQueryBeforeSave: 'Enter a search query before saving', enterQueryBeforeSave: 'Enter a search query before saving',
savedSearchNamed: 'Saved search: {{name}}', savedSearchNamed: 'Saved search: {{name}}',
recentlyViewedTitle: 'Recently Viewed',
compareAttribute: 'Attribute',
comparePrice: 'Price',
compareRating: 'Rating',
compareStock: 'Stock',
compareDiscount: 'Discount',
compareColor: 'Color',
compareSize: 'Size',
compareUnknown: 'unknown',
}, },
}; };

View File

@@ -10,6 +10,8 @@ export const hy: Translations = {
catalog: 'Կատալոգ', catalog: 'Կատալոգ',
wishlist: 'Ընտրյալ', wishlist: 'Ընտրյալ',
compare: 'Համեմատում', compare: 'Համեմատում',
openMenu: 'Բացել մենյուն',
closeMenu: 'Փակել մենյուն',
}, },
footer: { footer: {
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար', description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
@@ -102,6 +104,8 @@ export const hy: Translations = {
paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։', paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։',
autoClose: 'Պատուհանը կփակվի ավտոմատ...', autoClose: 'Պատուհանը կփակվի ավտոմատ...',
confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը', confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը',
removeItem: 'Հեռացնել ապրանքը',
paymentQrAlt: 'Վճարման QR կոդ',
acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։', acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։',
copyError: 'Պատճենման սխալ՝', copyError: 'Պատճենման սխալ՝',
emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։', emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։',
@@ -175,11 +179,15 @@ export const hy: Translations = {
enabled: 'Միացված', enabled: 'Միացված',
searchPlaceholder: 'Որոնել ապրանքներ, բրենդներ, կատեգորիաներ', searchPlaceholder: 'Որոնել ապրանքներ, բրենդներ, կատեգորիաներ',
searchSubmit: 'Որոնել', searchSubmit: 'Որոնել',
clearSearch: 'Մաքրել',
suggestionsTitle: 'Առաջարկներ', suggestionsTitle: 'Առաջարկներ',
popularSearchesTitle: 'Հանրաճանաչ որոնումներ',
recentSearchesTitle: 'Վերջին որոնումներ', recentSearchesTitle: 'Վերջին որոնումներ',
searchHistoryTitle: 'Որոնման պատմություն', searchHistoryTitle: 'Որոնման պատմություն',
clearHistory: 'Մաքրել', clearHistory: 'Մաքրել',
searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։', searchNoResultsHint: 'Արդյունքներ չկան։ Փորձեք ավելի լայն բանալի բառեր։',
searchProductsFound: 'Գտնված ապրանքներ՝ {{total}}',
searchResultsSummary: '{{total}} արդյունք "{{query}}" հարցմամբ',
sortBy: 'Տեսակավորել ըստ', sortBy: 'Տեսակավորել ըստ',
sortRelevance: 'Առաջարկվող', sortRelevance: 'Առաջարկվող',
sortLatest: 'Նորության', sortLatest: 'Նորության',
@@ -331,6 +339,39 @@ export const hy: Translations = {
retryConnection: 'Փորձել կրկին', retryConnection: 'Փորձել կրկին',
pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս', pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս',
}, },
platform: {
loadingPage: 'Պլատֆորմի էջի բեռնում...',
emptyConfig: 'Էջի կոնֆիգուրացիա չի գտնվել։',
},
backoffice: {
title: 'Backoffice Sandbox',
subtitle: 'Բիզնես տվյալների կառավարման դոմեն (mock պլատֆորմի հիմքի համար):',
loading: 'Backoffice տվյալների բեռնում...',
products: 'Ապրանքներ',
categories: 'Կատեգորիաներ',
otherDomains: 'Այլ դոմեններ',
sku: 'SKU',
titleColumn: 'Անվանում',
price: 'Գին',
status: 'Կարգավիճակ',
itemsCount: 'հատ',
orders: 'Պատվերներ',
customers: 'Հաճախորդներ',
inventory: 'Պահեստ',
media: 'Մեդիա',
settings: 'Կարգավորումներ',
notAvailable: 'չկա',
},
builder: {
title: 'Builder Sandbox',
subtitle: 'Միայն կոնֆիգուրացիայի խմբագրման մակերես։',
branding: 'Բրենդինգ',
brandName: 'Բրենդի անվանում',
featureFlags: 'Feature flags',
},
widgets: {
unavailable: 'Վիջեթը հասանելի չէ',
},
carousel: { carousel: {
loading: 'Ապրանքների բեռնում...', loading: 'Ապրանքների բեռնում...',
addToCart: 'Ավելացնել զամբյուղ', addToCart: 'Ավելացնել զամբյուղ',
@@ -360,6 +401,7 @@ export const hy: Translations = {
wishlistEmptyTitle: 'Ընտրյալները դատարկ են', wishlistEmptyTitle: 'Ընտրյալները դատարկ են',
wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։', wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։',
clearWishlist: 'Մաքրել ընտրյալները', clearWishlist: 'Մաքրել ընտրյալները',
wishlistCleared: 'Ընտրյալները մաքրվել են',
compareTitle: 'Ապրանքների համեմատում', compareTitle: 'Ապրանքների համեմատում',
compareEmptyTitle: 'Համեմատելու ապրանքներ չկան', compareEmptyTitle: 'Համեմատելու ապրանքներ չկան',
compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։', compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։',
@@ -378,7 +420,17 @@ export const hy: Translations = {
productShared: 'Ապրանքը կիսվել է', productShared: 'Ապրանքը կիսվել է',
productLinkCopied: 'Ապրանքի հղումը պատճենվել է', productLinkCopied: 'Ապրանքի հղումը պատճենվել է',
sharingUnsupported: 'Այս սարքում կիսվելու հնարավորությունը չի աջակցվում', sharingUnsupported: 'Այս սարքում կիսվելու հնարավորությունը չի աջակցվում',
addedToCart: 'Ավելացվեց զամբյուղում',
enterQueryBeforeSave: 'Մինչ պահպանումը մուտքագրեք որոնման հարցում', enterQueryBeforeSave: 'Մինչ պահպանումը մուտքագրեք որոնման հարցում',
savedSearchNamed: 'Պահպանված որոնում՝ {{name}}', savedSearchNamed: 'Պահպանված որոնում՝ {{name}}',
recentlyViewedTitle: 'Վերջին դիտվածներ',
compareAttribute: 'Ատրիբուտ',
comparePrice: 'Գին',
compareRating: 'Վարկանիշ',
compareStock: 'Առկայություն',
compareDiscount: 'Զեղչ',
compareColor: 'Գույն',
compareSize: 'Չափ',
compareUnknown: 'անհայտ',
}, },
}; };

View File

@@ -10,6 +10,8 @@ export const ru: Translations = {
catalog: 'Каталог', catalog: 'Каталог',
wishlist: 'Избранное', wishlist: 'Избранное',
compare: 'Сравнение', compare: 'Сравнение',
openMenu: 'Открыть меню',
closeMenu: 'Закрыть меню',
}, },
footer: { footer: {
description: 'Современный маркетплейс для комфортных покупок', description: 'Современный маркетплейс для комфортных покупок',
@@ -102,6 +104,8 @@ export const ru: Translations = {
paymentTimeoutDesc: 'Мы не получили подтверждение оплаты в течение 3 минут.', paymentTimeoutDesc: 'Мы не получили подтверждение оплаты в течение 3 минут.',
autoClose: 'Окно закроется автоматически...', autoClose: 'Окно закроется автоматически...',
confirmClear: 'Вы уверены, что хотите очистить корзину?', confirmClear: 'Вы уверены, что хотите очистить корзину?',
removeItem: 'Удалить товар',
paymentQrAlt: 'QR-код для оплаты',
acceptTerms: 'Пожалуйста, примите условия оферты, политику возврата и возврата для подтверждения оформления заказа.', acceptTerms: 'Пожалуйста, примите условия оферты, политику возврата и возврата для подтверждения оформления заказа.',
copyError: 'Ошибка копирования:', copyError: 'Ошибка копирования:',
emailSuccess: 'Email успешно отправлен! Проверьте свою почту.', emailSuccess: 'Email успешно отправлен! Проверьте свою почту.',
@@ -175,11 +179,15 @@ export const ru: Translations = {
enabled: 'Включено', enabled: 'Включено',
searchPlaceholder: 'Поиск товаров, брендов и категорий', searchPlaceholder: 'Поиск товаров, брендов и категорий',
searchSubmit: 'Найти', searchSubmit: 'Найти',
clearSearch: 'Очистить',
suggestionsTitle: 'Подсказки', suggestionsTitle: 'Подсказки',
popularSearchesTitle: 'Популярные запросы',
recentSearchesTitle: 'Недавние запросы', recentSearchesTitle: 'Недавние запросы',
searchHistoryTitle: 'История поиска', searchHistoryTitle: 'История поиска',
clearHistory: 'Очистить', clearHistory: 'Очистить',
searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.', searchNoResultsHint: 'Ничего не найдено. Попробуйте более общий запрос.',
searchProductsFound: 'Найдено товаров: {{total}}',
searchResultsSummary: '{{total}} результатов по запросу "{{query}}"',
sortBy: 'Сортировать по', sortBy: 'Сортировать по',
sortRelevance: 'Рекомендуемые', sortRelevance: 'Рекомендуемые',
sortLatest: 'Новизне', sortLatest: 'Новизне',
@@ -331,6 +339,39 @@ export const ru: Translations = {
retryConnection: 'Повторить попытку', retryConnection: 'Повторить попытку',
pageTitle: 'Маркетплейс товаров и услуг', pageTitle: 'Маркетплейс товаров и услуг',
}, },
platform: {
loadingPage: 'Загрузка страницы платформы...',
emptyConfig: 'Конфигурация страницы не найдена.',
},
backoffice: {
title: 'Песочница Backoffice',
subtitle: 'Домен управления бизнес-данными (mock для платформенного фундамента).',
loading: 'Загрузка данных backoffice...',
products: 'Товары',
categories: 'Категории',
otherDomains: 'Другие домены',
sku: 'SKU',
titleColumn: 'Название',
price: 'Цена',
status: 'Статус',
itemsCount: 'шт.',
orders: 'Заказы',
customers: 'Клиенты',
inventory: 'Склад',
media: 'Медиа',
settings: 'Настройки',
notAvailable: 'н/д',
},
builder: {
title: 'Песочница Builder',
subtitle: 'Поверхность редактирования только конфигурации.',
branding: 'Брендинг',
brandName: 'Название бренда',
featureFlags: 'Фичи-флаги',
},
widgets: {
unavailable: 'Виджет недоступен',
},
carousel: { carousel: {
loading: 'Загрузка товаров...', loading: 'Загрузка товаров...',
addToCart: 'Добавить в корзину', addToCart: 'Добавить в корзину',
@@ -360,6 +401,7 @@ export const ru: Translations = {
wishlistEmptyTitle: 'Избранное пока пусто', wishlistEmptyTitle: 'Избранное пока пусто',
wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.', wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.',
clearWishlist: 'Очистить избранное', clearWishlist: 'Очистить избранное',
wishlistCleared: 'Избранное очищено',
compareTitle: 'Сравнение товаров', compareTitle: 'Сравнение товаров',
compareEmptyTitle: 'Нет товаров для сравнения', compareEmptyTitle: 'Нет товаров для сравнения',
compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.', compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.',
@@ -378,7 +420,17 @@ export const ru: Translations = {
productShared: 'Товар отправлен', productShared: 'Товар отправлен',
productLinkCopied: 'Ссылка на товар скопирована', productLinkCopied: 'Ссылка на товар скопирована',
sharingUnsupported: 'На этом устройстве нет поддержки шаринга', sharingUnsupported: 'На этом устройстве нет поддержки шаринга',
addedToCart: 'Добавлено в корзину',
enterQueryBeforeSave: 'Введите поисковый запрос перед сохранением', enterQueryBeforeSave: 'Введите поисковый запрос перед сохранением',
savedSearchNamed: 'Сохраненный поиск: {{name}}', savedSearchNamed: 'Сохраненный поиск: {{name}}',
recentlyViewedTitle: 'Недавно просмотренные',
compareAttribute: 'Атрибут',
comparePrice: 'Цена',
compareRating: 'Рейтинг',
compareStock: 'Наличие',
compareDiscount: 'Скидка',
compareColor: 'Цвет',
compareSize: 'Размер',
compareUnknown: 'неизвестно',
}, },
}; };

View File

@@ -8,6 +8,8 @@ export interface Translations {
catalog: string; catalog: string;
wishlist: string; wishlist: string;
compare: string; compare: string;
openMenu: string;
closeMenu: string;
}; };
footer: { footer: {
description: string; description: string;
@@ -100,6 +102,8 @@ export interface Translations {
paymentTimeoutDesc: string; paymentTimeoutDesc: string;
autoClose: string; autoClose: string;
confirmClear: string; confirmClear: string;
removeItem: string;
paymentQrAlt: string;
acceptTerms: string; acceptTerms: string;
copyError: string; copyError: string;
emailSuccess: string; emailSuccess: string;
@@ -173,11 +177,15 @@ export interface Translations {
enabled: string; enabled: string;
searchPlaceholder: string; searchPlaceholder: string;
searchSubmit: string; searchSubmit: string;
clearSearch: string;
suggestionsTitle: string; suggestionsTitle: string;
popularSearchesTitle: string;
recentSearchesTitle: string; recentSearchesTitle: string;
searchHistoryTitle: string; searchHistoryTitle: string;
clearHistory: string; clearHistory: string;
searchNoResultsHint: string; searchNoResultsHint: string;
searchProductsFound: string;
searchResultsSummary: string;
sortBy: string; sortBy: string;
sortRelevance: string; sortRelevance: string;
sortLatest: string; sortLatest: string;
@@ -329,6 +337,39 @@ export interface Translations {
retryConnection: string; retryConnection: string;
pageTitle: string; pageTitle: string;
}; };
platform: {
loadingPage: string;
emptyConfig: string;
};
backoffice: {
title: string;
subtitle: string;
loading: string;
products: string;
categories: string;
otherDomains: string;
sku: string;
titleColumn: string;
price: string;
status: string;
itemsCount: string;
orders: string;
customers: string;
inventory: string;
media: string;
settings: string;
notAvailable: string;
};
builder: {
title: string;
subtitle: string;
branding: string;
brandName: string;
featureFlags: string;
};
widgets: {
unavailable: string;
};
carousel: { carousel: {
loading: string; loading: string;
addToCart: string; addToCart: string;
@@ -358,6 +399,7 @@ export interface Translations {
wishlistEmptyTitle: string; wishlistEmptyTitle: string;
wishlistEmptyDescription: string; wishlistEmptyDescription: string;
clearWishlist: string; clearWishlist: string;
wishlistCleared: string;
compareTitle: string; compareTitle: string;
compareEmptyTitle: string; compareEmptyTitle: string;
compareEmptyDescription: string; compareEmptyDescription: string;
@@ -376,7 +418,17 @@ export interface Translations {
productShared: string; productShared: string;
productLinkCopied: string; productLinkCopied: string;
sharingUnsupported: string; sharingUnsupported: string;
addedToCart: string;
enterQueryBeforeSave: string; enterQueryBeforeSave: string;
savedSearchNamed: string; savedSearchNamed: string;
recentlyViewedTitle: string;
compareAttribute: string;
comparePrice: string;
compareRating: string;
compareStock: string;
compareDiscount: string;
compareColor: string;
compareSize: string;
compareUnknown: string;
}; };
} }

View File

@@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core';
import { NgComponentOutlet } from '@angular/common'; import { NgComponentOutlet } from '@angular/common';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@@ -9,6 +9,7 @@ import { ResolvedWidget } from '../../widgets/contracts/widget-component.contrac
import { WidgetHostService } from '../../dynamic-renderer/widget-host/widget-host.service'; import { WidgetHostService } from '../../dynamic-renderer/widget-host/widget-host.service';
import { LanguageService } from '../../services/language.service'; import { LanguageService } from '../../services/language.service';
import { Category } from '../../core/categories/models/category-domain.model'; import { Category } from '../../core/categories/models/category-domain.model';
import { AuthService } from '../../services/auth.service';
@Component({ @Component({
selector: 'app-dynamic-page-layout', selector: 'app-dynamic-page-layout',
@@ -34,10 +35,19 @@ import { Category } from '../../core/categories/models/category-domain.model';
[style.--section-align]="section.layout?.align ?? null" [style.--section-align]="section.layout?.align ?? null"
> >
@for (widget of section.widgets; track widget.id) { @for (widget of section.widgets; track widget.id) {
@if (isWidgetPermitted(widget.source)) {
<div <div
class="dynamic-widget" class="dynamic-widget"
[attr.data-widget-type]="widget.type" [attr.data-widget-type]="widget.type"
[attr.data-widget-title]="widget.source.title ?? null"
[attr.data-widget-subtitle]="widget.source.subtitle ?? null"
[attr.data-widget-animation]="widget.source.animation?.name ?? null"
[style.--widget-padding]="widget.padding ?? null" [style.--widget-padding]="widget.padding ?? null"
[style.--widget-animation-duration]="widget.source.animation?.duration ?? null"
[style.--widget-animation-delay]="widget.source.animation?.delay ?? null"
[style.--widget-animation-timing]="widget.source.animation?.timingFunction ?? null"
[style.animation-name]="widget.source.animation?.name ?? null"
[ngStyle]="widget.source.style ?? null"
[class.dynamic-widget--desktop-hidden]="widget.visibility?.desktop === false" [class.dynamic-widget--desktop-hidden]="widget.visibility?.desktop === false"
[class.dynamic-widget--tablet-hidden]="widget.visibility?.tablet === false" [class.dynamic-widget--tablet-hidden]="widget.visibility?.tablet === false"
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false" [class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
@@ -50,6 +60,7 @@ import { Category } from '../../core/categories/models/category-domain.model';
} }
} }
</div> </div>
}
} }
</div> </div>
</div> </div>
@@ -82,6 +93,9 @@ import { Category } from '../../core/categories/models/category-domain.model';
.dynamic-widget { .dynamic-widget {
padding: var(--widget-padding, 0); padding: var(--widget-padding, 0);
animation-duration: var(--widget-animation-duration, initial);
animation-delay: var(--widget-animation-delay, initial);
animation-timing-function: var(--widget-animation-timing, initial);
} }
.dynamic-section[data-section-layout='grid'] .dynamic-section__content { .dynamic-section[data-section-layout='grid'] .dynamic-section__content {
@@ -116,6 +130,8 @@ import { Category } from '../../core/categories/models/category-domain.model';
export class DynamicPageLayoutComponent { export class DynamicPageLayoutComponent {
@Input() model: PageRenderModel | null = null; @Input() model: PageRenderModel | null = null;
private readonly authService = inject(AuthService);
constructor( constructor(
private readonly widgetHost: WidgetHostService, private readonly widgetHost: WidgetHostService,
private readonly router: Router, private readonly router: Router,
@@ -129,4 +145,17 @@ export class DynamicPageLayoutComponent {
onCategorySelected(category: Category): void { onCategorySelected(category: Category): void {
this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]); this.router.navigate([`/${this.languageService.currentLanguage()}/catalog`, category.id]);
} }
isWidgetPermitted(widget: WidgetRenderNode['source']): boolean {
const permissions = widget.permissions;
if (!permissions) {
return true;
}
if (permissions.requireAuthenticated && !this.authService.isAuthenticated()) {
return false;
}
return true;
}
} }

View File

@@ -1,28 +1,29 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { BackofficeFacade } from '../../facades/backoffice/backoffice.facade'; import { BackofficeFacade } from '../../facades/backoffice/backoffice.facade';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-backoffice-dashboard', selector: 'app-backoffice-dashboard',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, TranslatePipe],
template: ` template: `
<main class="backoffice-dashboard"> <main class="backoffice-dashboard">
<h1>Backoffice Sandbox</h1> <h1>{{ 'backoffice.title' | translate }}</h1>
<p>Business data management domain (mocked for platform foundation).</p> <p>{{ 'backoffice.subtitle' | translate }}</p>
@if (loading()) { @if (loading()) {
<section class="card">Loading backoffice data...</section> <section class="card">{{ 'backoffice.loading' | translate }}</section>
} @else { } @else {
<section class="card"> <section class="card">
<h2>Products</h2> <h2>{{ 'backoffice.products' | translate }}</h2>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>SKU</th> <th>{{ 'backoffice.sku' | translate }}</th>
<th>Title</th> <th>{{ 'backoffice.titleColumn' | translate }}</th>
<th>Price</th> <th>{{ 'backoffice.price' | translate }}</th>
<th>Status</th> <th>{{ 'backoffice.status' | translate }}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -31,7 +32,7 @@ import { BackofficeFacade } from '../../facades/backoffice/backoffice.facade';
<td>{{ product.sku }}</td> <td>{{ product.sku }}</td>
<td>{{ product.title }}</td> <td>{{ product.title }}</td>
<td>{{ product.price.amount }} {{ product.price.currency }}</td> <td>{{ product.price.amount }} {{ product.price.currency }}</td>
<td>{{ product.stockStatus ?? 'n/a' }}</td> <td>{{ product.stockStatus ?? ('backoffice.notAvailable' | translate) }}</td>
</tr> </tr>
} }
</tbody> </tbody>
@@ -39,22 +40,22 @@ import { BackofficeFacade } from '../../facades/backoffice/backoffice.facade';
</section> </section>
<section class="card"> <section class="card">
<h2>Categories</h2> <h2>{{ 'backoffice.categories' | translate }}</h2>
<ul> <ul>
@for (category of categories(); track category.id) { @for (category of categories(); track category.id) {
<li>{{ category.title }} ({{ category.itemsCount ?? 0 }})</li> <li>{{ category.title }} ({{ category.itemsCount ?? 0 }} {{ 'backoffice.itemsCount' | translate }})</li>
} }
</ul> </ul>
</section> </section>
<section class="card placeholders"> <section class="card placeholders">
<h2>Other Domains</h2> <h2>{{ 'backoffice.otherDomains' | translate }}</h2>
<div class="chips"> <div class="chips">
<span>Orders</span> <span>{{ 'backoffice.orders' | translate }}</span>
<span>Customers</span> <span>{{ 'backoffice.customers' | translate }}</span>
<span>Inventory</span> <span>{{ 'backoffice.inventory' | translate }}</span>
<span>Media</span> <span>{{ 'backoffice.media' | translate }}</span>
<span>Settings</span> <span>{{ 'backoffice.settings' | translate }}</span>
</div> </div>
</section> </section>
} }

View File

@@ -2,21 +2,22 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { BuilderConfigFacade } from '../../facades/builder/builder-config.facade'; import { BuilderConfigFacade } from '../../facades/builder/builder-config.facade';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-builder-sandbox', selector: 'app-builder-sandbox',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule, TranslatePipe],
template: ` template: `
<main class="builder-sandbox"> <main class="builder-sandbox">
<h1>Builder Sandbox</h1> <h1>{{ 'builder.title' | translate }}</h1>
<p>Configuration-only editing surface.</p> <p>{{ 'builder.subtitle' | translate }}</p>
@if (branding(); as brand) { @if (branding(); as brand) {
<section class="builder-card"> <section class="builder-card">
<h2>Branding</h2> <h2>{{ 'builder.branding' | translate }}</h2>
<label> <label>
Brand Name {{ 'builder.brandName' | translate }}
<input type="text" [ngModel]="brand.brandName" (ngModelChange)="onBrandNameChange($event)" /> <input type="text" [ngModel]="brand.brandName" (ngModelChange)="onBrandNameChange($event)" />
</label> </label>
</section> </section>
@@ -24,7 +25,7 @@ import { BuilderConfigFacade } from '../../facades/builder/builder-config.facade
@if (featureFlags(); as flags) { @if (featureFlags(); as flags) {
<section class="builder-card"> <section class="builder-card">
<h2>Feature Flags</h2> <h2>{{ 'builder.featureFlags' | translate }}</h2>
@for (flag of featureFlagKeys(); track flag) { @for (flag of featureFlagKeys(); track flag) {
<label class="flag-row"> <label class="flag-row">
<input type="checkbox" [checked]="flags[flag]" (change)="onFlagToggle(flag, $event)" /> <input type="checkbox" [checked]="flags[flag]" (change)="onFlagToggle(flag, $event)" />

View File

@@ -1,4 +1,4 @@
<div class="cart-container dexar"> <div class="cart-container platform">
<div class="cart-header"> <div class="cart-header">
<h1>{{ 'cart.title' | translate }}</h1> <h1>{{ 'cart.title' | translate }}</h1>
@if (itemCount() > 0) { @if (itemCount() > 0) {
@@ -37,7 +37,7 @@
<div class="item-info"> <div class="item-info">
<div class="item-header"> <div class="item-header">
<a [routerLink]="['/product', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a> <a [routerLink]="['/product', item.itemID] | langRoute" class="item-name">{{ itemName(item) }}</a>
<button class="remove-btn" (click)="removeItem(item)" title="Remove"> <button class="remove-btn" (click)="removeItem(item)" [attr.title]="'cart.removeItem' | translate">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/> <path d="M18 6L6 18M6 6l12 12"/>
</svg> </svg>
@@ -225,7 +225,7 @@
@if (qrCodeUrl()) { @if (qrCodeUrl()) {
<div class="qr-section"> <div class="qr-section">
<div class="qr-wrapper"> <div class="qr-wrapper">
<img [src]="qrCodeUrl()" alt="QR код для оплаты" class="qr-code" /> <img [src]="qrCodeUrl()" [attr.alt]="'cart.paymentQrAlt' | translate" class="qr-code" />
<div class="scan-line"></div> <div class="scan-line"></div>
</div> </div>
</div> </div>
@@ -272,7 +272,7 @@
@if (paymentStatus() === 'success') { @if (paymentStatus() === 'success') {
<div class="payment-status-screen success"> <div class="payment-status-screen success">
<div class="success-icon"></div> <div class="success-icon">?</div>
<h2>{{ 'cart.paymentSuccess' | translate }}</h2> <h2>{{ 'cart.paymentSuccess' | translate }}</h2>
<!-- <p class="success-text">{{ 'cart.paymentSuccessDesc' | translate }}</p> --> <!-- <p class="success-text">{{ 'cart.paymentSuccessDesc' | translate }}</p> -->
@@ -331,7 +331,7 @@
@if (paymentStatus() === 'timeout') { @if (paymentStatus() === 'timeout') {
<div class="payment-status-screen timeout"> <div class="payment-status-screen timeout">
<div class="timeout-icon"></div> <div class="timeout-icon">?</div>
<h2>{{ 'cart.paymentTimeout' | translate }}</h2> <h2>{{ 'cart.paymentTimeout' | translate }}</h2>
<p>{{ 'cart.paymentTimeoutDesc' | translate }}</p> <p>{{ 'cart.paymentTimeoutDesc' | translate }}</p>
<p class="auto-close">{{ 'cart.autoClose' | translate }}</p> <p class="auto-close">{{ 'cart.autoClose' | translate }}</p>
@@ -355,3 +355,5 @@
} }
<app-telegram-login /> <app-telegram-login />

View File

@@ -1,5 +1,5 @@
// Base styles (Dexar theme) // Base styles (platform theme)
.cart-container.dexar { .cart-container.platform {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
@@ -7,8 +7,8 @@
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
// Novo theme - Modern green design // alt theme - Modern green design
.cart-container.novo { .cart-container.alt {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 24px; padding: 24px;
@@ -16,7 +16,7 @@
background: linear-gradient(to bottom, rgba(16, 185, 129, 0.02) 0%, transparent 300px); background: linear-gradient(to bottom, rgba(16, 185, 129, 0.02) 0%, transparent 300px);
} }
.cart-container.dexar .cart-header { .cart-container.platform .cart-header {
margin-bottom: 28px; margin-bottom: 28px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -64,7 +64,7 @@
} }
} }
.cart-container.novo .cart-header { .cart-container.alt .cart-header {
margin-bottom: 32px; margin-bottom: 32px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -113,8 +113,8 @@
} }
} }
// Empty cart - Dexar // Empty cart - platform
.cart-container.dexar .empty-cart { .cart-container.platform .empty-cart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -170,8 +170,8 @@
} }
} }
// Empty cart - Novo // Empty cart - alt
.cart-container.novo .empty-cart { .cart-container.alt .empty-cart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -230,8 +230,8 @@
align-items: start; align-items: start;
} }
// Novo wider summary // alt wider summary
.cart-container.novo .cart-content { .cart-container.alt .cart-content {
grid-template-columns: minmax(0, 1fr) 400px; grid-template-columns: minmax(0, 1fr) 400px;
gap: 32px; gap: 32px;
} }
@@ -243,8 +243,8 @@
min-width: 0; min-width: 0;
} }
// Novo larger gap // alt larger gap
.cart-container.novo .cart-items { .cart-container.alt .cart-items {
gap: 20px; gap: 20px;
} }
@@ -266,13 +266,13 @@
} }
} }
// Novo rounded corners // alt rounded corners
.cart-container.novo .cart-item-wrapper { .cart-container.alt .cart-item-wrapper {
border-radius: 16px; border-radius: 16px;
} }
// Dexar cart item // platform cart item
.cart-container.dexar .cart-item { .cart-container.platform .cart-item {
display: flex; display: flex;
gap: 20px; gap: 20px;
background: white; background: white;
@@ -402,8 +402,8 @@
} }
} }
// Novo cart item - modern green theme // alt cart item - modern green theme
.cart-container.novo .cart-item { .cart-container.alt .cart-item {
display: flex; display: flex;
gap: 20px; gap: 20px;
background: white; background: white;
@@ -563,8 +563,8 @@
} }
} }
// Dexar quantity controls // platform quantity controls
.cart-container.dexar .quantity-controls { .cart-container.platform .quantity-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -607,8 +607,8 @@
} }
} }
// Novo quantity controls - modern green // alt quantity controls - modern green
.cart-container.novo .quantity-controls { .cart-container.alt .quantity-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 14px; gap: 14px;
@@ -676,8 +676,8 @@
} }
} }
// Dexar Cart Summary // platform Cart Summary
.cart-container.dexar .cart-summary { .cart-container.platform .cart-summary {
position: sticky; position: sticky;
top: 90px; top: 90px;
background: #f5f3f9; background: #f5f3f9;
@@ -827,8 +827,8 @@
} }
} }
// Novo Cart Summary - Green Modern // alt Cart Summary - Green Modern
.cart-container.novo .cart-summary { .cart-container.alt .cart-summary {
position: sticky; position: sticky;
top: 20px; top: 20px;
background: white; background: white;
@@ -1054,21 +1054,21 @@
line-height: 1.5; line-height: 1.5;
} }
.cart-container.dexar .cart-summary .delivery-warning { .cart-container.platform .cart-summary .delivery-warning {
color: #9a6700; color: #9a6700;
background: #fff8e8; background: #fff8e8;
border: 1px solid #f1ddb2; border: 1px solid #f1ddb2;
font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.cart-container.novo .cart-summary .delivery-warning { .cart-container.alt .cart-summary .delivery-warning {
color: #92400e; color: #92400e;
background: #fffbeb; background: #fffbeb;
border: 1px solid #fde68a; border: 1px solid #fde68a;
} }
// Dexar checkbox colors // platform checkbox colors
.cart-container.dexar .terms-agreement .checkbox-container { .cart-container.platform .terms-agreement .checkbox-container {
input[type="checkbox"]:checked ~ .checkmark { input[type="checkbox"]:checked ~ .checkmark {
background: #497671; background: #497671;
border-color: #497671; border-color: #497671;
@@ -1102,8 +1102,8 @@
} }
} }
// Novo checkbox colors - green // alt checkbox colors - green
.cart-container.novo .terms-agreement .checkbox-container { .cart-container.alt .terms-agreement .checkbox-container {
input[type="checkbox"]:checked ~ .checkmark { input[type="checkbox"]:checked ~ .checkmark {
background: #10b981; background: #10b981;
border-color: #10b981; border-color: #10b981;
@@ -1619,7 +1619,7 @@
gap: 20px; gap: 20px;
} }
.cart-container.novo .cart-content { .cart-container.alt .cart-content {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 20px; gap: 20px;
} }
@@ -1631,13 +1631,13 @@
min-width: 0; min-width: 0;
} }
.cart-container.novo, .cart-container.alt,
.cart-container.dexar { .cart-container.platform {
padding: 16px; padding: 16px;
} }
.cart-container.novo .cart-header, .cart-container.alt .cart-header,
.cart-container.dexar .cart-header { .cart-container.platform .cart-header {
flex-wrap: wrap; flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
@@ -1652,7 +1652,7 @@
.payment-modal { .payment-modal {
padding: 24px; padding: 24px;
padding-top: 56px; // Дополнительный отступ сверху для кнопки закрытия padding-top: 56px; // Äîïîëíèòåëüíûé îòñòóï ñâåðõó äëÿ êíîïêè çàêðûòèÿ
} }
.close-modal-btn { .close-modal-btn {
@@ -1710,17 +1710,17 @@
} }
/* ============================================= /* =============================================
novo THEME STYLES - Modern Cart Design alt THEME STYLES - Modern Cart Design
============================================= */ ============================================= */
.novo-cart-container { .alt-cart-container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
min-height: calc(100vh - 200px); min-height: calc(100vh - 200px);
} }
.novo-cart-header { .alt-cart-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -1739,7 +1739,7 @@
background-clip: text; background-clip: text;
} }
.novo-clear-btn { .alt-clear-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -1769,7 +1769,7 @@
} }
} }
.novo-empty-cart { .alt-empty-cart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1804,7 +1804,7 @@
max-width: 400px; max-width: 400px;
} }
.novo-shop-btn { .alt-shop-btn {
display: inline-block; display: inline-block;
padding: 0.875rem 2rem; padding: 0.875rem 2rem;
background: linear-gradient(135deg, #10b981, #14b8a6, #06b6d4); background: linear-gradient(135deg, #10b981, #14b8a6, #06b6d4);
@@ -1822,20 +1822,20 @@
} }
} }
.novo-cart-content { .alt-cart-content {
display: grid; display: grid;
grid-template-columns: 1fr 400px; grid-template-columns: 1fr 400px;
gap: 2rem; gap: 2rem;
align-items: start; align-items: start;
} }
.novo-cart-items { .alt-cart-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.25rem; gap: 1.25rem;
} }
.novo-cart-item { .alt-cart-item {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;
padding: 1.5rem; padding: 1.5rem;
@@ -1850,7 +1850,7 @@
transform: translateY(-2px); transform: translateY(-2px);
} }
.novo-item-image { .alt-item-image {
flex-shrink: 0; flex-shrink: 0;
width: 120px; width: 120px;
height: 120px; height: 120px;
@@ -1872,7 +1872,7 @@
} }
} }
.novo-item-details { .alt-item-details {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1898,7 +1898,7 @@
} }
} }
.novo-remove-btn { .alt-remove-btn {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1971,7 +1971,7 @@
} }
} }
.novo-quantity { .alt-quantity {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -2021,7 +2021,7 @@
} }
} }
.novo-cart-summary { .alt-cart-summary {
position: sticky; position: sticky;
top: 80px; top: 80px;
} }
@@ -2078,7 +2078,7 @@
} }
} }
.novo-checkout-btn { .alt-checkout-btn {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
margin-top: 1.25rem; margin-top: 1.25rem;
@@ -2108,10 +2108,10 @@
} }
} }
.novo-terms-agreement { .alt-terms-agreement {
margin-top: 1rem; margin-top: 1rem;
.novo-checkbox-container { .alt-checkbox-container {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 0.75rem; gap: 0.75rem;
@@ -2127,7 +2127,7 @@
height: 0; height: 0;
width: 0; width: 0;
&:checked ~ .novo-checkmark { &:checked ~ .alt-checkmark {
background: #10b981; background: #10b981;
border-color: #10b981; border-color: #10b981;
@@ -2137,7 +2137,7 @@
} }
} }
.novo-checkmark { .alt-checkmark {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
height: 20px; height: 20px;
@@ -2162,7 +2162,7 @@
} }
} }
.novo-terms-text { .alt-terms-text {
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.6; line-height: 1.6;
@@ -2182,23 +2182,23 @@
} }
} }
/* novo Cart Responsive */ /* alt Cart Responsive */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.novo-cart-content { .alt-cart-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
.novo-cart-summary { .alt-cart-summary {
position: static; position: static;
} }
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.novo-cart-container { .alt-cart-container {
padding: 1rem; padding: 1rem;
} }
.novo-cart-header { .alt-cart-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 1rem; gap: 1rem;
@@ -2208,22 +2208,22 @@
} }
} }
.novo-cart-item { .alt-cart-item {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
.novo-item-image { .alt-item-image {
width: 100%; width: 100%;
height: 200px; height: 200px;
} }
.novo-item-details { .alt-item-details {
.item-bottom { .item-bottom {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 0.75rem; gap: 0.75rem;
.novo-quantity { .alt-quantity {
justify-content: center; justify-content: center;
} }
} }
@@ -2238,3 +2238,5 @@
} }
} }
} }

View File

@@ -6,6 +6,6 @@
<app-dynamic-page-layout [model]="pageModel()" /> <app-dynamic-page-layout [model]="pageModel()" />
} @else { } @else {
<section class="home-empty section"> <section class="home-empty section">
<div class="page-container">No page configuration found.</div> <div class="page-container">{{ 'platform.emptyConfig' | translate }}</div>
</section> </section>
} }

View File

@@ -1,4 +1,4 @@
// ========== SHARED ANIMATIONS ========== // ========== SHARED ANIMATIONS ==========
@keyframes fadeIn { @keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); } from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); } to { opacity: 1; transform: translateY(0); }
@@ -19,28 +19,28 @@
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
// ========== NOVO HOME PAGE STYLES ========== // ========== alt HOME PAGE STYLES ==========
.novo-home { .alt-home {
min-height: calc(100vh - 200px); min-height: calc(100vh - 200px);
animation: fadeIn 0.6s ease; animation: fadeIn 0.6s ease;
} }
.novo-hero { .alt-hero {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
padding: 6rem 2rem; padding: 6rem 2rem;
text-align: center; text-align: center;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
&.novo-hero-compact { &.alt-hero-compact {
padding: 2.5rem 2rem; padding: 2.5rem 2rem;
.novo-hero-title { .alt-hero-title {
font-size: 2.2rem; font-size: 2.2rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.novo-hero-subtitle { .alt-hero-subtitle {
font-size: 1.1rem; font-size: 1.1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -69,14 +69,14 @@
} }
} }
.novo-hero-content { .alt-hero-content {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
.novo-hero-title { .alt-hero-title {
font-size: 3.5rem; font-size: 3.5rem;
font-weight: 700; font-weight: 700;
color: white; color: white;
@@ -85,14 +85,14 @@
animation: slideDown 0.8s ease; animation: slideDown 0.8s ease;
} }
.novo-hero-subtitle { .alt-hero-subtitle {
font-size: 1.5rem; font-size: 1.5rem;
color: rgba(255, 255, 255, 0.95); color: rgba(255, 255, 255, 0.95);
margin: 0 0 2.5rem 0; margin: 0 0 2.5rem 0;
animation: slideDown 0.8s ease 0.1s both; animation: slideDown 0.8s ease 0.1s both;
} }
.novo-hero-btn { .alt-hero-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -121,12 +121,12 @@
} }
} }
.novo-loading { .alt-loading {
text-align: center; text-align: center;
padding: 5rem 2rem; padding: 5rem 2rem;
} }
.novo-spinner { .alt-spinner {
width: 60px; width: 60px;
height: 60px; height: 60px;
border: 5px solid var(--bg-secondary); border: 5px solid var(--bg-secondary);
@@ -136,11 +136,11 @@
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
} }
.novo-error { .alt-error {
text-align: center; text-align: center;
padding: 5rem 2rem; padding: 5rem 2rem;
.novo-error-icon { .alt-error-icon {
font-size: 4rem; font-size: 4rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
@@ -156,7 +156,7 @@
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
} }
.novo-retry-btn { .alt-retry-btn {
padding: 0.875rem 2rem; padding: 0.875rem 2rem;
background: var(--primary-color); background: var(--primary-color);
color: white; color: white;
@@ -174,13 +174,13 @@
} }
} }
.novo-categories { .alt-categories {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 5rem 2rem; padding: 5rem 2rem;
} }
.novo-section-header { .alt-section-header {
text-align: center; text-align: center;
margin-bottom: 4rem; margin-bottom: 4rem;
@@ -198,13 +198,13 @@
} }
} }
.novo-empty { .alt-empty {
text-align: center; text-align: center;
padding: 5rem 2rem; padding: 5rem 2rem;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-xl); border-radius: var(--radius-xl);
.novo-empty-icon { .alt-empty-icon {
font-size: 5rem; font-size: 5rem;
opacity: 0.5; opacity: 0.5;
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -223,13 +223,13 @@
} }
} }
.novo-categories-grid { .alt-categories-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem; gap: 2rem;
} }
.novo-category-card { .alt-category-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: white; background: white;
@@ -245,23 +245,23 @@
box-shadow: var(--shadow-lg); box-shadow: var(--shadow-lg);
border-color: var(--primary-color); border-color: var(--primary-color);
.novo-category-image { .alt-category-image {
img { img {
transform: scale(1.1); transform: scale(1.1);
} }
.novo-category-placeholder { .alt-category-placeholder {
transform: scale(1.1); transform: scale(1.1);
} }
} }
.novo-category-arrow { .alt-category-arrow {
transform: translateX(8px); transform: translateX(8px);
} }
} }
} }
.novo-category-image { .alt-category-image {
height: 200px; height: 200px;
overflow: hidden; overflow: hidden;
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -277,7 +277,7 @@
transition: transform 0.4s; transition: transform 0.4s;
} }
.novo-category-placeholder { .alt-category-placeholder {
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
@@ -295,7 +295,7 @@
} }
} }
.novo-category-info { .alt-category-info {
padding: 1.5rem; padding: 1.5rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -308,7 +308,7 @@
margin: 0; margin: 0;
} }
.novo-category-count { .alt-category-count {
font-weight: 600; font-weight: 600;
font-size: clamp(11px, 1vw, 13px); font-size: clamp(11px, 1vw, 13px);
color: var(--text-secondary); color: var(--text-secondary);
@@ -318,47 +318,47 @@
} }
@media (max-width: 968px) { @media (max-width: 968px) {
.novo-hero { .alt-hero {
padding: 4rem 2rem; padding: 4rem 2rem;
} }
.novo-hero-title { .alt-hero-title {
font-size: 2.5rem; font-size: 2.5rem;
} }
.novo-hero-subtitle { .alt-hero-subtitle {
font-size: 1.2rem; font-size: 1.2rem;
} }
.novo-categories-grid { .alt-categories-grid {
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1.5rem; gap: 1.5rem;
} }
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.novo-hero { .alt-hero {
padding: 3rem 1rem; padding: 3rem 1rem;
} }
.novo-hero-title { .alt-hero-title {
font-size: 2rem; font-size: 2rem;
} }
.novo-hero-subtitle { .alt-hero-subtitle {
font-size: 1.1rem; font-size: 1.1rem;
} }
.novo-hero-btn { .alt-hero-btn {
padding: 0.875rem 2rem; padding: 0.875rem 2rem;
font-size: 1rem; font-size: 1rem;
} }
.novo-categories { .alt-categories {
padding: 3rem 1rem; padding: 3rem 1rem;
} }
.novo-section-header { .alt-section-header {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
h2 { h2 {
@@ -370,24 +370,24 @@
} }
} }
.novo-categories-grid { .alt-categories-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1.25rem; gap: 1.25rem;
} }
.novo-category-card:hover { .alt-category-card:hover {
transform: translateY(-4px); transform: translateY(-4px);
} }
} }
// ========== DEXAR REDESIGN 2026 STYLES ========== // ========== platform REDESIGN 2026 STYLES ==========
.dexar-home { .platform-home {
width: 100%; width: 100%;
overflow-x: hidden; overflow-x: hidden;
} }
// Hero Section // Hero Section
.dexar-hero { .platform-hero {
position: relative; position: relative;
width: 100%; width: 100%;
height: 600px; height: 600px;
@@ -403,7 +403,7 @@
} }
} }
.dexar-hero-overlay { .platform-hero-overlay {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -417,7 +417,7 @@
padding: 0 56px; padding: 0 56px;
} }
.dexar-hero-content { .platform-hero-content {
max-width: 600px; max-width: 600px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -425,7 +425,7 @@
animation: fadeInUp 0.8s ease-out; animation: fadeInUp 0.8s ease-out;
} }
.dexar-hero-title { .platform-hero-title {
font-weight: 500; font-weight: 500;
font-size: 42px; font-size: 42px;
color: var(--text-primary); color: var(--text-primary);
@@ -434,7 +434,7 @@
animation: fadeInUp 0.8s ease-out 0.1s both; animation: fadeInUp 0.8s ease-out 0.1s both;
} }
.dexar-hero-subtitle { .platform-hero-subtitle {
font-weight: 500; font-weight: 500;
font-size: 24px; font-size: 24px;
color: var(--text-primary); color: var(--text-primary);
@@ -443,7 +443,7 @@
animation: fadeInUp 0.8s ease-out 0.2s both; animation: fadeInUp 0.8s ease-out 0.2s both;
} }
.dexar-hero-tagline { .platform-hero-tagline {
font-weight: 500; font-weight: 500;
font-size: 24px; font-size: 24px;
color: var(--text-primary); color: var(--text-primary);
@@ -452,14 +452,14 @@
animation: fadeInUp 0.8s ease-out 0.3s both; animation: fadeInUp 0.8s ease-out 0.3s both;
} }
.dexar-hero-actions { .platform-hero-actions {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-top: 12px; margin-top: 12px;
animation: fadeInUp 0.8s ease-out 0.4s both; animation: fadeInUp 0.8s ease-out 0.4s both;
} }
.dexar-btn-primary { .platform-btn-primary {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -488,7 +488,7 @@
} }
} }
.dexar-btn-secondary { .platform-btn-secondary {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -529,15 +529,15 @@
} }
// Loading & Error States // Loading & Error States
.dexar-loading, .platform-loading,
.dexar-error { .platform-error {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
} }
.dexar-spinner { .platform-spinner {
width: 50px; width: 50px;
height: 50px; height: 50px;
border: 4px solid #f3f3f3; border: 4px solid #f3f3f3;
@@ -547,7 +547,7 @@
margin: 0 auto 20px; margin: 0 auto 20px;
} }
.dexar-error { .platform-error {
button { button {
margin-top: 20px; margin-top: 20px;
padding: 12px 28px; padding: 12px 28px;
@@ -569,27 +569,27 @@
} }
// Categories Section // Categories Section
.dexar-categories { .platform-categories {
max-width: 1440px; max-width: 1440px;
margin: 50px auto; margin: 50px auto;
padding: 0 56px; padding: 0 56px;
} }
.dexar-categories-title { .platform-categories-title {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 600; font-weight: 600;
margin-bottom: 40px; margin-bottom: 40px;
color: var(--text-primary); color: var(--text-primary);
} }
.dexar-empty-categories { .platform-empty-categories {
text-align: center; text-align: center;
padding: 80px 20px; padding: 80px 20px;
background: white; background: white;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
.dexar-empty-icon { .platform-empty-icon {
font-size: 4rem; font-size: 4rem;
margin-bottom: 20px; margin-bottom: 20px;
opacity: 0.5; opacity: 0.5;
@@ -609,7 +609,7 @@
} }
} }
.dexar-categories-grid { .platform-categories-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
gap: 30px; gap: 30px;
@@ -617,7 +617,7 @@
width: 100%; width: 100%;
} }
.dexar-category-card { .platform-category-card {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -627,17 +627,17 @@
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
.dexar-category-image { .platform-category-image {
box-shadow: 0 6px 8px 0 rgba(0, 0, 0, 0.2); box-shadow: 0 6px 8px 0 rgba(0, 0, 0, 0.2);
} }
.dexar-category-info { .platform-category-info {
box-shadow: 0 6px 8px 0 rgba(0, 0, 0, 0.2); box-shadow: 0 6px 8px 0 rgba(0, 0, 0, 0.2);
} }
} }
} }
.dexar-category-image { .platform-category-image {
width: 100%; width: 100%;
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -662,7 +662,7 @@
} }
} }
.dexar-category-fallback { .platform-category-fallback {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@@ -674,7 +674,7 @@
background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%);
} }
.dexar-category-info { .platform-category-info {
width: 100%; width: 100%;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-top: none; border-top: none;
@@ -689,7 +689,7 @@
transition: background 0.3s ease; transition: background 0.3s ease;
} }
.dexar-category-name { .platform-category-name {
font-weight: 600; font-weight: 600;
font-size: clamp(14px, 1.4vw, 18px); font-size: clamp(14px, 1.4vw, 18px);
color: var(--text-primary); color: var(--text-primary);
@@ -704,7 +704,7 @@
min-height: calc(2 * 1.3em); min-height: calc(2 * 1.3em);
} }
.dexar-category-count { .platform-category-count {
font-weight: 600; font-weight: 600;
font-size: clamp(11px, 1vw, 13px); font-size: clamp(11px, 1vw, 13px);
color: var(--text-secondary); color: var(--text-secondary);
@@ -713,10 +713,10 @@
} }
// Wide category card (spans 2 columns) // Wide category card (spans 2 columns)
.dexar-category-card--wide { .platform-category-card--wide {
grid-column: span 2; grid-column: span 2;
.dexar-category-image { .platform-category-image {
aspect-ratio: 8 / 3; aspect-ratio: 8 / 3;
img { img {
@@ -727,82 +727,82 @@
// Responsive Design // Responsive Design
@media (max-width: 1200px) { @media (max-width: 1200px) {
.dexar-hero { .platform-hero {
height: 380px; height: 380px;
} }
.dexar-hero-overlay { .platform-hero-overlay {
padding: 0 40px; padding: 0 40px;
} }
.dexar-hero-title { .platform-hero-title {
font-size: 38px; font-size: 38px;
} }
.dexar-hero-subtitle, .platform-hero-subtitle,
.dexar-hero-tagline { .platform-hero-tagline {
font-size: 22px; font-size: 22px;
} }
.dexar-categories { .platform-categories {
padding: 0 40px; padding: 0 40px;
} }
.dexar-categories-grid { .platform-categories-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 24px; gap: 24px;
} }
} }
@media (max-width: 992px) { @media (max-width: 992px) {
.dexar-hero { .platform-hero {
height: 340px; height: 340px;
} }
.dexar-hero-overlay { .platform-hero-overlay {
padding: 0 32px; padding: 0 32px;
} }
.dexar-hero-title { .platform-hero-title {
font-size: 34px; font-size: 34px;
} }
.dexar-hero-subtitle, .platform-hero-subtitle,
.dexar-hero-tagline { .platform-hero-tagline {
font-size: 20px; font-size: 20px;
} }
.dexar-btn-primary, .platform-btn-primary,
.dexar-btn-secondary { .platform-btn-secondary {
height: 44px; height: 44px;
font-size: 18px; font-size: 18px;
} }
.dexar-btn-primary { .platform-btn-primary {
width: 240px; width: 240px;
} }
.dexar-btn-secondary { .platform-btn-secondary {
width: 200px; width: 200px;
} }
.dexar-categories { .platform-categories {
padding: 0 32px; padding: 0 32px;
} }
.dexar-categories-grid { .platform-categories-grid {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 20px; gap: 20px;
} }
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.dexar-hero { .platform-hero {
height: 320px; height: 320px;
background-position: right center; background-position: right center;
} }
.dexar-hero-overlay { .platform-hero-overlay {
padding: 0 20px; padding: 0 20px;
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
@@ -811,23 +811,23 @@
); );
} }
.dexar-hero-title { .platform-hero-title {
font-size: 28px; font-size: 28px;
} }
.dexar-hero-subtitle, .platform-hero-subtitle,
.dexar-hero-tagline { .platform-hero-tagline {
font-size: 17px; font-size: 17px;
} }
.dexar-hero-actions { .platform-hero-actions {
flex-direction: row; flex-direction: row;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
} }
.dexar-btn-primary, .platform-btn-primary,
.dexar-btn-secondary { .platform-btn-secondary {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: 180px; max-width: 180px;
@@ -836,65 +836,65 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.dexar-categories { .platform-categories {
margin: 40px auto; margin: 40px auto;
padding: 0 20px; padding: 0 20px;
} }
.dexar-categories-title { .platform-categories-title {
font-size: 2rem; font-size: 2rem;
margin-bottom: 30px; margin-bottom: 30px;
} }
.dexar-categories-grid { .platform-categories-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 16px; gap: 16px;
} }
.dexar-category-info { .platform-category-info {
padding: 10px 12px; padding: 10px 12px;
} }
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.dexar-hero { .platform-hero {
height: 280px; height: 280px;
} }
.dexar-hero-title { .platform-hero-title {
font-size: 24px; font-size: 24px;
} }
.dexar-hero-subtitle, .platform-hero-subtitle,
.dexar-hero-tagline { .platform-hero-tagline {
font-size: 15px; font-size: 15px;
} }
.dexar-hero-actions { .platform-hero-actions {
gap: 10px; gap: 10px;
} }
.dexar-btn-primary, .platform-btn-primary,
.dexar-btn-secondary { .platform-btn-secondary {
height: 38px; height: 38px;
font-size: 13px; font-size: 13px;
max-width: 160px; max-width: 160px;
} }
.dexar-categories { .platform-categories {
padding: 0 16px; padding: 0 16px;
} }
.dexar-categories-grid { .platform-categories-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 12px; gap: 12px;
} }
.dexar-category-info { .platform-category-info {
padding: 8px 10px; padding: 8px 10px;
} }
.dexar-category-card:hover { .platform-category-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
} }
@@ -921,3 +921,4 @@
0% { background-position: 200% 0; } 0% { background-position: 200% 0; }
100% { background-position: -200% 0; } 100% { background-position: -200% 0; }
} }

View File

@@ -3,11 +3,12 @@ import { Router } from '@angular/router';
import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component'; import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component';
import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model';
import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade'; import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-home', selector: 'app-home',
standalone: true, standalone: true,
imports: [DynamicPageLayoutComponent], imports: [DynamicPageLayoutComponent, TranslatePipe],
templateUrl: './home.component.html', templateUrl: './home.component.html',
styleUrls: ['./home.component.scss'], styleUrls: ['./home.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush

View File

@@ -1,6 +1,6 @@
@use 'sass:color'; @use 'sass:color';
// ========== DEXAR ITEM DETAIL - Redesigned 2026 ========== // ========== platform ITEM DETAIL - Redesigned 2026 ==========
$dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; $dx-font: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
$dx-dark: #1e3c38; $dx-dark: #1e3c38;
$dx-primary: #497671; $dx-primary: #497671;
@@ -310,8 +310,8 @@ $dx-card-bg: #f5f3f9;
} }
} }
// Variant chips (colour/size) — shared between dexar and novo // Variant chips (colour/size) — shared between platform and alt
.dx-variants, .novo-variants { .dx-variants, .alt-variants {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@@ -374,7 +374,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.dx-attributes, .novo-attributes { .dx-attributes, .alt-attributes {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px 16px; gap: 6px 16px;
@@ -400,7 +400,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.dx-delivery, .novo-delivery { .dx-delivery, .alt-delivery {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
@@ -410,20 +410,20 @@ $dx-card-bg: #f5f3f9;
background: #f8fafa; background: #f8fafa;
} }
.dx-delivery-title, .novo-delivery-title { .dx-delivery-title, .alt-delivery-title {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
color: $dx-dark; color: $dx-dark;
} }
.dx-delivery-list, .novo-delivery-list { .dx-delivery-list, .alt-delivery-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.dx-delivery-option, .novo-delivery-option { .dx-delivery-option, .alt-delivery-option {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@@ -433,7 +433,7 @@ $dx-card-bg: #f5f3f9;
background: white; background: white;
} }
.dx-delivery-option-main, .novo-delivery-option-main { .dx-delivery-option-main, .alt-delivery-option-main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -441,14 +441,14 @@ $dx-card-bg: #f5f3f9;
font-size: 0.9rem; font-size: 0.9rem;
} }
.dx-delivery-price, .novo-delivery-price { .dx-delivery-price, .alt-delivery-price {
flex-shrink: 0; flex-shrink: 0;
font-weight: 700; font-weight: 700;
color: $dx-primary; color: $dx-primary;
font-size: 0.95rem; font-size: 0.95rem;
} }
.dx-delivery-chip, .novo-delivery-chip { .dx-delivery-chip, .alt-delivery-chip {
display: inline-flex; display: inline-flex;
align-self: flex-start; align-self: flex-start;
padding: 6px 12px; padding: 6px 12px;
@@ -810,9 +810,9 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== DEXAR RESPONSIVE ========== // ========== platform RESPONSIVE ==========
// Large desktop — constrain gallery height // Large desktop — constrain gallery height
@media (min-width: 1201px) { @media (min-width: 1201px) {
.dx-main-photo { .dx-main-photo {
max-height: 560px; max-height: 560px;
@@ -1099,20 +1099,20 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== novo ITEM DETAIL STYLES ========== // ========== alt ITEM DETAIL STYLES ==========
.novo-item-container { .alt-item-container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
padding: 2rem; padding: 2rem;
} }
.novo-loading, .alt-loading,
.novo-error { .alt-error {
text-align: center; text-align: center;
padding: 4rem 1rem; padding: 4rem 1rem;
} }
.novo-spinner { .alt-spinner {
width: 48px; width: 48px;
height: 48px; height: 48px;
border: 3px solid var(--border-color); border: 3px solid var(--border-color);
@@ -1122,7 +1122,7 @@ $dx-card-bg: #f5f3f9;
margin: 0 auto 1.5rem; margin: 0 auto 1.5rem;
} }
.novo-back-link { .alt-back-link {
display: inline-block; display: inline-block;
margin-top: 1.5rem; margin-top: 1.5rem;
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
@@ -1140,15 +1140,15 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-item-content { .alt-item-content {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 3rem; gap: 3rem;
margin-bottom: 3rem; margin-bottom: 3rem;
} }
.novo-gallery { .alt-gallery {
.novo-main-photo { .alt-main-photo {
width: 100%; width: 100%;
aspect-ratio: 1; aspect-ratio: 1;
background: var(--bg-secondary); background: var(--bg-secondary);
@@ -1165,7 +1165,7 @@ $dx-card-bg: #f5f3f9;
background: white; background: white;
} }
&.novo-no-image { &.alt-no-image {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -1183,13 +1183,13 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-thumbnails { .alt-thumbnails {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 0.75rem; gap: 0.75rem;
} }
.novo-thumb { .alt-thumb {
aspect-ratio: 1; aspect-ratio: 1;
background: var(--bg-secondary); background: var(--bg-secondary);
border-radius: var(--radius-md); border-radius: var(--radius-md);
@@ -1235,8 +1235,8 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-info { .alt-info {
.novo-title { .alt-title {
font-size: 2rem; font-size: 2rem;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
@@ -1244,7 +1244,7 @@ $dx-card-bg: #f5f3f9;
line-height: 1.3; line-height: 1.3;
} }
.novo-rating { .alt-rating {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -1266,7 +1266,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-price-block { .alt-price-block {
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 1.5rem; padding: 1.5rem;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -1302,7 +1302,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-stock { .alt-stock {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
@@ -1347,7 +1347,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-add-cart { .alt-add-cart {
width: 100%; width: 100%;
padding: 1.25rem 2rem; padding: 1.25rem 2rem;
background: var(--primary-color); background: var(--primary-color);
@@ -1375,7 +1375,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-description { .alt-description {
h3 { h3 {
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 600; font-weight: 600;
@@ -1392,7 +1392,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-reviews { .alt-reviews {
margin-top: 3rem; margin-top: 3rem;
padding-top: 3rem; padding-top: 3rem;
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
@@ -1404,12 +1404,12 @@ $dx-card-bg: #f5f3f9;
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
} }
.novo-reviews-list { .alt-reviews-list {
display: grid; display: grid;
gap: 1.5rem; gap: 1.5rem;
} }
.novo-review-card { .alt-review-card {
background: white; background: white;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -1461,7 +1461,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-no-reviews { .alt-no-reviews {
text-align: center; text-align: center;
padding: 3rem 2rem; padding: 3rem 2rem;
color: var(--text-secondary); color: var(--text-secondary);
@@ -1471,8 +1471,8 @@ $dx-card-bg: #f5f3f9;
} }
} }
/* novo Review Form */ /* alt Review Form */
.novo-review-form { .alt-review-form {
background: var(--bg-secondary); background: var(--bg-secondary);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -1486,7 +1486,7 @@ $dx-card-bg: #f5f3f9;
margin: 0 0 1.5rem 0; margin: 0 0 1.5rem 0;
} }
.novo-rating-input { .alt-rating-input {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
label { label {
@@ -1497,11 +1497,11 @@ $dx-card-bg: #f5f3f9;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.novo-star-selector { .alt-star-selector {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
.novo-star { .alt-star {
font-size: 2rem; font-size: 2rem;
color: #d1d5db; color: #d1d5db;
cursor: pointer; cursor: pointer;
@@ -1520,7 +1520,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-textarea { .alt-textarea {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
@@ -1545,14 +1545,14 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-form-actions { .alt-form-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
.novo-anonymous-toggle { .alt-anonymous-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -1571,13 +1571,13 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-username-preview { .alt-username-preview {
font-size: 0.875rem; font-size: 0.875rem;
color: var(--text-secondary); color: var(--text-secondary);
font-style: italic; font-style: italic;
} }
.novo-submit-review-btn { .alt-submit-review-btn {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -1612,7 +1612,7 @@ $dx-card-bg: #f5f3f9;
pointer-events: none; pointer-events: none;
} }
.novo-spinner-small { .alt-spinner-small {
width: 16px; width: 16px;
height: 16px; height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3); border: 2px solid rgba(255, 255, 255, 0.3);
@@ -1623,7 +1623,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-status-message { .alt-status-message {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@@ -1673,40 +1673,40 @@ $dx-card-bg: #f5f3f9;
} }
} }
// ========== NOVO RESPONSIVE ========== // ========== alt RESPONSIVE ==========
// Tablet portrait // Tablet portrait
@media (max-width: 968px) { @media (max-width: 968px) {
.novo-item-content { .alt-item-content {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 2rem; gap: 2rem;
} }
.novo-gallery { .alt-gallery {
max-width: 600px; max-width: 600px;
margin: 0 auto; margin: 0 auto;
} }
.novo-info .novo-title { .alt-info .alt-title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.novo-info .novo-price-block .current-price { .alt-info .alt-price-block .current-price {
font-size: 2rem; font-size: 2rem;
} }
.novo-info .novo-add-cart { .alt-info .alt-add-cart {
max-width: 100%; max-width: 100%;
} }
.novo-review-form { .alt-review-form {
padding: 1.5rem; padding: 1.5rem;
.novo-form-actions { .alt-form-actions {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
.novo-submit-review-btn { .alt-submit-review-btn {
margin-left: 0; margin-left: 0;
justify-content: center; justify-content: center;
} }
@@ -1716,41 +1716,41 @@ $dx-card-bg: #f5f3f9;
// Mobile // Mobile
@media (max-width: 768px) { @media (max-width: 768px) {
.novo-item-container { .alt-item-container {
padding: 1rem; padding: 1rem;
} }
.novo-item-content { .alt-item-content {
gap: 1.5rem; gap: 1.5rem;
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.novo-gallery { .alt-gallery {
max-width: 100%; max-width: 100%;
.novo-main-photo { .alt-main-photo {
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
.novo-thumbnails { .alt-thumbnails {
grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
gap: 0.5rem; gap: 0.5rem;
} }
} }
.novo-info { .alt-info {
.novo-title { .alt-title {
font-size: 1.35rem; font-size: 1.35rem;
} }
.novo-rating { .alt-rating {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.novo-price-block { .alt-price-block {
padding: 1rem; padding: 1rem;
.current-price { .current-price {
@@ -1762,26 +1762,26 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-stock { .alt-stock {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem; padding: 0.75rem;
} }
.novo-add-cart { .alt-add-cart {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
font-size: 1rem; font-size: 1rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.novo-description { .alt-description {
h3 { h3 {
font-size: 1.1rem; font-size: 1.1rem;
} }
} }
} }
.novo-specs-table { .alt-specs-table {
.spec-key { .spec-key {
white-space: normal; white-space: normal;
width: auto; width: auto;
@@ -1793,7 +1793,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-reviews { .alt-reviews {
margin-top: 2rem; margin-top: 2rem;
padding-top: 2rem; padding-top: 2rem;
@@ -1803,11 +1803,11 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-review-card { .alt-review-card {
padding: 1rem; padding: 1rem;
} }
.novo-review-form { .alt-review-form {
padding: 1.25rem; padding: 1.25rem;
h3 { h3 {
@@ -1818,32 +1818,32 @@ $dx-card-bg: #f5f3f9;
// Small mobile // Small mobile
@media (max-width: 480px) { @media (max-width: 480px) {
.novo-item-container { .alt-item-container {
padding: 0.75rem; padding: 0.75rem;
} }
.novo-item-content { .alt-item-content {
gap: 1.25rem; gap: 1.25rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
} }
.novo-gallery { .alt-gallery {
.novo-thumbnails { .alt-thumbnails {
grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
gap: 0.4rem; gap: 0.4rem;
} }
.novo-main-photo { .alt-main-photo {
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
} }
.novo-info { .alt-info {
.novo-title { .alt-title {
font-size: 1.15rem; font-size: 1.15rem;
} }
.novo-price-block { .alt-price-block {
padding: 0.75rem; padding: 0.75rem;
.current-price { .current-price {
@@ -1851,36 +1851,36 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-stock { .alt-stock {
padding: 0.6rem; padding: 0.6rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.novo-add-cart { .alt-add-cart {
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
font-size: 0.95rem; font-size: 0.95rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);
} }
} }
.novo-review-form { .alt-review-form {
padding: 1rem; padding: 1rem;
.novo-rating-input { .alt-rating-input {
.novo-star-selector { .alt-star-selector {
.novo-star { .alt-star {
font-size: 1.6rem; font-size: 1.6rem;
} }
} }
} }
.novo-textarea { .alt-textarea {
padding: 0.75rem; padding: 0.75rem;
font-size: 0.9rem; font-size: 0.9rem;
} }
} }
.novo-review-card { .alt-review-card {
padding: 0.75rem; padding: 0.75rem;
.review-header { .review-header {
@@ -1897,7 +1897,7 @@ $dx-card-bg: #f5f3f9;
} }
} }
.novo-specs-table { .alt-specs-table {
td { td {
padding: 6px 8px; padding: 6px 8px;
font-size: 0.8rem; font-size: 0.8rem;
@@ -1924,7 +1924,7 @@ $dx-card-bg: #f5f3f9;
// ========== BADGES, TAGS & SPECS (shared) ========== // ========== BADGES, TAGS & SPECS (shared) ==========
// Badges // Badges
.novo-badges, .dx-badges { .alt-badges, .dx-badges {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
@@ -1952,7 +1952,7 @@ $dx-card-bg: #f5f3f9;
} }
// Tags // Tags
.novo-tags, .dx-tags { .alt-tags, .dx-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; gap: 6px;
@@ -1970,7 +1970,7 @@ $dx-card-bg: #f5f3f9;
} }
// Specs table // Specs table
.novo-specs-table, .dx-specs-table { .alt-specs-table, .dx-specs-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
margin: 12px 0; margin: 12px 0;
@@ -1999,7 +1999,7 @@ $dx-card-bg: #f5f3f9;
} }
// Simple description // Simple description
.novo-simple-desc, .dx-simple-desc { .alt-simple-desc, .dx-simple-desc {
font-size: 0.95rem; font-size: 0.95rem;
color: #697777; color: #697777;
line-height: 1.6; line-height: 1.6;
@@ -2012,3 +2012,4 @@ $dx-card-bg: #f5f3f9;
color: #697777; color: #697777;
margin-left: 8px; margin-left: 8px;
} }

View File

@@ -3,21 +3,22 @@ import { Router } from '@angular/router';
import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component'; import { DynamicPageLayoutComponent } from '../../layouts/containers/dynamic-page-layout.component';
import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model'; import { PageRenderModel } from '../../dynamic-renderer/page-renderer/page-renderer.model';
import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade'; import { WebsiteRuntimeFacade } from '../../facades/website/website-runtime.facade';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-platform-home', selector: 'app-platform-home',
standalone: true, standalone: true,
imports: [DynamicPageLayoutComponent], imports: [DynamicPageLayoutComponent, TranslatePipe],
template: ` template: `
@if (loading()) { @if (loading()) {
<section class="platform-loading section"> <section class="platform-loading section">
<div class="page-container">Loading platform page...</div> <div class="page-container">{{ 'platform.loadingPage' | translate }}</div>
</section> </section>
} @else if (model()) { } @else if (model()) {
<app-dynamic-page-layout [model]="model()" /> <app-dynamic-page-layout [model]="model()" />
} @else { } @else {
<section class="platform-empty section"> <section class="platform-empty section">
<div class="page-container">No page configuration found.</div> <div class="page-container">{{ 'platform.emptyConfig' | translate }}</div>
</section> </section>
} }
`, `,

View File

@@ -4,6 +4,19 @@ export interface WidgetActionConfig {
payload?: Record<string, unknown>; payload?: Record<string, unknown>;
} }
export interface WidgetAnimationConfig {
name?: string;
duration?: string;
delay?: string;
timingFunction?: string;
}
export interface WidgetPermissionsConfig {
requireAuthenticated?: boolean;
roles?: string[];
permissions?: string[];
}
export interface WidgetVisibilityConfig { export interface WidgetVisibilityConfig {
desktop?: boolean; desktop?: boolean;
tablet?: boolean; tablet?: boolean;
@@ -14,9 +27,14 @@ export interface WidgetConfig {
id: string; id: string;
type: string; type: string;
version: string; version: string;
title?: string;
subtitle?: string;
order?: number; order?: number;
padding?: string; padding?: string;
visibility?: WidgetVisibilityConfig; visibility?: WidgetVisibilityConfig;
animation?: WidgetAnimationConfig;
style?: Record<string, string>;
permissions?: WidgetPermissionsConfig;
props: Record<string, unknown>; props: Record<string, unknown>;
actions?: Record<string, WidgetActionConfig>; actions?: Record<string, WidgetActionConfig>;
featureFlag?: string; featureFlag?: string;

View File

@@ -3,6 +3,18 @@ import { ThemeCssVariables } from '../tokens/theme-css-variable.model';
import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map'; import { THEME_VARIABLE_MAP } from '../tokens/theme-variable-map';
export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables { export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariables {
const spacingScale = Array.isArray(theme.spacing.scale) && theme.spacing.scale.length > 0
? theme.spacing.scale
: [0.25, 0.5, 1, 1.5, 2];
const spacingValues = spacingScale
.slice(0, 5)
.map(multiplier => `${Math.max(0, Number(multiplier) || 0) * theme.spacing.unit}px`);
while (spacingValues.length < 5) {
spacingValues.push(`${theme.spacing.unit}px`);
}
const vars: ThemeCssVariables = { const vars: ThemeCssVariables = {
[THEME_VARIABLE_MAP.primary]: theme.palette.primary, [THEME_VARIABLE_MAP.primary]: theme.palette.primary,
[THEME_VARIABLE_MAP.secondary]: theme.palette.secondary, [THEME_VARIABLE_MAP.secondary]: theme.palette.secondary,
@@ -16,6 +28,14 @@ export function mapThemeConfigToCssVariables(theme: ThemeConfig): ThemeCssVariab
[THEME_VARIABLE_MAP.backgroundPrimary]: theme.palette.backgroundPrimary, [THEME_VARIABLE_MAP.backgroundPrimary]: theme.palette.backgroundPrimary,
[THEME_VARIABLE_MAP.backgroundSecondary]: theme.palette.backgroundSecondary, [THEME_VARIABLE_MAP.backgroundSecondary]: theme.palette.backgroundSecondary,
[THEME_VARIABLE_MAP.border]: theme.palette.border, [THEME_VARIABLE_MAP.border]: theme.palette.border,
[THEME_VARIABLE_MAP.spaceXs]: spacingValues[0],
[THEME_VARIABLE_MAP.spaceSm]: spacingValues[1],
[THEME_VARIABLE_MAP.spaceMd]: spacingValues[2],
[THEME_VARIABLE_MAP.spaceLg]: spacingValues[3],
[THEME_VARIABLE_MAP.spaceXl]: spacingValues[4],
[THEME_VARIABLE_MAP.transitionFast]: '120ms ease',
[THEME_VARIABLE_MAP.transitionNormal]: '180ms ease',
[THEME_VARIABLE_MAP.transitionSlow]: '300ms ease',
'--app-font-family': theme.typography.primaryFontFamily, '--app-font-family': theme.typography.primaryFontFamily,
'--app-font-size': `${theme.typography.baseFontSize}px`, '--app-font-size': `${theme.typography.baseFontSize}px`,
'--app-spacing-unit': `${theme.spacing.unit}px` '--app-spacing-unit': `${theme.spacing.unit}px`

View File

@@ -10,5 +10,13 @@ export const THEME_VARIABLE_MAP = {
textSecondary: '--text-secondary', textSecondary: '--text-secondary',
backgroundPrimary: '--bg-primary', backgroundPrimary: '--bg-primary',
backgroundSecondary: '--bg-secondary', backgroundSecondary: '--bg-secondary',
border: '--border-color' border: '--border-color',
spaceXs: '--space-xs',
spaceSm: '--space-sm',
spaceMd: '--space-md',
spaceLg: '--space-lg',
spaceXl: '--space-xl',
transitionFast: '--transition-fast',
transitionNormal: '--transition-normal',
transitionSlow: '--transition-slow'
} as const; } as const;

View File

@@ -8,6 +8,16 @@ export interface WidgetSettingsSchema {
required?: string[]; required?: string[];
} }
export interface WidgetMetadataSupport {
title: boolean;
subtitle: boolean;
visibility: boolean;
layout: boolean;
animation: boolean;
style: boolean;
permissions: boolean;
}
export interface WidgetManifestEntry { export interface WidgetManifestEntry {
type: string; type: string;
version: string; version: string;
@@ -16,6 +26,7 @@ export interface WidgetManifestEntry {
supportedDataSources: WidgetDataSourceName[]; supportedDataSources: WidgetDataSourceName[];
settingsSchema: WidgetSettingsSchema; settingsSchema: WidgetSettingsSchema;
defaultSettings: Record<string, unknown>; defaultSettings: Record<string, unknown>;
metadataSupport?: WidgetMetadataSupport;
enabled?: boolean; enabled?: boolean;
} }

View File

@@ -50,8 +50,31 @@ export class DataSourceResolverService {
} }
private resolveSettings(definition: WidgetManifestEntry | undefined, widget: WidgetConfig): Record<string, unknown> { private resolveSettings(definition: WidgetManifestEntry | undefined, widget: WidgetConfig): Record<string, unknown> {
const metadataSettings: Record<string, unknown> = {};
if (widget.title != null) {
metadataSettings['title'] = widget.title;
}
if (widget.subtitle != null) {
metadataSettings['subtitle'] = widget.subtitle;
}
if (widget.animation != null) {
metadataSettings['animation'] = widget.animation;
}
if (widget.style != null) {
metadataSettings['style'] = widget.style;
}
if (widget.permissions != null) {
metadataSettings['permissions'] = widget.permissions;
}
return { return {
...(definition?.defaultSettings ?? {}), ...(definition?.defaultSettings ?? {}),
...metadataSettings,
...(widget.props ?? {}) ...(widget.props ?? {})
}; };
} }

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, inject } from '@angular/core';
import { SectionConfig } from '../../shared/models/config'; import { SectionConfig } from '../../shared/models/config';
import { RecentlyViewedStripComponent } from '../../features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component'; import { RecentlyViewedStripComponent } from '../../features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component';
import { TranslateService } from '../../i18n/translate.service';
@Component({ @Component({
selector: 'app-recently-viewed-widget', selector: 'app-recently-viewed-widget',
@@ -16,12 +17,14 @@ import { RecentlyViewedStripComponent } from '../../features/website/user-experi
changeDetection: ChangeDetectionStrategy.OnPush changeDetection: ChangeDetectionStrategy.OnPush
}) })
export class RecentlyViewedWidgetComponent { export class RecentlyViewedWidgetComponent {
private readonly translate = inject(TranslateService);
@Input() section: SectionConfig | null = null; @Input() section: SectionConfig | null = null;
@Input() data: { settings?: Record<string, unknown> } | null = null; @Input() data: { settings?: Record<string, unknown> } | null = null;
get title(): string { get title(): string {
const raw = this.data?.settings?.['title']; const raw = this.data?.settings?.['title'];
return typeof raw === 'string' && raw.trim().length > 0 ? raw : 'Recently Viewed'; return typeof raw === 'string' && raw.trim().length > 0 ? raw : this.translate.t('ux.recentlyViewedTitle');
} }
get maxItems(): number { get maxItems(): number {

View File

@@ -1,11 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { TranslatePipe } from '../../i18n/translate.pipe';
@Component({ @Component({
selector: 'app-unknown-widget', selector: 'app-unknown-widget',
standalone: true, standalone: true,
imports: [TranslatePipe],
template: ` template: `
<section class="unknown-widget" role="status" aria-live="polite"> <section class="unknown-widget" role="status" aria-live="polite">
<strong>Widget unavailable</strong> <strong>{{ 'widgets.unavailable' | translate }}</strong>
<p>{{ widgetType }}@{{ widgetVersion }}</p> <p>{{ widgetType }}@{{ widgetVersion }}</p>
</section> </section>
`, `,

View File

@@ -19,6 +19,7 @@
"subtitle": "Configuration-driven multi-tenant commerce", "subtitle": "Configuration-driven multi-tenant commerce",
"ctaLabel": "Start Shopping" "ctaLabel": "Start Shopping"
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -41,6 +42,7 @@
"source": "root", "source": "root",
"emptyMessage": "No categories available" "emptyMessage": "No categories available"
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -67,6 +69,7 @@
"count": 8, "count": 8,
"actionLabel": "Select" "actionLabel": "Select"
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -88,6 +91,7 @@
"source": "featured", "source": "featured",
"count": 8 "count": 8
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -106,6 +110,7 @@
"defaultSettings": { "defaultSettings": {
"links": [] "links": []
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -123,6 +128,7 @@
"defaultSettings": { "defaultSettings": {
"links": [] "links": []
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -144,6 +150,7 @@
"maxItems": 8, "maxItems": 8,
"showClear": false "showClear": false
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": true "enabled": true
}, },
{ {
@@ -163,6 +170,7 @@
} }
}, },
"defaultSettings": {}, "defaultSettings": {},
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": false "enabled": false
}, },
{ {
@@ -180,6 +188,7 @@
"defaultSettings": { "defaultSettings": {
"html": "" "html": ""
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": false "enabled": false
}, },
{ {
@@ -198,7 +207,9 @@
"defaultSettings": { "defaultSettings": {
"logos": [] "logos": []
}, },
"metadataSupport": {"title": true, "subtitle": true, "visibility": true, "layout": true, "animation": true, "style": true, "permissions": true},
"enabled": false "enabled": false
} }
] ]
} }

View File

@@ -36,11 +36,21 @@
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1); --shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15); --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 12px 32px rgba(73, 118, 113, 0.2); --shadow-lg: 0 12px 32px rgba(73, 118, 113, 0.2);
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--radius-sm: 8px; --radius-sm: 8px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 13px; --radius-lg: 13px;
--radius-xl: 22px; --radius-xl: 22px;
--transition-fast: 120ms ease;
--transition-normal: 180ms ease;
--transition-slow: 300ms ease;
} }
* { * {
@@ -69,7 +79,7 @@ body {
min-height: 100vh; min-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
&.dexar-menu-open { &.platform-menu-open {
overflow: hidden; overflow: hidden;
} }
} }
@@ -93,7 +103,7 @@ canvas {
/* Smooth Transitions */ /* Smooth Transitions */
a, button, input, textarea, select { a, button, input, textarea, select {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all var(--transition-slow);
} }
/* Responsive Typography */ /* Responsive Typography */
@@ -134,7 +144,7 @@ a, button, input, textarea, select {
.page-container { .page-container {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px; padding: var(--space-lg) var(--space-md);
width: 100%; width: 100%;
} }
@@ -142,7 +152,7 @@ a, button, input, textarea, select {
.container { .container {
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 24px 16px; padding: var(--space-lg) var(--space-md);
width: 100%; width: 100%;
} }
@@ -154,7 +164,7 @@ a, button, input, textarea, select {
.grid { .grid {
display: grid; display: grid;
gap: 16px; gap: var(--space-md);
} }
.card { .card {
@@ -162,7 +172,7 @@ a, button, input, textarea, select {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-md); border-radius: var(--radius-md);
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
transition: transform 180ms ease, box-shadow 180ms ease; transition: transform var(--transition-normal), box-shadow var(--transition-normal);
} }
.card:hover { .card:hover {
@@ -173,7 +183,7 @@ a, button, input, textarea, select {
.product-card, .product-card,
.item-card, .item-card,
.catalog-product-card { .catalog-product-card {
transition: transform 180ms ease, box-shadow 180ms ease; transition: transform var(--transition-normal), box-shadow var(--transition-normal);
} }
.product-card:hover, .product-card:hover,
@@ -194,7 +204,7 @@ a, button, input, textarea, select {
line-height: 1.2; line-height: 1.2;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: transform 180ms ease, box-shadow 180ms ease, background-color 180ms ease, color 180ms ease, border-color 180ms ease; transition: transform var(--transition-normal), box-shadow var(--transition-normal), background-color var(--transition-normal), color var(--transition-normal), border-color var(--transition-normal);
} }
.btn:hover { .btn:hover {
@@ -353,3 +363,5 @@ a, button, input, textarea, select {
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }