diff --git a/Sprint-11-User-Experience-Report.md b/Sprint-11-User-Experience-Report.md
new file mode 100644
index 0000000..57e2175
--- /dev/null
+++ b/Sprint-11-User-Experience-Report.md
@@ -0,0 +1,173 @@
+# Sprint 11 - User Experience Module Report
+
+## Scope
+Implemented reusable customer experience capabilities for marketplace storefront UX while preserving established architecture constraints.
+
+Implemented features:
+- Wishlist (guest local storage + architecture-ready repository contract)
+- Compare (configurable max count, reusable comparison table, compare page)
+- Recently Viewed (auto tracking + reusable strip + widget compatibility)
+- Continue Browsing (persist/restore filters, sort, page, scroll)
+- Saved Searches (architecture + UI integration)
+- Product Sharing (Web Share + clipboard fallback)
+- Product Card action preparation without duplication
+- Floating notifications and heart animation
+
+## Architectural Constraints Compliance
+Confirmed constraints:
+- No modifications to authentication, payment, bootstrap loading flow, widget engine core, section engine core.
+- No direct HttpClient usage in UI feature components.
+- Product data path remains facade-driven.
+- Existing ProductFacade contracts preserved (only UX facade added separately).
+- Bootstrap contains feature configuration only; no business/user data payload.
+
+## Implemented Modules and Components
+
+### Core Domain and Repository Contracts
+Added:
+- src/app/core/user-experience/models/user-experience.model.ts
+- src/app/core/user-experience/repositories/user-experience.repository.ts
+- src/app/core/user-experience/repositories/local-user-experience.repository.ts
+- src/app/core/user-experience/user-experience-repository.token.ts
+- src/app/facades/platform/user-experience.facade.ts
+
+Purpose:
+- Create domain entities: FavoriteItem, ComparedProduct, RecentlyViewedItem, SavedSearch, ContinueBrowsingState.
+- Provide repository abstraction for future authenticated backend synchronization.
+- Implement guest mode via localStorage repository.
+
+### Bootstrap Config Extensions
+Added:
+- src/app/shared/models/config/user-experience-config.model.ts
+
+Updated:
+- src/app/shared/models/config/bootstrap-config.model.ts
+- src/app/shared/models/config/index.ts
+- src/assets/mock/bootstrap/bootstrap.json
+
+New bootstrap block:
+- userExperience.wishlist
+- userExperience.compare
+- userExperience.recentlyViewed
+- userExperience.share
+- userExperience.continueBrowsing
+- userExperience.savedSearches
+
+### Wishlist
+Added:
+- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts
+- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html
+- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss
+
+Integrated:
+- Header badge + counter in header component.
+- Route: /wishlist.
+
+### Compare
+Added:
+- 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/compare/components/compare-table.component.scss
+- src/app/features/website/user-experience/compare/containers/compare-page.component.ts
+- src/app/features/website/user-experience/compare/containers/compare-page.component.html
+- src/app/features/website/user-experience/compare/containers/compare-page.component.scss
+
+Integrated:
+- Route: /compare.
+- Configurable max items from bootstrap userExperience.compare.maxItems.
+- Hide-identical and highlight-differences controls.
+- Responsive table wrapper.
+
+### Recently Viewed
+Added:
+- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts
+- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html
+- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss
+- src/app/widgets/ui/recently-viewed-widget.component.ts
+
+Integrated:
+- Auto-tracking in product details container after product load.
+- Widget compatibility via widget registry bootstrap + manifest.
+
+Updated widget integration:
+- src/app/widgets/ui/index.ts
+- src/app/widgets/registry/widget-registry.bootstrap.service.ts
+- src/app/widgets/resolvers/data-source-resolver.service.ts
+- src/assets/mock/bootstrap/widget-manifest.json
+
+### Continue Browsing + Saved Searches
+Integrated in catalog container:
+- Persist selected filters, sort, page, layout, and scroll position.
+- Restore state automatically on entry when route has no explicit category/query.
+- Save current search and reuse/delete saved searches.
+
+Updated:
+- src/app/features/website/catalog/containers/catalog-container.component.ts
+- src/app/features/website/catalog/containers/catalog-container.component.html
+- src/app/features/website/catalog/containers/catalog-container.component.scss
+
+### Product Sharing and Product Card Actions
+Added:
+- src/app/features/website/user-experience/services/product-share.service.ts
+
+Updated Product Card to reusable actions (without duplication):
+- src/app/components/product-card/product-card.component.ts
+- src/app/components/product-card/product-card.component.html
+- src/app/components/product-card/product-card.component.scss
+
+Updated catalog grid/result propagation:
+- src/app/features/website/catalog/components/product-grid/product-grid.component.ts
+- src/app/features/website/catalog/components/product-grid/product-grid.component.html
+- src/app/features/website/catalog/components/search-results/search-results.component.ts
+- src/app/features/website/catalog/components/search-results/search-results.component.html
+
+### UI/Animation/Notification Enhancements
+Added:
+- src/app/features/website/user-experience/services/user-notification.service.ts
+- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts
+- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
+- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss
+
+Integrated globally in app shell:
+- src/app/app.ts
+- src/app/app.html
+
+Heart animation:
+- Added active pulse animation in product card favorite action.
+
+### Routes and Header
+Updated:
+- src/app/app.routes.ts (wishlist/compare routes)
+- src/app/components/header/header.component.ts
+- src/app/components/header/header.component.html
+- src/app/components/header/header.component.scss
+
+### Localization
+Updated translation contract and locale dictionaries:
+- src/app/i18n/translations.ts
+- src/app/i18n/en.ts
+- src/app/i18n/ru.ts
+- src/app/i18n/hy.ts
+
+## Documentation Updates
+Updated to include Sprint 11 bootstrap and backend contract expectations:
+- docs/platform/02-bootstrap-json-spec.md
+- docs/platform/06-api-contracts.md
+- docs/platform/13-backend-requirements.md
+- docs/backend-platform/business-apis.md
+
+## Validation
+Executed:
+- npm run build
+- npm run arch:check:boundaries
+- npm run arch:check:cycles
+
+Results:
+- Build: PASS (existing bundle budget warning remains)
+- Architecture boundaries: PASS
+- Circular dependencies: PASS
+
+## Notes
+- Guest mode is fully functional using local storage.
+- Authenticated synchronization is prepared by repository contracts and can be implemented via alternate repository provider without changing feature UI components.
+- Bootstrap remains configuration-only and contains no user/business lists for wishlist/compare/recently viewed/saved searches.
diff --git a/docs/backend-platform/business-apis.md b/docs/backend-platform/business-apis.md
index de851bc..32c6dcd 100644
--- a/docs/backend-platform/business-apis.md
+++ b/docs/backend-platform/business-apis.md
@@ -130,6 +130,49 @@ Purpose:
Must not change:
- acknowledgement contract expected by frontend engagement form.
+## /me/wishlist (future-ready)
+Purpose:
+- authenticated wishlist synchronization across devices.
+
+High-level response shape:
+- wishlist product references
+- optional addedAt metadata
+
+Tenant rule:
+- wishlist entries must remain tenant-scoped.
+
+## /me/compare (future-ready)
+Purpose:
+- optional compare list synchronization for authenticated users.
+
+High-level response shape:
+- compared product references
+- optional addedAt metadata
+
+Tenant rule:
+- compare list must be isolated by tenant + user.
+
+## /me/saved-searches (future-ready)
+Purpose:
+- persist and restore saved search presets.
+
+High-level response shape:
+- saved query/filter/sort presets
+- timestamps and id
+
+Tenant rule:
+- saved searches must be tenant-scoped and user-scoped.
+
+## /me/recently-viewed (future-ready)
+Purpose:
+- synchronize recently viewed product history for authenticated users.
+
+High-level response shape:
+- product references + viewedAt metadata
+
+Tenant rule:
+- history must remain tenant-scoped and privacy-safe.
+
## /categories
Purpose:
- category tree retrieval
diff --git a/docs/platform/02-bootstrap-json-spec.md b/docs/platform/02-bootstrap-json-spec.md
index 6f8bb57..3b19709 100644
--- a/docs/platform/02-bootstrap-json-spec.md
+++ b/docs/platform/02-bootstrap-json-spec.md
@@ -56,6 +56,7 @@ Bootstrap JSON является главным конфигурационным
- footer
- catalog
- productPage
+- userExperience
- staticPages
- widgetRegistry
- visibility
@@ -100,6 +101,24 @@ Bootstrap JSON является главным конфигурационным
- productPage.tabs.items: array (description/specifications/reviews/questions/delivery/warranty)
- productPage.relatedProducts.enabled: boolean
+### User Experience Config (опционально)
+- userExperience.wishlist.enabled: boolean
+- userExperience.wishlist.headerBadgeEnabled: boolean
+- userExperience.compare.enabled: boolean
+- userExperience.compare.maxItems: number
+- userExperience.compare.hideIdenticalDefault: boolean
+- userExperience.compare.highlightDifferencesDefault: boolean
+- userExperience.recentlyViewed.enabled: boolean
+- userExperience.recentlyViewed.maxItems: number
+- userExperience.recentlyViewed.widgetEnabled: boolean
+- userExperience.share.enabled: boolean
+- userExperience.continueBrowsing.enabled: boolean
+- userExperience.savedSearches.enabled: boolean
+- userExperience.savedSearches.maxItems: number
+
+Правило:
+- Для userExperience допускаются только feature-конфиги (flags/limits/default behaviors), без пользовательских списков (wishlist/compare/recentlyViewed/saved searches) и без product payloads.
+
## Пример полного минимального bootstrap
```json
{
diff --git a/docs/platform/06-api-contracts.md b/docs/platform/06-api-contracts.md
index 5749126..1237c6b 100644
--- a/docs/platform/06-api-contracts.md
+++ b/docs/platform/06-api-contracts.md
@@ -50,6 +50,27 @@
- Backend may introduce new sort IDs via bootstrap `catalog.availableSorts`.
- Frontend must render unknown sort keys safely if label mapping is provided.
+ ## User Experience API Expectations (architecture-ready)
+ - Wishlist (authenticated mode, future-ready):
+ - GET /me/wishlist
+ - POST /me/wishlist
+ - DELETE /me/wishlist/{itemId}
+ - Compare list (optional sync for authenticated mode):
+ - GET /me/compare
+ - POST /me/compare
+ - DELETE /me/compare/{itemId}
+ - Saved searches:
+ - GET /me/saved-searches
+ - POST /me/saved-searches
+ - DELETE /me/saved-searches/{id}
+ - Recently viewed sync (optional):
+ - GET /me/recently-viewed
+ - POST /me/recently-viewed
+
+ Правила:
+ - Guest mode может хранить UX данные локально (local storage) без backend-запросов.
+ - UI не вызывает HttpClient напрямую: Feature -> Facade -> Repository/Provider.
+
## Пример API ответа: категории
```json
{
diff --git a/docs/platform/13-backend-requirements.md b/docs/platform/13-backend-requirements.md
index c079c4e..c8f853b 100644
--- a/docs/platform/13-backend-requirements.md
+++ b/docs/platform/13-backend-requirements.md
@@ -10,6 +10,7 @@
- Выдача навигации, статических страниц и feature flags.
- Product Engagement API для рейтинга, отзывов и вопросов.
- Advanced Search API для keyword/suggestions/filter metadata/sorting.
+- User Experience API (future-ready): wishlist/compare/saved-searches/recently-viewed sync for authenticated users.
### Контракт статических страниц
Backend должен поддерживать формат:
@@ -48,6 +49,19 @@ Backend должен поддерживать формат:
- GET /search/suggestions?q={term} (future-ready)
- GET /catalog/filters?category={id}&q={term} (future-ready)
+## User Experience endpoints (future-ready)
+- GET /me/wishlist
+- POST /me/wishlist
+- DELETE /me/wishlist/{itemId}
+- GET /me/compare
+- POST /me/compare
+- DELETE /me/compare/{itemId}
+- GET /me/saved-searches
+- POST /me/saved-searches
+- DELETE /me/saved-searches/{id}
+- GET /me/recently-viewed
+- POST /me/recently-viewed
+
## Catalog bootstrap contract expectations
- Backend should populate `catalog.availableSorts` and `catalog.enabledFilters`.
- Backend should not include product list or filter results inside bootstrap.
diff --git a/src/app/app.html b/src/app/app.html
index e0926f0..d620679 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -18,6 +18,7 @@
}
+
@defer (on viewport) {
} @placeholder {
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 1a850e3..0c3b11d 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -38,6 +38,14 @@ const coreRoutes: Routes = [
path: 'search',
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent)
},
+ {
+ path: 'wishlist',
+ loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent)
+ },
+ {
+ path: 'compare',
+ loadComponent: () => import('./features/website/user-experience/compare/containers/compare-page.component').then(m => m.ComparePageComponent)
+ },
{
path: 'cart',
loadComponent: () => import('./pages/cart/cart.component').then(m => m.CartComponent)
diff --git a/src/app/app.ts b/src/app/app.ts
index 5bc17a6..5918df3 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -14,10 +14,11 @@ import { TranslateService } from './i18n/translate.service';
import { PlatformRuntimeService } from './core/runtime/platform-runtime.service';
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
import { ApiHealthService } from './services/api-health.service';
+import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
@Component({
selector: 'app-root',
- imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
+ imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent],
templateUrl: './app.html',
styleUrl: './app.scss'
})
diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html
index 07c1db5..f733501 100644
--- a/src/app/components/header/header.component.html
+++ b/src/app/components/header/header.component.html
@@ -38,6 +38,24 @@
+ @if (userExperienceConfig.wishlist.enabled) {
+
+ }
+
+ @if (userExperienceConfig.compare.enabled) {
+
+ }
+
diff --git a/src/app/components/header/header.component.scss b/src/app/components/header/header.component.scss
index 4e426eb..0622c4e 100644
--- a/src/app/components/header/header.component.scss
+++ b/src/app/components/header/header.component.scss
@@ -632,6 +632,50 @@
flex-shrink: 0;
}
+.dexar-ux-btn {
+ position: relative;
+ width: 34px;
+ height: 34px;
+ border: 1px solid #d3dad9;
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.84);
+ color: #1e3c38;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: transform 0.18s ease, border-color 0.2s ease, background 0.2s ease;
+}
+
+.dexar-ux-btn:hover {
+ transform: translateY(-1px);
+ border-color: #497671;
+ background: #ffffff;
+}
+
+.dexar-ux-icon {
+ font-size: 15px;
+ line-height: 1;
+ font-weight: 700;
+}
+
+.dexar-ux-badge {
+ position: absolute;
+ right: -6px;
+ top: -6px;
+ min-width: 16px;
+ height: 16px;
+ border-radius: 999px;
+ background: #497671;
+ color: #ffffff;
+ font-size: 10px;
+ font-weight: 700;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 4px;
+}
+
.dexar-cart-btn {
display: flex;
flex-direction: column;
diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts
index ae9c30e..f8bcce0 100644
--- a/src/app/components/header/header.component.ts
+++ b/src/app/components/header/header.component.ts
@@ -8,6 +8,9 @@ import { RegionSelectorComponent } from '../region-selector/region-selector.comp
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
+import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
+import { ConfigService } from '../../core/config/config.service';
+import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
@Component({
selector: 'app-header',
@@ -25,6 +28,12 @@ export class HeaderComponent {
private document = inject(DOCUMENT);
private langService = inject(LanguageService);
private uiRuntime = inject(UiRuntimeFacade);
+ private uxFacade = inject(UserExperienceFacade);
+ private configService = inject(ConfigService);
+
+ readonly wishlistCount = this.uxFacade.wishlistCount;
+ readonly compareCount = this.uxFacade.compareCount;
+ readonly userExperienceConfig = this.resolveUserExperienceConfig();
constructor(private cartService: CartService, private router: Router) {
this.cartItemCount = this.cartService.itemCount;
@@ -89,6 +98,18 @@ export class HeaderComponent {
});
}
+ navigateToWishlist(): void {
+ this.closeMenu();
+ const lang = this.langService.currentLanguage();
+ this.router.navigate([`/${lang}/wishlist`]);
+ }
+
+ navigateToCompare(): void {
+ this.closeMenu();
+ const lang = this.langService.currentLanguage();
+ this.router.navigate([`/${lang}/compare`]);
+ }
+
formatCartTotal(total: number): string {
const locale = this.langService.currentLanguage() === 'en'
? 'en-US'
@@ -104,4 +125,21 @@ export class HeaderComponent {
return `${amount} ${currencySymbol}`;
}
+
+ private resolveUserExperienceConfig() {
+ const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
+
+ return {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG,
+ ...raw,
+ wishlist: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist,
+ ...(raw.wishlist ?? {})
+ },
+ compare: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
+ ...(raw.compare ?? {})
+ }
+ };
+ }
}
diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html
index 8ae4e03..14ad4c0 100644
--- a/src/app/components/product-card/product-card.component.html
+++ b/src/app/components/product-card/product-card.component.html
@@ -9,16 +9,19 @@
{{ item.remainings }}
}
- @if (showFavoritePlaceholder || showComparePlaceholder || showQuickViewPlaceholder) {
+ @if (showFavoriteControl() || showCompareControl() || showShareAction || showQuickViewPlaceholder) {
- @if (showFavoritePlaceholder) {
-
+ @if (showFavoriteControl()) {
+
}
- @if (showComparePlaceholder) {
-
+ @if (showCompareControl()) {
+
+ }
+ @if (showShareAction) {
+
}
@if (showQuickViewPlaceholder) {
-
+
}
}
diff --git a/src/app/components/product-card/product-card.component.scss b/src/app/components/product-card/product-card.component.scss
index 78e0cad..06742b2 100644
--- a/src/app/components/product-card/product-card.component.scss
+++ b/src/app/components/product-card/product-card.component.scss
@@ -90,16 +90,46 @@
gap: 6px;
}
-.product-actions-overlay button {
- min-height: 24px;
+.product-action-btn {
+ width: 30px;
+ height: 30px;
border: 1px solid rgba(30, 60, 56, 0.15);
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.9);
+ border-radius: 50%;
+ background: rgba(255, 255, 255, 0.95);
color: #1e3c38;
- font-size: 0.72rem;
+ font-size: 0.9rem;
font-weight: 700;
- padding: 0 8px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
cursor: pointer;
+ transition: transform 0.18s ease, border-color 0.2s ease, color 0.2s ease;
+}
+
+.product-action-btn:hover {
+ transform: translateY(-1px);
+ border-color: #497671;
+}
+
+.product-action-btn.active {
+ border-color: #497671;
+ color: #497671;
+}
+
+.product-action-favorite.active {
+ animation: favorite-pulse 280ms ease-out;
+}
+
+@keyframes favorite-pulse {
+ 0% {
+ transform: scale(0.9);
+ }
+ 60% {
+ transform: scale(1.2);
+ }
+ 100% {
+ transform: scale(1);
+ }
}
.product-badges-overlay {
diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts
index 283a316..05875f6 100644
--- a/src/app/components/product-card/product-card.component.ts
+++ b/src/app/components/product-card/product-card.component.ts
@@ -28,6 +28,11 @@ export class ProductCardComponent {
@Input() showFavoritePlaceholder = false;
@Input() showComparePlaceholder = false;
@Input() showQuickViewPlaceholder = false;
+ @Input() showFavoriteAction = false;
+ @Input() showCompareAction = false;
+ @Input() showShareAction = false;
+ @Input() isFavorite = false;
+ @Input() isCompared = false;
@Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>();
@Output() preview = new EventEmitter();
@@ -35,6 +40,9 @@ export class ProductCardComponent {
@Output() favoritePlaceholder = new EventEmitter();
@Output() comparePlaceholder = new EventEmitter();
@Output() quickViewPlaceholder = new EventEmitter();
+ @Output() favoriteToggled = new EventEmitter();
+ @Output() compareToggled = new EventEmitter();
+ @Output() shareRequested = new EventEmitter();
readonly getMainImage = getMainImage;
readonly getDiscountedPrice = getDiscountedPrice;
@@ -52,12 +60,14 @@ export class ProductCardComponent {
event.preventDefault();
event.stopPropagation();
this.favoritePlaceholder.emit(this.item.itemID);
+ this.favoriteToggled.emit(this.item.itemID);
}
onComparePlaceholder(event: Event): void {
event.preventDefault();
event.stopPropagation();
this.comparePlaceholder.emit(this.item.itemID);
+ this.compareToggled.emit(this.item.itemID);
}
onQuickViewPlaceholder(event: Event): void {
@@ -65,4 +75,18 @@ export class ProductCardComponent {
event.stopPropagation();
this.quickViewPlaceholder.emit(this.item.itemID);
}
+
+ onShare(event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+ this.shareRequested.emit(this.item.itemID);
+ }
+
+ showFavoriteControl(): boolean {
+ return this.showFavoriteAction || this.showFavoritePlaceholder;
+ }
+
+ showCompareControl(): boolean {
+ return this.showCompareAction || this.showComparePlaceholder;
+ }
}
diff --git a/src/app/core/user-experience/models/user-experience.model.ts b/src/app/core/user-experience/models/user-experience.model.ts
new file mode 100644
index 0000000..9c03058
--- /dev/null
+++ b/src/app/core/user-experience/models/user-experience.model.ts
@@ -0,0 +1,48 @@
+import { Product } from '../../products/models/product-domain.model';
+
+export interface FavoriteItem {
+ product: Product;
+ addedAt: string;
+ ownerType: 'guest' | 'user';
+}
+
+export interface ComparedProduct {
+ product: Product;
+ addedAt: string;
+}
+
+export interface RecentlyViewedItem {
+ product: Product;
+ viewedAt: string;
+}
+
+export interface SavedSearch {
+ id: string;
+ name: string;
+ query: string;
+ sort: string;
+ categoryId?: number;
+ filters: {
+ values: Record;
+ ranges: Record;
+ toggles: Record;
+ };
+ createdAt: string;
+}
+
+export interface ContinueBrowsingState {
+ routeKey: string;
+ query: string;
+ sort: string;
+ layout: string;
+ page: number;
+ pageSize: number;
+ categoryId?: number;
+ filterState: {
+ values: Record;
+ ranges: Record;
+ toggles: Record;
+ };
+ scrollY: number;
+ updatedAt: string;
+}
diff --git a/src/app/core/user-experience/repositories/local-user-experience.repository.ts b/src/app/core/user-experience/repositories/local-user-experience.repository.ts
new file mode 100644
index 0000000..4a793e2
--- /dev/null
+++ b/src/app/core/user-experience/repositories/local-user-experience.repository.ts
@@ -0,0 +1,113 @@
+import { Injectable } from '@angular/core';
+import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../models/user-experience.model';
+import { UserExperienceRepository } from './user-experience.repository';
+
+const KEYS = {
+ wishlist: 'marketplace.ux.wishlist',
+ compare: 'marketplace.ux.compare',
+ recentlyViewed: 'marketplace.ux.recently-viewed',
+ savedSearches: 'marketplace.ux.saved-searches',
+ continueBrowsingPrefix: 'marketplace.ux.continue-browsing:'
+} as const;
+
+@Injectable({ providedIn: 'root' })
+export class LocalUserExperienceRepository implements UserExperienceRepository {
+ getWishlist(): FavoriteItem[] {
+ return this.readArray(KEYS.wishlist);
+ }
+
+ saveWishlist(items: FavoriteItem[]): void {
+ this.write(KEYS.wishlist, items);
+ }
+
+ getComparedProducts(): ComparedProduct[] {
+ return this.readArray(KEYS.compare);
+ }
+
+ saveComparedProducts(items: ComparedProduct[]): void {
+ this.write(KEYS.compare, items);
+ }
+
+ getRecentlyViewed(): RecentlyViewedItem[] {
+ return this.readArray(KEYS.recentlyViewed);
+ }
+
+ saveRecentlyViewed(items: RecentlyViewedItem[]): void {
+ this.write(KEYS.recentlyViewed, items);
+ }
+
+ getSavedSearches(): SavedSearch[] {
+ return this.readArray(KEYS.savedSearches);
+ }
+
+ saveSavedSearches(items: SavedSearch[]): void {
+ this.write(KEYS.savedSearches, items);
+ }
+
+ getContinueBrowsing(routeKey: string): ContinueBrowsingState | null {
+ return this.readSingle(this.continueKey(routeKey));
+ }
+
+ saveContinueBrowsing(state: ContinueBrowsingState): void {
+ this.write(this.continueKey(state.routeKey), state);
+ }
+
+ clearContinueBrowsing(routeKey: string): void {
+ if (!this.isBrowser()) {
+ return;
+ }
+
+ localStorage.removeItem(this.continueKey(routeKey));
+ }
+
+ private readArray(key: string): T[] {
+ if (!this.isBrowser()) {
+ return [];
+ }
+
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) {
+ return [];
+ }
+
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? (parsed as T[]) : [];
+ } catch {
+ return [];
+ }
+ }
+
+ private readSingle(key: string): T | null {
+ if (!this.isBrowser()) {
+ return null;
+ }
+
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) {
+ return null;
+ }
+
+ return JSON.parse(raw) as T;
+ } catch {
+ return null;
+ }
+ }
+
+ private write(key: string, value: T): void {
+ if (!this.isBrowser()) {
+ return;
+ }
+
+ localStorage.setItem(key, JSON.stringify(value));
+ }
+
+ private continueKey(routeKey: string): string {
+ return `${KEYS.continueBrowsingPrefix}${routeKey}`;
+ }
+
+ private isBrowser(): boolean {
+ return typeof window !== 'undefined';
+ }
+}
diff --git a/src/app/core/user-experience/repositories/user-experience.repository.ts b/src/app/core/user-experience/repositories/user-experience.repository.ts
new file mode 100644
index 0000000..f26d931
--- /dev/null
+++ b/src/app/core/user-experience/repositories/user-experience.repository.ts
@@ -0,0 +1,19 @@
+import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../models/user-experience.model';
+
+export interface UserExperienceRepository {
+ getWishlist(): FavoriteItem[];
+ saveWishlist(items: FavoriteItem[]): void;
+
+ getComparedProducts(): ComparedProduct[];
+ saveComparedProducts(items: ComparedProduct[]): void;
+
+ getRecentlyViewed(): RecentlyViewedItem[];
+ saveRecentlyViewed(items: RecentlyViewedItem[]): void;
+
+ getSavedSearches(): SavedSearch[];
+ saveSavedSearches(items: SavedSearch[]): void;
+
+ getContinueBrowsing(routeKey: string): ContinueBrowsingState | null;
+ saveContinueBrowsing(state: ContinueBrowsingState): void;
+ clearContinueBrowsing(routeKey: string): void;
+}
diff --git a/src/app/core/user-experience/user-experience-repository.token.ts b/src/app/core/user-experience/user-experience-repository.token.ts
new file mode 100644
index 0000000..d7462ca
--- /dev/null
+++ b/src/app/core/user-experience/user-experience-repository.token.ts
@@ -0,0 +1,12 @@
+import { InjectionToken, inject } from '@angular/core';
+import { LocalUserExperienceRepository } from './repositories/local-user-experience.repository';
+import { UserExperienceRepository } from './repositories/user-experience.repository';
+
+export const USER_EXPERIENCE_REPOSITORY = new InjectionToken('USER_EXPERIENCE_REPOSITORY', {
+ providedIn: 'root',
+ factory: () => {
+ // Sprint 11: guest-first local storage; can be switched to authenticated repository later.
+ const localRepository = inject(LocalUserExperienceRepository);
+ return localRepository;
+ }
+});
diff --git a/src/app/facades/platform/user-experience.facade.ts b/src/app/facades/platform/user-experience.facade.ts
new file mode 100644
index 0000000..7a888d4
--- /dev/null
+++ b/src/app/facades/platform/user-experience.facade.ts
@@ -0,0 +1,136 @@
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { Product } from '../../core/products/models/product-domain.model';
+import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../../core/user-experience/models/user-experience.model';
+import { USER_EXPERIENCE_REPOSITORY } from '../../core/user-experience/user-experience-repository.token';
+
+@Injectable({ providedIn: 'root' })
+export class UserExperienceFacade {
+ private readonly repository = inject(USER_EXPERIENCE_REPOSITORY);
+
+ private readonly wishlistState = signal(this.repository.getWishlist());
+ private readonly compareState = signal(this.repository.getComparedProducts());
+ private readonly recentlyViewedState = signal(this.repository.getRecentlyViewed());
+ private readonly savedSearchesState = signal(this.repository.getSavedSearches());
+
+ readonly wishlist = this.wishlistState.asReadonly();
+ readonly comparedProducts = this.compareState.asReadonly();
+ readonly recentlyViewed = this.recentlyViewedState.asReadonly();
+ readonly savedSearches = this.savedSearchesState.asReadonly();
+
+ readonly wishlistCount = computed(() => this.wishlistState().length);
+ readonly compareCount = computed(() => this.compareState().length);
+
+ isInWishlist(productId: number): boolean {
+ return this.wishlistState().some(item => item.product.itemID === productId);
+ }
+
+ isInCompare(productId: number): boolean {
+ return this.compareState().some(item => item.product.itemID === productId);
+ }
+
+ toggleWishlist(product: Product, ownerType: FavoriteItem['ownerType'] = 'guest'): { added: boolean; count: number } {
+ const existing = this.wishlistState();
+ const present = existing.some(item => item.product.itemID === product.itemID);
+
+ const next = present
+ ? existing.filter(item => item.product.itemID !== product.itemID)
+ : [{ product, addedAt: new Date().toISOString(), ownerType }, ...existing];
+
+ this.wishlistState.set(next);
+ this.repository.saveWishlist(next);
+
+ return { added: !present, count: next.length };
+ }
+
+ clearWishlist(): void {
+ this.wishlistState.set([]);
+ this.repository.saveWishlist([]);
+ }
+
+ addToCompare(product: Product, maxItems: number): { added: boolean; reason?: 'limit'; count: number } {
+ const existing = this.compareState();
+
+ if (existing.some(item => item.product.itemID === product.itemID)) {
+ return { added: false, count: existing.length };
+ }
+
+ if (existing.length >= maxItems) {
+ return { added: false, reason: 'limit', count: existing.length };
+ }
+
+ const next = [{ product, addedAt: new Date().toISOString() }, ...existing];
+ this.compareState.set(next);
+ this.repository.saveComparedProducts(next);
+
+ return { added: true, count: next.length };
+ }
+
+ removeFromCompare(productId: number): void {
+ const next = this.compareState().filter(item => item.product.itemID !== productId);
+ this.compareState.set(next);
+ this.repository.saveComparedProducts(next);
+ }
+
+ clearCompare(): void {
+ this.compareState.set([]);
+ this.repository.saveComparedProducts([]);
+ }
+
+ trackRecentlyViewed(product: Product, maxItems: number): void {
+ const existing = this.recentlyViewedState().filter(item => item.product.itemID !== product.itemID);
+ const next = [{ product, viewedAt: new Date().toISOString() }, ...existing].slice(0, Math.max(1, maxItems));
+
+ this.recentlyViewedState.set(next);
+ this.repository.saveRecentlyViewed(next);
+ }
+
+ clearRecentlyViewed(): void {
+ this.recentlyViewedState.set([]);
+ this.repository.saveRecentlyViewed([]);
+ }
+
+ saveSearch(input: Omit, maxItems: number): SavedSearch {
+ const normalizedQuery = input.query.trim();
+ const existing = this.savedSearchesState();
+ const duplicateIndex = existing.findIndex(entry =>
+ entry.query.toLowerCase() === normalizedQuery.toLowerCase() &&
+ entry.sort === input.sort &&
+ (entry.categoryId ?? null) === (input.categoryId ?? null)
+ );
+
+ if (duplicateIndex >= 0) {
+ return existing[duplicateIndex];
+ }
+
+ const nextEntry: SavedSearch = {
+ ...input,
+ query: normalizedQuery,
+ id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ createdAt: new Date().toISOString()
+ };
+
+ const next = [nextEntry, ...existing].slice(0, Math.max(1, maxItems));
+ this.savedSearchesState.set(next);
+ this.repository.saveSavedSearches(next);
+
+ return nextEntry;
+ }
+
+ removeSavedSearch(id: string): void {
+ const next = this.savedSearchesState().filter(item => item.id !== id);
+ this.savedSearchesState.set(next);
+ this.repository.saveSavedSearches(next);
+ }
+
+ saveContinueBrowsing(state: ContinueBrowsingState): void {
+ this.repository.saveContinueBrowsing(state);
+ }
+
+ getContinueBrowsing(routeKey: string): ContinueBrowsingState | null {
+ return this.repository.getContinueBrowsing(routeKey);
+ }
+
+ clearContinueBrowsing(routeKey: string): void {
+ this.repository.clearContinueBrowsing(routeKey);
+ }
+}
diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.html b/src/app/features/website/catalog/components/product-grid/product-grid.component.html
index a6c56d0..230427b 100644
--- a/src/app/features/website/catalog/components/product-grid/product-grid.component.html
+++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.html
@@ -10,13 +10,19 @@
[showStock]="showAvailability"
[showRating]="showRatings"
[showDiscountBadge]="showDiscounts"
- [showFavoritePlaceholder]="showActionsPlaceholder"
- [showComparePlaceholder]="showActionsPlaceholder"
+ [showFavoriteAction]="showActionsPlaceholder"
+ [showCompareAction]="showActionsPlaceholder"
+ [showShareAction]="showShareAction"
[showQuickViewPlaceholder]="showActionsPlaceholder"
+ [isFavorite]="isFavorite(product)"
+ [isCompared]="isCompared(product)"
[addToCartLabel]="'catalog.addToCart' | translate"
(selected)="productSelected.emit(product)"
(addToCart)="onAddToCart(product, $event.event)"
(preview)="productPreview.emit($event)"
+ (favoriteToggled)="favoriteToggled.emit(product)"
+ (compareToggled)="compareToggled.emit(product)"
+ (shareRequested)="shareRequested.emit(product)"
/>
}
diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts
index 0b3aa6f..a1b34a7 100644
--- a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts
+++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts
@@ -21,10 +21,16 @@ export class CatalogProductGridComponent {
@Input() showDiscounts = true;
@Input() showAvailability = true;
@Input() showActionsPlaceholder = false;
+ @Input() showShareAction = false;
+ @Input() favoriteIds: number[] = [];
+ @Input() comparedIds: number[] = [];
@Output() productSelected = new EventEmitter();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter();
+ @Output() favoriteToggled = new EventEmitter();
+ @Output() compareToggled = new EventEmitter();
+ @Output() shareRequested = new EventEmitter();
private readonly languageService = inject(LanguageService);
@@ -42,6 +48,14 @@ export class CatalogProductGridComponent {
this.addToCart.emit({ product, event });
}
+ isFavorite(product: Product): boolean {
+ return this.favoriteIds.includes(product.itemID);
+ }
+
+ isCompared(product: Product): boolean {
+ return this.comparedIds.includes(product.itemID);
+ }
+
layoutClass(): string {
switch (this.layout) {
case 'large-grid':
diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.html b/src/app/features/website/catalog/components/search-results/search-results.component.html
index a699d78..1659bce 100644
--- a/src/app/features/website/catalog/components/search-results/search-results.component.html
+++ b/src/app/features/website/catalog/components/search-results/search-results.component.html
@@ -18,9 +18,15 @@
[showDiscounts]="showDiscounts"
[showAvailability]="showAvailability"
[showActionsPlaceholder]="true"
+ [showShareAction]="showShareAction"
+ [favoriteIds]="favoriteIds"
+ [comparedIds]="comparedIds"
(productSelected)="productSelected.emit($event)"
(addToCart)="addToCart.emit($event)"
- (productPreview)="productPreview.emit($event)" />
+ (productPreview)="productPreview.emit($event)"
+ (favoriteToggled)="favoriteToggled.emit($event)"
+ (compareToggled)="compareToggled.emit($event)"
+ (shareRequested)="shareRequested.emit($event)" />
} @else {
No results found
diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.ts b/src/app/features/website/catalog/components/search-results/search-results.component.ts
index 17e9824..a080491 100644
--- a/src/app/features/website/catalog/components/search-results/search-results.component.ts
+++ b/src/app/features/website/catalog/components/search-results/search-results.component.ts
@@ -22,11 +22,17 @@ export class CatalogSearchResultsComponent {
@Input() showRatings = true;
@Input() showDiscounts = true;
@Input() showAvailability = true;
+ @Input() showShareAction = true;
+ @Input() favoriteIds: number[] = [];
+ @Input() comparedIds: number[] = [];
@Output() pageChange = new EventEmitter
();
@Output() productSelected = new EventEmitter();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter();
+ @Output() favoriteToggled = new EventEmitter();
+ @Output() compareToggled = new EventEmitter();
+ @Output() shareRequested = new EventEmitter();
get totalPages(): number {
return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize)));
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html
index 01dd0c4..a1adbab 100644
--- a/src/app/features/website/catalog/containers/catalog-container.component.html
+++ b/src/app/features/website/catalog/containers/catalog-container.component.html
@@ -24,6 +24,17 @@
(suggestionSelected)="useSuggestion($event)"
(recentSelected)="useRecentSearch($event)"
(historyCleared)="clearSearchHistory()" />
+
+ @if (userExperienceConfig().savedSearches.enabled && savedSearches().length > 0) {
+
+ @for (saved of savedSearches(); track saved.id) {
+
+
+
+
+ }
+
+ }
@if (loading()) {
@@ -98,6 +109,10 @@
[selected]="state().sort"
(selectedChange)="changeSort($event)" />
+ @if (userExperienceConfig().savedSearches.enabled) {
+
+ }
+
+ (productPreview)="previewProduct($event)"
+ (favoriteToggled)="onFavoriteToggled($event)"
+ (compareToggled)="onCompareToggled($event)"
+ (shareRequested)="onShareRequested($event)" />
}
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.scss b/src/app/features/website/catalog/containers/catalog-container.component.scss
index 6dc5879..c91d027 100644
--- a/src/app/features/website/catalog/containers/catalog-container.component.scss
+++ b/src/app/features/website/catalog/containers/catalog-container.component.scss
@@ -12,6 +12,35 @@
margin-bottom: 24px;
}
+.catalog-saved-searches {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.catalog-saved-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border: 1px solid var(--border-color);
+ border-radius: 999px;
+ background: var(--bg-primary);
+ padding: 4px 8px;
+}
+
+.catalog-saved-chip button {
+ border: 0;
+ background: transparent;
+ color: var(--text-primary);
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.catalog-saved-chip-remove {
+ color: var(--text-secondary);
+ line-height: 1;
+}
+
.catalog-root-link {
width: fit-content;
color: #1e3c38;
@@ -72,6 +101,17 @@
justify-content: space-between;
}
+.catalog-save-search-btn {
+ min-height: 36px;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-sm);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ padding: 0 12px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
.catalog-category-banner {
padding: 16px;
background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb);
diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts
index 32132b2..7c9d2f6 100644
--- a/src/app/features/website/catalog/containers/catalog-container.component.ts
+++ b/src/app/features/website/catalog/containers/catalog-container.component.ts
@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { combineLatest } from 'rxjs';
@@ -9,12 +9,13 @@ import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade';
+import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service';
import { PrefetchService } from '../../../../services/prefetch.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
-import { DEFAULT_CATALOG_CONFIG } from '../../../../shared/models/config';
+import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component';
import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
@@ -23,6 +24,8 @@ import { CatalogSearchResultsComponent } from '../components/search-results/sear
import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component';
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 { UserNotificationService } from '../../user-experience/services/user-notification.service';
type CatalogViewMode = 'categories' | 'products';
@@ -55,8 +58,12 @@ export class CatalogContainerComponent {
private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService);
private readonly searchHistoryService = inject(CatalogSearchHistoryService);
+ private readonly uxFacade = inject(UserExperienceFacade);
+ private readonly shareService = inject(ProductShareService);
+ private readonly notifications = inject(UserNotificationService);
readonly catalogConfig = signal(this.resolveCatalogConfig());
+ readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly state = signal(createInitialCatalogState());
readonly categories = signal([]);
@@ -71,11 +78,14 @@ export class CatalogContainerComponent {
readonly searchSuggestions = signal([]);
readonly recentSearches = signal([]);
readonly searchHistory = signal(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
+ readonly savedSearches = this.uxFacade.savedSearches;
readonly viewMode = signal('categories');
readonly loading = signal(true);
readonly loadingProducts = signal(false);
readonly error = signal(null);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
+ readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID));
+ readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID));
readonly sortDefinitions = computed(() => {
const labels: Record = {
@@ -97,6 +107,7 @@ export class CatalogContainerComponent {
private dataSubscription?: Subscription;
private readonly backendFetchSize = 200;
+ private pendingScrollY: number | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
@@ -107,6 +118,11 @@ export class CatalogContainerComponent {
.subscribe(([params, queryParams]) => {
const categoryId = Number(params.get('id')) || null;
const searchQuery = (queryParams.get('q') ?? '').trim();
+
+ if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
+ return;
+ }
+
this.enterCategory(categoryId);
if (searchQuery.length > 0) {
@@ -195,6 +211,7 @@ export class CatalogContainerComponent {
onSearchQueryChange(query: string): void {
this.state.update(current => ({ ...current, search: query }));
+ this.persistContinueBrowsing();
const normalized = query.trim().toLowerCase();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
@@ -228,6 +245,7 @@ export class CatalogContainerComponent {
this.recentSearches.set(this.searchHistory().slice(0, 5));
}
+ this.persistContinueBrowsing();
this.loadCatalog();
}
@@ -247,6 +265,106 @@ export class CatalogContainerComponent {
this.recentSearches.set([]);
}
+ onFavoriteToggled(product: Product): void {
+ const result = this.uxFacade.toggleWishlist(product);
+ this.notifications.show(result.added ? 'Added to wishlist' : 'Removed from wishlist', result.added ? 'success' : 'info');
+ }
+
+ onCompareToggled(product: Product): void {
+ if (this.uxFacade.isInCompare(product.itemID)) {
+ this.uxFacade.removeFromCompare(product.itemID);
+ this.notifications.show('Removed from compare', 'info');
+ return;
+ }
+
+ const maxItems = this.userExperienceConfig().compare.maxItems;
+ const result = this.uxFacade.addToCompare(product, maxItems);
+ if (result.reason === 'limit') {
+ this.notifications.show(`Compare limit reached (${maxItems})`, 'warning');
+ return;
+ }
+
+ this.notifications.show('Added to compare', 'success');
+ }
+
+ async onShareRequested(product: Product): Promise {
+ if (!this.userExperienceConfig().share.enabled) {
+ return;
+ }
+
+ const lang = this.languageService.currentLanguage();
+ const path = `/${lang}/product/${product.itemID}`;
+ const productUrl = typeof window !== 'undefined'
+ ? new URL(path, window.location.origin).toString()
+ : path;
+
+ const result = await this.shareService.shareProduct(product, productUrl);
+ if (result === 'native') {
+ this.notifications.show('Product shared', 'success');
+ return;
+ }
+
+ if (result === 'copied') {
+ this.notifications.show('Product link copied', 'success');
+ return;
+ }
+
+ this.notifications.show('Sharing is not supported on this device', 'warning');
+ }
+
+ saveCurrentSearch(): void {
+ if (!this.userExperienceConfig().savedSearches.enabled) {
+ return;
+ }
+
+ const query = this.state().search.trim();
+ if (!query.length) {
+ this.notifications.show('Enter a search query before saving', 'warning');
+ return;
+ }
+
+ const categoryId = this.state().filters.categoryIds[0];
+ const saved = this.uxFacade.saveSearch({
+ name: query,
+ query,
+ sort: this.state().sort,
+ categoryId,
+ filters: this.filterState()
+ }, this.userExperienceConfig().savedSearches.maxItems);
+
+ this.notifications.show(`Saved search: ${saved.name}`, 'success');
+ }
+
+ useSavedSearch(id: string): void {
+ const target = this.savedSearches().find(item => item.id === id);
+ if (!target) {
+ return;
+ }
+
+ this.viewMode.set('products');
+ this.state.update(current => ({
+ ...current,
+ search: target.query,
+ sort: target.sort as CatalogState['sort'],
+ pagination: {
+ ...current.pagination,
+ page: 1,
+ skip: 0
+ }
+ }));
+ this.filterState.set(target.filters);
+ this.loadCatalog(target.categoryId);
+ }
+
+ removeSavedSearch(id: string): void {
+ this.uxFacade.removeSavedSearch(id);
+ }
+
+ @HostListener('window:scroll')
+ onWindowScroll(): void {
+ this.persistContinueBrowsing();
+ }
+
onFilterStateChange(next: CatalogFilterStateValue): void {
this.filterState.set(next);
this.state.update(current => ({
@@ -259,11 +377,13 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
+ this.persistContinueBrowsing();
}
resetFilters(): void {
this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.recomputeResults();
+ this.persistContinueBrowsing();
}
changeSort(sortId: string): void {
@@ -278,10 +398,12 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
+ this.persistContinueBrowsing();
}
changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout }));
+ this.persistContinueBrowsing();
}
onResultsPageChange(page: number): void {
@@ -295,6 +417,7 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
+ this.persistContinueBrowsing();
}
private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void {
@@ -496,6 +619,12 @@ export class CatalogContainerComponent {
this.searchResult.set(result);
this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted));
+
+ if (this.pendingScrollY != null && typeof window !== 'undefined') {
+ const scrollY = this.pendingScrollY;
+ this.pendingScrollY = null;
+ setTimeout(() => window.scrollTo({ top: scrollY, behavior: 'auto' }), 0);
+ }
}
private buildSuggestions(query: string, products: Product[]): string[] {
@@ -646,6 +775,96 @@ export class CatalogContainerComponent {
};
}
+ private resolveUserExperienceConfig() {
+ const snapshot = this.configService.getBootstrapSnapshot() as any;
+ const raw = snapshot?.userExperience ?? {};
+
+ return {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG,
+ ...raw,
+ wishlist: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist,
+ ...(raw.wishlist ?? {})
+ },
+ compare: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
+ ...(raw.compare ?? {}),
+ maxItems: Number.isFinite(raw.compare?.maxItems)
+ ? Math.max(2, Number(raw.compare.maxItems))
+ : DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems
+ },
+ recentlyViewed: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed,
+ ...(raw.recentlyViewed ?? {})
+ },
+ share: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.share,
+ ...(raw.share ?? {})
+ },
+ continueBrowsing: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.continueBrowsing,
+ ...(raw.continueBrowsing ?? {})
+ },
+ savedSearches: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches,
+ ...(raw.savedSearches ?? {}),
+ maxItems: Number.isFinite(raw.savedSearches?.maxItems)
+ ? Math.max(1, Number(raw.savedSearches.maxItems))
+ : DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches.maxItems
+ }
+ };
+ }
+
+ private persistContinueBrowsing(): void {
+ if (!this.userExperienceConfig().continueBrowsing.enabled || this.viewMode() !== 'products') {
+ return;
+ }
+
+ const state = this.state();
+ this.uxFacade.saveContinueBrowsing({
+ routeKey: 'catalog',
+ query: state.search,
+ sort: state.sort,
+ layout: state.layout,
+ page: state.pagination.page,
+ pageSize: state.pagination.count,
+ categoryId: state.filters.categoryIds[0],
+ filterState: this.filterState(),
+ scrollY: typeof window !== 'undefined' ? window.scrollY : 0,
+ updatedAt: new Date().toISOString()
+ });
+ }
+
+ private restoreContinueBrowsing(): boolean {
+ const saved = this.uxFacade.getContinueBrowsing('catalog');
+ if (!saved) {
+ return false;
+ }
+
+ this.viewMode.set('products');
+ this.filterState.set(saved.filterState);
+ this.state.update(current => ({
+ ...current,
+ search: saved.query,
+ sort: saved.sort as CatalogState['sort'],
+ layout: saved.layout as CatalogLayoutMode,
+ pagination: {
+ ...current.pagination,
+ page: Math.max(1, saved.page),
+ count: Math.max(1, saved.pageSize),
+ skip: Math.max(0, (saved.page - 1) * saved.pageSize)
+ },
+ filters: {
+ ...current.filters,
+ categoryIds: saved.categoryId == null ? [] : [saved.categoryId]
+ }
+ }));
+
+ this.pendingScrollY = Math.max(0, saved.scrollY || 0);
+ this.loadCatalog(saved.categoryId);
+ return true;
+ }
+
private setError(message: string): void {
this.error.set(message);
this.loading.set(false);
diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts
index b27abfe..49414d3 100644
--- a/src/app/features/website/product/containers/product-details-container.component.ts
+++ b/src/app/features/website/product/containers/product-details-container.component.ts
@@ -5,11 +5,12 @@ import { Product } from '../../../../core/products/models/product-domain.model';
import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
import { ConfigService } from '../../../../core/config/config.service';
import { ProductFacade } from '../../../../facades/platform/product.facade';
+import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service';
-import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
+import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -52,10 +53,12 @@ export class ProductDetailsContainerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService);
private readonly productFacade = inject(ProductFacade);
+ private readonly uxFacade = inject(UserExperienceFacade);
private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService);
readonly productPageConfigState = signal>(this.resolveProductPageConfig());
+ readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly product = signal(null);
readonly relatedProducts = signal([]);
@@ -178,6 +181,9 @@ export class ProductDetailsContainerComponent {
}
this.product.set(product);
+ if (this.userExperienceConfig().recentlyViewed.enabled) {
+ this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
+ }
this.initVariantSelection(product);
this.loadRelatedProducts(product);
this.loadEngagement(product.itemID);
@@ -461,4 +467,20 @@ export class ProductDetailsContainerComponent {
}
};
}
+
+ private resolveUserExperienceConfig() {
+ const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
+
+ return {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG,
+ ...raw,
+ recentlyViewed: {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed,
+ ...(raw.recentlyViewed ?? {}),
+ maxItems: Number.isFinite(raw.recentlyViewed?.maxItems)
+ ? Math.max(1, Number(raw.recentlyViewed.maxItems))
+ : DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed.maxItems
+ }
+ };
+ }
}
diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.html b/src/app/features/website/user-experience/compare/components/compare-table.component.html
new file mode 100644
index 0000000..f00ad9b
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/components/compare-table.component.html
@@ -0,0 +1,26 @@
+@if (products.length > 0) {
+
+
+
+
+ | Attribute |
+ @for (product of products; track product.itemID) {
+
+ {{ product.name }}
+ |
+ }
+
+
+
+ @for (row of rows(); track row.key) {
+
+ | {{ row.label }} |
+ @for (value of row.values; track $index) {
+ {{ value }} |
+ }
+
+ }
+
+
+
+}
diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.scss b/src/app/features/website/user-experience/compare/components/compare-table.component.scss
new file mode 100644
index 0000000..606f12e
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/components/compare-table.component.scss
@@ -0,0 +1,43 @@
+.compare-table-wrap {
+ overflow-x: auto;
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ background: var(--bg-primary);
+}
+
+.compare-table {
+ width: 100%;
+ min-width: 680px;
+ border-collapse: collapse;
+}
+
+.compare-table th,
+.compare-table td {
+ border-bottom: 1px solid var(--border-color);
+ padding: 12px;
+ text-align: left;
+ vertical-align: top;
+}
+
+.compare-table th {
+ color: var(--text-primary);
+ background: color-mix(in srgb, var(--bg-secondary) 60%, white);
+}
+
+.compare-product-title {
+ font-size: 0.95rem;
+ font-weight: 700;
+ line-height: 1.3;
+}
+
+.compare-different td {
+ background: color-mix(in srgb, var(--warning-color) 9%, white);
+}
+
+@media (max-width: 640px) {
+ .compare-table th,
+ .compare-table td {
+ padding: 10px;
+ font-size: 0.9rem;
+ }
+}
diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.ts b/src/app/features/website/user-experience/compare/components/compare-table.component.ts
new file mode 100644
index 0000000..62b3313
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/components/compare-table.component.ts
@@ -0,0 +1,63 @@
+import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core';
+import { Product } from '../../../../../core/products/models/product-domain.model';
+
+interface CompareRow {
+ key: string;
+ label: string;
+ values: string[];
+ identical: boolean;
+}
+
+@Component({
+ selector: 'app-compare-table',
+ standalone: true,
+ imports: [],
+ templateUrl: './compare-table.component.html',
+ styleUrls: ['./compare-table.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class CompareTableComponent {
+ @Input() products: Product[] = [];
+ @Input() hideIdentical = false;
+ @Input() highlightDifferences = true;
+
+ readonly rows = computed(() => {
+ const products = this.products;
+ if (products.length === 0) {
+ return [];
+ }
+
+ const baseRows: CompareRow[] = [
+ this.toRow('price', 'Price', products.map(product => `${product.price.toFixed(2)} ${product.currency}`)),
+ this.toRow('rating', 'Rating', products.map(product => `${(product.rating ?? 0).toFixed(1)}`)),
+ this.toRow('stock', 'Stock', products.map(product => product.remainings ?? 'unknown')),
+ this.toRow('discount', 'Discount', products.map(product => `${product.discount ?? 0}%`)),
+ this.toRow('color', 'Color', products.map(product => product.colour ?? '—')),
+ this.toRow('size', 'Size', products.map(product => product.size ?? '—'))
+ ];
+
+ const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))];
+
+ for (const key of dynamicKeys) {
+ const values = products.map(product => {
+ const value = (product.descriptionFields ?? []).find(field => field.key === key)?.value;
+ return value?.trim() || '—';
+ });
+
+ baseRows.push(this.toRow(`attr:${key}`, key, values));
+ }
+
+ return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
+ });
+
+ isDifferentRow(row: CompareRow): boolean {
+ return this.highlightDifferences && !row.identical;
+ }
+
+ private toRow(key: string, label: string, values: string[]): CompareRow {
+ const normalized = values.map(value => value.trim().toLowerCase());
+ const identical = normalized.every(value => value === normalized[0]);
+
+ return { key, label, values, identical };
+ }
+}
diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.html b/src/app/features/website/user-experience/compare/containers/compare-page.component.html
new file mode 100644
index 0000000..0fcd0b8
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.html
@@ -0,0 +1,46 @@
+
+
+
+ @if (hasItems()) {
+
+
+
+
+
+ } @else {
+
+ }
+
diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.scss b/src/app/features/website/user-experience/compare/containers/compare-page.component.scss
new file mode 100644
index 0000000..5df494f
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.scss
@@ -0,0 +1,84 @@
+.compare-page {
+ display: grid;
+ gap: 14px;
+}
+
+.compare-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.compare-head h1,
+.compare-head p {
+ margin: 0;
+}
+
+.compare-head p {
+ color: var(--text-secondary);
+}
+
+.compare-controls {
+ display: flex;
+ gap: 18px;
+ align-items: center;
+ flex-wrap: wrap;
+ padding: 12px;
+}
+
+.compare-controls label {
+ display: inline-flex;
+ gap: 8px;
+ align-items: center;
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.compare-products-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.compare-product-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--border-color);
+ border-radius: 999px;
+ padding: 4px 10px;
+ background: var(--bg-primary);
+}
+
+.compare-product-chip a {
+ color: var(--text-primary);
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.compare-product-chip button {
+ border: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.compare-empty {
+ min-height: 220px;
+ display: grid;
+ place-content: center;
+ gap: 8px;
+ text-align: center;
+ padding: 24px;
+}
+
+.compare-empty h2,
+.compare-empty p {
+ margin: 0;
+}
+
+.compare-empty p {
+ color: var(--text-secondary);
+}
diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.ts b/src/app/features/website/user-experience/compare/containers/compare-page.component.ts
new file mode 100644
index 0000000..56e4baa
--- /dev/null
+++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.ts
@@ -0,0 +1,48 @@
+import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { ConfigService } from '../../../../../core/config/config.service';
+import { Product } from '../../../../../core/products/models/product-domain.model';
+import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
+import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config';
+import { CompareTableComponent } from '../components/compare-table.component';
+
+@Component({
+ selector: 'app-compare-page',
+ standalone: true,
+ imports: [RouterLink, TranslatePipe, LangRoutePipe, CompareTableComponent],
+ templateUrl: './compare-page.component.html',
+ styleUrls: ['./compare-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class ComparePageComponent {
+ private readonly uxFacade = inject(UserExperienceFacade);
+ private readonly configService = inject(ConfigService);
+
+ private readonly compareConfig = this.resolveCompareConfig();
+
+ readonly hideIdentical = signal(this.compareConfig.hideIdenticalDefault);
+ readonly highlightDifferences = signal(this.compareConfig.highlightDifferencesDefault);
+
+ readonly compared = this.uxFacade.comparedProducts;
+ readonly products = computed(() => this.compared().map(entry => entry.product));
+ readonly hasItems = computed(() => this.products().length > 0);
+
+ remove(productId: number): void {
+ this.uxFacade.removeFromCompare(productId);
+ }
+
+ clear(): void {
+ this.uxFacade.clearCompare();
+ }
+
+ private resolveCompareConfig() {
+ const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
+ return {
+ ...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
+ ...raw,
+ maxItems: Number.isFinite(raw.maxItems) ? Math.max(2, Number(raw.maxItems)) : DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems
+ };
+ }
+}
diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
new file mode 100644
index 0000000..a4f6982
--- /dev/null
+++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html
@@ -0,0 +1,10 @@
+@if (notifications().length > 0) {
+
+}
diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss
new file mode 100644
index 0000000..61e4bf1
--- /dev/null
+++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss
@@ -0,0 +1,70 @@
+.floating-notifications {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ z-index: 1200;
+ display: grid;
+ gap: 10px;
+ width: min(360px, calc(100vw - 24px));
+}
+
+.floating-note {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 8px;
+ align-items: center;
+ border-radius: 12px;
+ border: 1px solid var(--border-color);
+ background: #ffffff;
+ box-shadow: 0 10px 24px rgba(30, 60, 56, 0.2);
+ padding: 10px 12px;
+ animation: note-in 220ms ease-out both;
+}
+
+.floating-note p {
+ margin: 0;
+ color: var(--text-primary);
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.floating-note button {
+ border: 0;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 1.15rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.floating-note-success {
+ border-color: color-mix(in srgb, var(--success-color) 50%, white);
+}
+
+.floating-note-warning {
+ border-color: color-mix(in srgb, var(--warning-color) 50%, white);
+}
+
+.floating-note-info {
+ border-color: color-mix(in srgb, var(--primary-color) 45%, white);
+}
+
+@keyframes note-in {
+ from {
+ opacity: 0;
+ transform: translateY(6px) scale(0.98);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+@media (max-width: 640px) {
+ .floating-notifications {
+ right: 10px;
+ left: 10px;
+ bottom: 10px;
+ width: auto;
+ }
+}
diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts
new file mode 100644
index 0000000..24bc528
--- /dev/null
+++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts
@@ -0,0 +1,19 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
+import { UserNotificationService } from '../../services/user-notification.service';
+
+@Component({
+ selector: 'app-floating-notifications',
+ standalone: true,
+ templateUrl: './floating-notifications.component.html',
+ styleUrls: ['./floating-notifications.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class FloatingNotificationsComponent {
+ private readonly notificationsService = inject(UserNotificationService);
+
+ readonly notifications = this.notificationsService.notifications;
+
+ dismiss(id: string): void {
+ this.notificationsService.dismiss(id);
+ }
+}
diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html
new file mode 100644
index 0000000..f0c00e6
--- /dev/null
+++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html
@@ -0,0 +1,26 @@
+
+
+ {{ title }}
+ @if (showClear && hasItems()) {
+
+ }
+
+
+ @if (hasItems()) {
+
+ @for (entry of items(); track entry.product.itemID) {
+
+ }
+
+ } @else {
+ {{ 'ux.recentlyViewedEmpty' | translate }}
+ }
+
diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss
new file mode 100644
index 0000000..3633020
--- /dev/null
+++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss
@@ -0,0 +1,30 @@
+.recently-viewed {
+ padding: 14px;
+ display: grid;
+ gap: 12px;
+}
+
+.recently-viewed-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.recently-viewed-head h2 {
+ margin: 0;
+ font-size: 1.25rem;
+ color: var(--text-primary);
+}
+
+.recently-viewed-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 12px;
+}
+
+.recently-viewed-empty {
+ margin: 0;
+ color: var(--text-secondary);
+}
diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts
new file mode 100644
index 0000000..3fe3207
--- /dev/null
+++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts
@@ -0,0 +1,44 @@
+import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@angular/core';
+import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
+import { Product } from '../../../../../core/products/models/product-domain.model';
+import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+import { CartService } from '../../../../../services';
+import { UserNotificationService } from '../../services/user-notification.service';
+
+@Component({
+ selector: 'app-recently-viewed-strip',
+ standalone: true,
+ imports: [ProductCardComponent, TranslatePipe],
+ templateUrl: './recently-viewed-strip.component.html',
+ styleUrls: ['./recently-viewed-strip.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RecentlyViewedStripComponent {
+ @Input() title = 'Recently Viewed';
+ @Input() maxItems = 8;
+ @Input() showClear = false;
+
+ private readonly uxFacade = inject(UserExperienceFacade);
+ private readonly cartService = inject(CartService);
+ private readonly notifications = inject(UserNotificationService);
+
+ readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems)));
+ readonly hasItems = computed(() => this.items().length > 0);
+
+ addToCart(itemID: number, event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+ this.cartService.addItem(itemID);
+ this.notifications.show('Added to cart', 'success');
+ }
+
+ clear(): void {
+ this.uxFacade.clearRecentlyViewed();
+ this.notifications.show('Recently viewed cleared', 'info');
+ }
+
+ itemProduct(index: number): Product {
+ return this.items()[index].product;
+ }
+}
diff --git a/src/app/features/website/user-experience/services/product-share.service.ts b/src/app/features/website/user-experience/services/product-share.service.ts
new file mode 100644
index 0000000..12b85e4
--- /dev/null
+++ b/src/app/features/website/user-experience/services/product-share.service.ts
@@ -0,0 +1,32 @@
+import { Injectable } from '@angular/core';
+import { Product } from '../../../../core/products/models/product-domain.model';
+
+export type ShareResult = 'native' | 'copied' | 'unsupported';
+
+@Injectable({ providedIn: 'root' })
+export class ProductShareService {
+ async shareProduct(product: Product, productUrl: string): Promise {
+ if (typeof window === 'undefined') {
+ return 'unsupported';
+ }
+
+ const title = product.name;
+ const text = product.simpleDescription ?? product.name;
+
+ if (typeof navigator !== 'undefined' && 'share' in navigator) {
+ try {
+ await navigator.share({ title, text, url: productUrl });
+ return 'native';
+ } catch {
+ // User cancellation and unsupported payload should fall back to clipboard copy.
+ }
+ }
+
+ if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(productUrl);
+ return 'copied';
+ }
+
+ return 'unsupported';
+ }
+}
diff --git a/src/app/features/website/user-experience/services/user-notification.service.ts b/src/app/features/website/user-experience/services/user-notification.service.ts
new file mode 100644
index 0000000..05a9728
--- /dev/null
+++ b/src/app/features/website/user-experience/services/user-notification.service.ts
@@ -0,0 +1,32 @@
+import { Injectable, signal } from '@angular/core';
+
+export type UserNotificationType = 'success' | 'info' | 'warning';
+
+export interface UserNotification {
+ id: string;
+ message: string;
+ type: UserNotificationType;
+}
+
+@Injectable({ providedIn: 'root' })
+export class UserNotificationService {
+ private readonly state = signal([]);
+
+ readonly notifications = this.state.asReadonly();
+
+ show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500): void {
+ const next: UserNotification = {
+ id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ message,
+ type
+ };
+
+ this.state.update(items => [next, ...items].slice(0, 4));
+
+ setTimeout(() => this.dismiss(next.id), durationMs);
+ }
+
+ dismiss(id: string): void {
+ this.state.update(items => items.filter(item => item.id !== id));
+ }
+}
diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html
new file mode 100644
index 0000000..4687661
--- /dev/null
+++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html
@@ -0,0 +1,35 @@
+
+
+
+ @if (hasItems()) {
+
+ @for (entry of favorites(); track entry.product.itemID) {
+
+ }
+
+ } @else {
+
+ }
+
diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss
new file mode 100644
index 0000000..3d0371e
--- /dev/null
+++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss
@@ -0,0 +1,47 @@
+.wishlist-page {
+ display: grid;
+ gap: 18px;
+}
+
+.wishlist-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.wishlist-head h1,
+.wishlist-head p {
+ margin: 0;
+}
+
+.wishlist-head p {
+ color: var(--text-secondary);
+}
+
+.wishlist-grid {
+ align-items: stretch;
+}
+
+.wishlist-item {
+ min-width: 0;
+}
+
+.wishlist-empty {
+ min-height: 220px;
+ display: grid;
+ place-content: center;
+ gap: 8px;
+ text-align: center;
+ padding: 24px;
+}
+
+.wishlist-empty h2,
+.wishlist-empty p {
+ margin: 0;
+}
+
+.wishlist-empty p {
+ color: var(--text-secondary);
+}
diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts
new file mode 100644
index 0000000..8b09c36
--- /dev/null
+++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts
@@ -0,0 +1,47 @@
+import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
+import { RouterLink } from '@angular/router';
+import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
+import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
+import { TranslatePipe } from '../../../../../i18n/translate.pipe';
+import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
+import { CartService } from '../../../../../services';
+import { UserNotificationService } from '../../services/user-notification.service';
+
+@Component({
+ selector: 'app-wishlist-page',
+ standalone: true,
+ imports: [RouterLink, ProductCardComponent, TranslatePipe, LangRoutePipe],
+ templateUrl: './wishlist-page.component.html',
+ styleUrls: ['./wishlist-page.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class WishlistPageComponent {
+ private readonly cartService = inject(CartService);
+ private readonly uxFacade = inject(UserExperienceFacade);
+ private readonly notifications = inject(UserNotificationService);
+
+ readonly favorites = this.uxFacade.wishlist;
+ readonly hasItems = computed(() => this.favorites().length > 0);
+
+ addToCart(itemId: number, event: Event): void {
+ event.preventDefault();
+ event.stopPropagation();
+ this.cartService.addItem(itemId);
+ this.notifications.show('Added to cart', 'success');
+ }
+
+ removeFromWishlist(itemId: number): void {
+ const target = this.favorites().find(entry => entry.product.itemID === itemId);
+ if (!target) {
+ return;
+ }
+
+ this.uxFacade.toggleWishlist(target.product);
+ this.notifications.show('Removed from wishlist', 'info');
+ }
+
+ clearWishlist(): void {
+ this.uxFacade.clearWishlist();
+ this.notifications.show('Wishlist cleared', 'info');
+ }
+}
diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts
index ab024bc..18ad85d 100644
--- a/src/app/i18n/en.ts
+++ b/src/app/i18n/en.ts
@@ -8,6 +8,8 @@ export const en: Translations = {
contacts: 'Contacts',
searchPlaceholder: 'Search...',
catalog: 'Catalog',
+ wishlist: 'Wishlist',
+ compare: 'Compare',
},
footer: {
description: 'A modern marketplace for comfortable shopping',
@@ -243,4 +245,21 @@ export const en: Translations = {
qrExpired: 'QR code expired. Click to refresh',
qrError: 'Could not create login session. Click to retry',
},
+ ux: {
+ items: 'items',
+ wishlistTitle: 'Wishlist',
+ wishlistEmptyTitle: 'Your wishlist is empty',
+ wishlistEmptyDescription: 'Save products to revisit them anytime.',
+ clearWishlist: 'Clear wishlist',
+ compareTitle: 'Compare Products',
+ compareEmptyTitle: 'No products to compare',
+ compareEmptyDescription: 'Add products from catalog cards to compare specs and prices.',
+ clearCompare: 'Clear compare',
+ hideIdentical: 'Hide identical values',
+ highlightDifferences: 'Highlight differences',
+ clearRecentlyViewed: 'Clear recently viewed',
+ recentlyViewedEmpty: 'No recently viewed products yet.',
+ saveSearch: 'Save search',
+ goToCatalog: 'Go to catalog',
+ },
};
diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts
index 2912350..05996e1 100644
--- a/src/app/i18n/hy.ts
+++ b/src/app/i18n/hy.ts
@@ -8,6 +8,8 @@ export const hy: Translations = {
contacts: 'Կապ',
searchPlaceholder: 'Փնտրել...',
catalog: 'Կատալոգ',
+ wishlist: 'Ընտրյալ',
+ compare: 'Համեմատում',
},
footer: {
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
@@ -243,4 +245,21 @@ export const hy: Translations = {
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
},
+ ux: {
+ items: 'ապրանք',
+ wishlistTitle: 'Ընտրյալներ',
+ wishlistEmptyTitle: 'Ընտրյալները դատարկ են',
+ wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։',
+ clearWishlist: 'Մաքրել ընտրյալները',
+ compareTitle: 'Ապրանքների համեմատում',
+ compareEmptyTitle: 'Համեմատելու ապրանքներ չկան',
+ compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։',
+ clearCompare: 'Մաքրել համեմատումը',
+ hideIdentical: 'Թաքցնել նույն արժեքները',
+ highlightDifferences: 'Ընդգծել տարբերությունները',
+ clearRecentlyViewed: 'Մաքրել դիտվածները',
+ recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։',
+ saveSearch: 'Պահպանել որոնումը',
+ goToCatalog: 'Գնալ կատալոգ',
+ },
};
diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts
index 9fea86f..43b767e 100644
--- a/src/app/i18n/ru.ts
+++ b/src/app/i18n/ru.ts
@@ -8,6 +8,8 @@ export const ru: Translations = {
contacts: 'Контакты',
searchPlaceholder: 'Искать...',
catalog: 'Каталог',
+ wishlist: 'Избранное',
+ compare: 'Сравнение',
},
footer: {
description: 'Современный маркетплейс для комфортных покупок',
@@ -243,4 +245,21 @@ export const ru: Translations = {
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
},
+ ux: {
+ items: 'товаров',
+ wishlistTitle: 'Избранное',
+ wishlistEmptyTitle: 'Избранное пока пусто',
+ wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.',
+ clearWishlist: 'Очистить избранное',
+ compareTitle: 'Сравнение товаров',
+ compareEmptyTitle: 'Нет товаров для сравнения',
+ compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.',
+ clearCompare: 'Очистить сравнение',
+ hideIdentical: 'Скрыть одинаковые значения',
+ highlightDifferences: 'Подсвечивать различия',
+ clearRecentlyViewed: 'Очистить просмотренные',
+ recentlyViewedEmpty: 'Вы пока не просматривали товары.',
+ saveSearch: 'Сохранить поиск',
+ goToCatalog: 'Перейти в каталог',
+ },
};
diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts
index 77dfe34..9a62053 100644
--- a/src/app/i18n/translations.ts
+++ b/src/app/i18n/translations.ts
@@ -6,6 +6,8 @@ export interface Translations {
contacts: string;
searchPlaceholder: string;
catalog: string;
+ wishlist: string;
+ compare: string;
};
footer: {
description: string;
@@ -241,4 +243,21 @@ export interface Translations {
qrExpired: string;
qrError: string;
};
+ ux: {
+ items: string;
+ wishlistTitle: string;
+ wishlistEmptyTitle: string;
+ wishlistEmptyDescription: string;
+ clearWishlist: string;
+ compareTitle: string;
+ compareEmptyTitle: string;
+ compareEmptyDescription: string;
+ clearCompare: string;
+ hideIdentical: string;
+ highlightDifferences: string;
+ clearRecentlyViewed: string;
+ recentlyViewedEmpty: string;
+ saveSearch: string;
+ goToCatalog: string;
+ };
}
diff --git a/src/app/layouts/containers/dynamic-page-layout.component.ts b/src/app/layouts/containers/dynamic-page-layout.component.ts
index 2a0587e..1aeef10 100644
--- a/src/app/layouts/containers/dynamic-page-layout.component.ts
+++ b/src/app/layouts/containers/dynamic-page-layout.component.ts
@@ -43,7 +43,7 @@ import { Category } from '../../core/categories/models/category-domain.model';
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
>
@if (resolveWidget(widget, section, model.id) | async; as resolved) {
-
+
}
}
diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts
index fd80e0c..3e45bc9 100644
--- a/src/app/shared/models/config/bootstrap-config.model.ts
+++ b/src/app/shared/models/config/bootstrap-config.model.ts
@@ -14,6 +14,7 @@ import { SeoConfig } from './seo.model';
import { StaticPagesConfig } from './static-page.model';
import { TenantConfig } from './tenant.model';
import { ThemeConfig } from './theme.model';
+import { UserExperienceConfig } from './user-experience-config.model';
import { WidgetRegistryConfig } from './widget-registry.model';
export interface BootstrapConfig {
@@ -33,6 +34,7 @@ export interface BootstrapConfig {
navigation: NavigationConfig;
footer?: FooterConfig;
productPage?: ProductPageConfig;
+ userExperience?: UserExperienceConfig;
pages: PageConfig[];
staticPages?: StaticPagesConfig;
widgetRegistry?: WidgetRegistryConfig;
diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts
index 3288da9..19028cf 100644
--- a/src/app/shared/models/config/index.ts
+++ b/src/app/shared/models/config/index.ts
@@ -16,5 +16,6 @@ export * from './seo.model';
export * from './static-page.model';
export * from './tenant.model';
export * from './theme.model';
+export * from './user-experience-config.model';
export * from './widget.model';
export * from './widget-registry.model';
diff --git a/src/app/shared/models/config/user-experience-config.model.ts b/src/app/shared/models/config/user-experience-config.model.ts
new file mode 100644
index 0000000..fb96246
--- /dev/null
+++ b/src/app/shared/models/config/user-experience-config.model.ts
@@ -0,0 +1,67 @@
+export interface WishlistFeatureConfig {
+ enabled?: boolean;
+ headerBadgeEnabled?: boolean;
+}
+
+export interface CompareFeatureConfig {
+ enabled?: boolean;
+ maxItems?: number;
+ hideIdenticalDefault?: boolean;
+ highlightDifferencesDefault?: boolean;
+}
+
+export interface RecentlyViewedFeatureConfig {
+ enabled?: boolean;
+ maxItems?: number;
+ widgetEnabled?: boolean;
+}
+
+export interface ShareFeatureConfig {
+ enabled?: boolean;
+}
+
+export interface ContinueBrowsingFeatureConfig {
+ enabled?: boolean;
+}
+
+export interface SavedSearchesFeatureConfig {
+ enabled?: boolean;
+ maxItems?: number;
+}
+
+export interface UserExperienceConfig {
+ wishlist?: WishlistFeatureConfig;
+ compare?: CompareFeatureConfig;
+ recentlyViewed?: RecentlyViewedFeatureConfig;
+ share?: ShareFeatureConfig;
+ continueBrowsing?: ContinueBrowsingFeatureConfig;
+ savedSearches?: SavedSearchesFeatureConfig;
+}
+
+export const DEFAULT_USER_EXPERIENCE_CONFIG: Required = {
+ wishlist: {
+ enabled: true,
+ headerBadgeEnabled: true
+ },
+ compare: {
+ enabled: true,
+ maxItems: 4,
+ hideIdenticalDefault: false,
+ highlightDifferencesDefault: true
+ },
+ recentlyViewed: {
+ enabled: true,
+ maxItems: 12,
+ widgetEnabled: true
+ },
+ share: {
+ enabled: true
+ },
+ continueBrowsing: {
+ enabled: true
+ },
+ savedSearches: {
+ enabled: true,
+ maxItems: 10
+ }
+};
diff --git a/src/app/widgets/registry/widget-registry.bootstrap.service.ts b/src/app/widgets/registry/widget-registry.bootstrap.service.ts
index d0da558..c9a3f8d 100644
--- a/src/app/widgets/registry/widget-registry.bootstrap.service.ts
+++ b/src/app/widgets/registry/widget-registry.bootstrap.service.ts
@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core';
import { Observable, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
-import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
+import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent, RecentlyViewedWidgetComponent } from '../ui';
import { RegisteredWidget } from '../contracts/widget-component.contract';
import { WidgetRegistryService } from './widget-registry.service';
import { WidgetManifestService } from './widget-manifest.service';
@@ -12,7 +12,8 @@ const APPROVED_WIDGET_COMPONENTS: Record
'footer': FooterNavigationWidgetComponent,
'footer-navigation': FooterNavigationWidgetComponent,
'product-carousel': ProductCarouselWidgetComponent,
- 'product-collection': ProductCarouselWidgetComponent
+ 'product-collection': ProductCarouselWidgetComponent,
+ 'recently-viewed': RecentlyViewedWidgetComponent
};
@Injectable({ providedIn: 'root' })
diff --git a/src/app/widgets/resolvers/data-source-resolver.service.ts b/src/app/widgets/resolvers/data-source-resolver.service.ts
index d04a6e3..89c8dae 100644
--- a/src/app/widgets/resolvers/data-source-resolver.service.ts
+++ b/src/app/widgets/resolvers/data-source-resolver.service.ts
@@ -42,6 +42,8 @@ export class DataSourceResolverService {
case 'footer':
case 'footer-navigation':
return of(this.toFooterData(section, settings));
+ case 'recently-viewed':
+ return of({ section, settings });
default:
return of({ section, settings });
}
diff --git a/src/app/widgets/ui/categories-widget.component.ts b/src/app/widgets/ui/categories-widget.component.ts
index 4b04085..ec59878 100644
--- a/src/app/widgets/ui/categories-widget.component.ts
+++ b/src/app/widgets/ui/categories-widget.component.ts
@@ -18,7 +18,7 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
@if (widgetData.categories.length) {
} @else if (widgetData.emptyMessage) {
{{ widgetData.emptyMessage }}
@@ -37,9 +37,11 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
export class CategoriesWidgetComponent {
@Input() section: SectionConfig | null = null;
@Input() data: CategoriesWidgetData | null = null;
+ @Input() categorySelectedCallback: ((category: unknown) => void) | null = null;
@Output() categorySelected = new EventEmitter();
- onCategorySelected(category: unknown): void {
+ handleCategorySelected(category: unknown): void {
+ this.categorySelectedCallback?.(category);
this.categorySelected.emit(category);
}
}
\ No newline at end of file
diff --git a/src/app/widgets/ui/index.ts b/src/app/widgets/ui/index.ts
index 500c8c5..e3a77ef 100644
--- a/src/app/widgets/ui/index.ts
+++ b/src/app/widgets/ui/index.ts
@@ -2,4 +2,5 @@ export * from './categories-widget.component';
export * from './footer-navigation-widget.component';
export * from './hero-widget.component';
export * from './product-carousel-widget.component';
+export * from './recently-viewed-widget.component';
export * from './unknown-widget.component';
diff --git a/src/app/widgets/ui/recently-viewed-widget.component.ts b/src/app/widgets/ui/recently-viewed-widget.component.ts
new file mode 100644
index 0000000..af3b7d2
--- /dev/null
+++ b/src/app/widgets/ui/recently-viewed-widget.component.ts
@@ -0,0 +1,36 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { SectionConfig } from '../../shared/models/config';
+import { RecentlyViewedStripComponent } from '../../features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component';
+
+@Component({
+ selector: 'app-recently-viewed-widget',
+ standalone: true,
+ imports: [RecentlyViewedStripComponent],
+ template: `
+
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class RecentlyViewedWidgetComponent {
+ @Input() section: SectionConfig | null = null;
+ @Input() data: { settings?: Record } | null = null;
+
+ get title(): string {
+ const raw = this.data?.settings?.['title'];
+ return typeof raw === 'string' && raw.trim().length > 0 ? raw : 'Recently Viewed';
+ }
+
+ get maxItems(): number {
+ const raw = this.data?.settings?.['maxItems'];
+ const value = typeof raw === 'number' ? raw : Number(raw);
+ return Number.isFinite(value) ? Math.max(1, value) : 8;
+ }
+
+ get showClear(): boolean {
+ return this.data?.settings?.['showClear'] === true;
+ }
+}
diff --git a/src/assets/mock/bootstrap/bootstrap.json b/src/assets/mock/bootstrap/bootstrap.json
index 1998043..e947c11 100644
--- a/src/assets/mock/bootstrap/bootstrap.json
+++ b/src/assets/mock/bootstrap/bootstrap.json
@@ -282,6 +282,33 @@
"enabled": true
}
},
+ "userExperience": {
+ "wishlist": {
+ "enabled": true,
+ "headerBadgeEnabled": true
+ },
+ "compare": {
+ "enabled": true,
+ "maxItems": 4,
+ "hideIdenticalDefault": false,
+ "highlightDifferencesDefault": true
+ },
+ "recentlyViewed": {
+ "enabled": true,
+ "maxItems": 12,
+ "widgetEnabled": true
+ },
+ "share": {
+ "enabled": true
+ },
+ "continueBrowsing": {
+ "enabled": true
+ },
+ "savedSearches": {
+ "enabled": true,
+ "maxItems": 10
+ }
+ },
"widgetRegistry": {
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
},
diff --git a/src/assets/mock/bootstrap/widget-manifest.json b/src/assets/mock/bootstrap/widget-manifest.json
index e910bcb..61337a1 100644
--- a/src/assets/mock/bootstrap/widget-manifest.json
+++ b/src/assets/mock/bootstrap/widget-manifest.json
@@ -125,6 +125,27 @@
},
"enabled": true
},
+ {
+ "type": "recently-viewed",
+ "version": "1.0.0",
+ "componentKey": "recently-viewed",
+ "supportedLayouts": ["stack", "grid", "carousel"],
+ "supportedDataSources": ["future"],
+ "settingsSchema": {
+ "type": "object",
+ "properties": {
+ "title": { "type": "string" },
+ "maxItems": { "type": "number" },
+ "showClear": { "type": "boolean" }
+ }
+ },
+ "defaultSettings": {
+ "title": "Recently Viewed",
+ "maxItems": 8,
+ "showClear": false
+ },
+ "enabled": true
+ },
{
"type": "banner",
"version": "1.0.0",