feat(ux): implement sprint 11 user experience module

This commit is contained in:
sdarbinyan
2026-07-09 01:13:54 +04:00
parent 1a8f916942
commit 92e1bdaff8
59 changed files with 2062 additions and 25 deletions

View File

@@ -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.

View File

@@ -130,6 +130,49 @@ Purpose:
Must not change: Must not change:
- acknowledgement contract expected by frontend engagement form. - 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 ## /categories
Purpose: Purpose:
- category tree retrieval - category tree retrieval

View File

@@ -56,6 +56,7 @@ Bootstrap JSON является главным конфигурационным
- footer - footer
- catalog - catalog
- productPage - productPage
- userExperience
- staticPages - staticPages
- widgetRegistry - widgetRegistry
- visibility - visibility
@@ -100,6 +101,24 @@ Bootstrap JSON является главным конфигурационным
- productPage.tabs.items: array (description/specifications/reviews/questions/delivery/warranty) - productPage.tabs.items: array (description/specifications/reviews/questions/delivery/warranty)
- productPage.relatedProducts.enabled: boolean - 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 ## Пример полного минимального bootstrap
```json ```json
{ {

View File

@@ -50,6 +50,27 @@
- Backend may introduce new sort IDs via bootstrap `catalog.availableSorts`. - Backend may introduce new sort IDs via bootstrap `catalog.availableSorts`.
- Frontend must render unknown sort keys safely if label mapping is provided. - 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 ответа: категории ## Пример API ответа: категории
```json ```json
{ {

View File

@@ -10,6 +10,7 @@
- Выдача навигации, статических страниц и feature flags. - Выдача навигации, статических страниц и feature flags.
- Product Engagement API для рейтинга, отзывов и вопросов. - Product Engagement API для рейтинга, отзывов и вопросов.
- Advanced Search API для keyword/suggestions/filter metadata/sorting. - Advanced Search API для keyword/suggestions/filter metadata/sorting.
- User Experience API (future-ready): wishlist/compare/saved-searches/recently-viewed sync for authenticated users.
### Контракт статических страниц ### Контракт статических страниц
Backend должен поддерживать формат: Backend должен поддерживать формат:
@@ -48,6 +49,19 @@ Backend должен поддерживать формат:
- GET /search/suggestions?q={term} (future-ready) - GET /search/suggestions?q={term} (future-ready)
- GET /catalog/filters?category={id}&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 ## Catalog bootstrap contract expectations
- Backend should populate `catalog.availableSorts` and `catalog.enabledFilters`. - Backend should populate `catalog.availableSorts` and `catalog.enabledFilters`.
- Backend should not include product list or filter results inside bootstrap. - Backend should not include product list or filter results inside bootstrap.

View File

@@ -18,6 +18,7 @@
} }
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<app-floating-notifications />
@defer (on viewport) { @defer (on viewport) {
<app-footer></app-footer> <app-footer></app-footer>
} @placeholder { } @placeholder {

View File

@@ -38,6 +38,14 @@ const coreRoutes: Routes = [
path: 'search', path: 'search',
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) 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', path: 'cart',
loadComponent: () => import('./pages/cart/cart.component').then(m => m.CartComponent) loadComponent: () => import('./pages/cart/cart.component').then(m => m.CartComponent)

View File

@@ -14,10 +14,11 @@ import { TranslateService } from './i18n/translate.service';
import { PlatformRuntimeService } from './core/runtime/platform-runtime.service'; import { PlatformRuntimeService } from './core/runtime/platform-runtime.service';
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
import { ApiHealthService } from './services/api-health.service'; import { ApiHealthService } from './services/api-health.service';
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe], imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent],
templateUrl: './app.html', templateUrl: './app.html',
styleUrl: './app.scss' styleUrl: './app.scss'
}) })

View File

@@ -38,6 +38,24 @@
<!-- Right Actions --> <!-- Right Actions -->
<div class="dexar-actions"> <div class="dexar-actions">
@if (userExperienceConfig.wishlist.enabled) {
<button type="button" class="dexar-ux-btn" (click)="navigateToWishlist()" [attr.aria-label]="'header.wishlist' | translate">
<span class="dexar-ux-icon"></span>
@if (userExperienceConfig.wishlist.headerBadgeEnabled && wishlistCount() > 0) {
<span class="dexar-ux-badge">{{ wishlistCount() }}</span>
}
</button>
}
@if (userExperienceConfig.compare.enabled) {
<button type="button" class="dexar-ux-btn" (click)="navigateToCompare()" [attr.aria-label]="'header.compare' | translate">
<span class="dexar-ux-icon"></span>
@if (compareCount() > 0) {
<span class="dexar-ux-badge">{{ compareCount() }}</span>
}
</button>
}
<!-- Cart Button --> <!-- Cart Button -->
<a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()"> <a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()">
<span class="dexar-cart-icon"> <span class="dexar-cart-icon">

View File

@@ -632,6 +632,50 @@
flex-shrink: 0; 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 { .dexar-cart-btn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -8,6 +8,9 @@ import { RegionSelectorComponent } from '../region-selector/region-selector.comp
import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe';
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; 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({ @Component({
selector: 'app-header', selector: 'app-header',
@@ -25,6 +28,12 @@ export class HeaderComponent {
private document = inject(DOCUMENT); private document = inject(DOCUMENT);
private langService = inject(LanguageService); private langService = inject(LanguageService);
private uiRuntime = inject(UiRuntimeFacade); 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) { constructor(private cartService: CartService, private router: Router) {
this.cartItemCount = this.cartService.itemCount; 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 { formatCartTotal(total: number): string {
const locale = this.langService.currentLanguage() === 'en' const locale = this.langService.currentLanguage() === 'en'
? 'en-US' ? 'en-US'
@@ -104,4 +125,21 @@ export class HeaderComponent {
return `${amount} ${currencySymbol}`; 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 ?? {})
}
};
}
} }

View File

@@ -9,16 +9,19 @@
<span class="stock-badge" [class.out]="item.remainings === 'out'">{{ item.remainings }}</span> <span class="stock-badge" [class.out]="item.remainings === 'out'">{{ item.remainings }}</span>
} }
@if (showFavoritePlaceholder || showComparePlaceholder || showQuickViewPlaceholder) { @if (showFavoriteControl() || showCompareControl() || showShareAction || showQuickViewPlaceholder) {
<div class="product-actions-overlay"> <div class="product-actions-overlay">
@if (showFavoritePlaceholder) { @if (showFavoriteControl()) {
<button type="button" (click)="onFavoritePlaceholder($event)">Fav</button> <button type="button" class="product-action-btn product-action-favorite" [class.active]="isFavorite" (click)="onFavoritePlaceholder($event)"></button>
} }
@if (showComparePlaceholder) { @if (showCompareControl()) {
<button type="button" (click)="onComparePlaceholder($event)">Compare</button> <button type="button" class="product-action-btn" [class.active]="isCompared" (click)="onComparePlaceholder($event)"></button>
}
@if (showShareAction) {
<button type="button" class="product-action-btn" (click)="onShare($event)"></button>
} }
@if (showQuickViewPlaceholder) { @if (showQuickViewPlaceholder) {
<button type="button" (click)="onQuickViewPlaceholder($event)">Quick</button> <button type="button" class="product-action-btn" (click)="onQuickViewPlaceholder($event)"></button>
} }
</div> </div>
} }

View File

@@ -90,16 +90,46 @@
gap: 6px; gap: 6px;
} }
.product-actions-overlay button { .product-action-btn {
min-height: 24px; width: 30px;
height: 30px;
border: 1px solid rgba(30, 60, 56, 0.15); border: 1px solid rgba(30, 60, 56, 0.15);
border-radius: 999px; border-radius: 50%;
background: rgba(255, 255, 255, 0.9); background: rgba(255, 255, 255, 0.95);
color: #1e3c38; color: #1e3c38;
font-size: 0.72rem; font-size: 0.9rem;
font-weight: 700; font-weight: 700;
padding: 0 8px; display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer; 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 { .product-badges-overlay {

View File

@@ -28,6 +28,11 @@ export class ProductCardComponent {
@Input() showFavoritePlaceholder = false; @Input() showFavoritePlaceholder = false;
@Input() showComparePlaceholder = false; @Input() showComparePlaceholder = false;
@Input() showQuickViewPlaceholder = 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() addToCart = new EventEmitter<{ itemID: number; event: Event }>();
@Output() preview = new EventEmitter<number>(); @Output() preview = new EventEmitter<number>();
@@ -35,6 +40,9 @@ export class ProductCardComponent {
@Output() favoritePlaceholder = new EventEmitter<number>(); @Output() favoritePlaceholder = new EventEmitter<number>();
@Output() comparePlaceholder = new EventEmitter<number>(); @Output() comparePlaceholder = new EventEmitter<number>();
@Output() quickViewPlaceholder = new EventEmitter<number>(); @Output() quickViewPlaceholder = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<number>();
@Output() compareToggled = new EventEmitter<number>();
@Output() shareRequested = new EventEmitter<number>();
readonly getMainImage = getMainImage; readonly getMainImage = getMainImage;
readonly getDiscountedPrice = getDiscountedPrice; readonly getDiscountedPrice = getDiscountedPrice;
@@ -52,12 +60,14 @@ export class ProductCardComponent {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.favoritePlaceholder.emit(this.item.itemID); this.favoritePlaceholder.emit(this.item.itemID);
this.favoriteToggled.emit(this.item.itemID);
} }
onComparePlaceholder(event: Event): void { onComparePlaceholder(event: Event): void {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
this.comparePlaceholder.emit(this.item.itemID); this.comparePlaceholder.emit(this.item.itemID);
this.compareToggled.emit(this.item.itemID);
} }
onQuickViewPlaceholder(event: Event): void { onQuickViewPlaceholder(event: Event): void {
@@ -65,4 +75,18 @@ export class ProductCardComponent {
event.stopPropagation(); event.stopPropagation();
this.quickViewPlaceholder.emit(this.item.itemID); 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;
}
} }

View File

@@ -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<string, string[]>;
ranges: Record<string, { min?: number; max?: number }>;
toggles: Record<string, boolean>;
};
createdAt: string;
}
export interface ContinueBrowsingState {
routeKey: string;
query: string;
sort: string;
layout: string;
page: number;
pageSize: number;
categoryId?: number;
filterState: {
values: Record<string, string[]>;
ranges: Record<string, { min?: number; max?: number }>;
toggles: Record<string, boolean>;
};
scrollY: number;
updatedAt: string;
}

View File

@@ -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<FavoriteItem>(KEYS.wishlist);
}
saveWishlist(items: FavoriteItem[]): void {
this.write(KEYS.wishlist, items);
}
getComparedProducts(): ComparedProduct[] {
return this.readArray<ComparedProduct>(KEYS.compare);
}
saveComparedProducts(items: ComparedProduct[]): void {
this.write(KEYS.compare, items);
}
getRecentlyViewed(): RecentlyViewedItem[] {
return this.readArray<RecentlyViewedItem>(KEYS.recentlyViewed);
}
saveRecentlyViewed(items: RecentlyViewedItem[]): void {
this.write(KEYS.recentlyViewed, items);
}
getSavedSearches(): SavedSearch[] {
return this.readArray<SavedSearch>(KEYS.savedSearches);
}
saveSavedSearches(items: SavedSearch[]): void {
this.write(KEYS.savedSearches, items);
}
getContinueBrowsing(routeKey: string): ContinueBrowsingState | null {
return this.readSingle<ContinueBrowsingState>(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<T>(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<T>(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<T>(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';
}
}

View File

@@ -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;
}

View File

@@ -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<UserExperienceRepository>('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;
}
});

View File

@@ -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<FavoriteItem[]>(this.repository.getWishlist());
private readonly compareState = signal<ComparedProduct[]>(this.repository.getComparedProducts());
private readonly recentlyViewedState = signal<RecentlyViewedItem[]>(this.repository.getRecentlyViewed());
private readonly savedSearchesState = signal<SavedSearch[]>(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<SavedSearch, 'id' | 'createdAt'>, 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);
}
}

View File

@@ -10,13 +10,19 @@
[showStock]="showAvailability" [showStock]="showAvailability"
[showRating]="showRatings" [showRating]="showRatings"
[showDiscountBadge]="showDiscounts" [showDiscountBadge]="showDiscounts"
[showFavoritePlaceholder]="showActionsPlaceholder" [showFavoriteAction]="showActionsPlaceholder"
[showComparePlaceholder]="showActionsPlaceholder" [showCompareAction]="showActionsPlaceholder"
[showShareAction]="showShareAction"
[showQuickViewPlaceholder]="showActionsPlaceholder" [showQuickViewPlaceholder]="showActionsPlaceholder"
[isFavorite]="isFavorite(product)"
[isCompared]="isCompared(product)"
[addToCartLabel]="'catalog.addToCart' | translate" [addToCartLabel]="'catalog.addToCart' | translate"
(selected)="productSelected.emit(product)" (selected)="productSelected.emit(product)"
(addToCart)="onAddToCart(product, $event.event)" (addToCart)="onAddToCart(product, $event.event)"
(preview)="productPreview.emit($event)" (preview)="productPreview.emit($event)"
(favoriteToggled)="favoriteToggled.emit(product)"
(compareToggled)="compareToggled.emit(product)"
(shareRequested)="shareRequested.emit(product)"
/> />
</div> </div>
} }

View File

@@ -21,10 +21,16 @@ export class CatalogProductGridComponent {
@Input() showDiscounts = true; @Input() showDiscounts = true;
@Input() showAvailability = true; @Input() showAvailability = true;
@Input() showActionsPlaceholder = false; @Input() showActionsPlaceholder = false;
@Input() showShareAction = false;
@Input() favoriteIds: number[] = [];
@Input() comparedIds: number[] = [];
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>();
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
@@ -42,6 +48,14 @@ export class CatalogProductGridComponent {
this.addToCart.emit({ product, event }); 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 { layoutClass(): string {
switch (this.layout) { switch (this.layout) {
case 'large-grid': case 'large-grid':

View File

@@ -18,9 +18,15 @@
[showDiscounts]="showDiscounts" [showDiscounts]="showDiscounts"
[showAvailability]="showAvailability" [showAvailability]="showAvailability"
[showActionsPlaceholder]="true" [showActionsPlaceholder]="true"
[showShareAction]="showShareAction"
[favoriteIds]="favoriteIds"
[comparedIds]="comparedIds"
(productSelected)="productSelected.emit($event)" (productSelected)="productSelected.emit($event)"
(addToCart)="addToCart.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 { } @else {
<div class="empty-state card"> <div class="empty-state card">
<h3>No results found</h3> <h3>No results found</h3>

View File

@@ -22,11 +22,17 @@ export class CatalogSearchResultsComponent {
@Input() showRatings = true; @Input() showRatings = true;
@Input() showDiscounts = true; @Input() showDiscounts = true;
@Input() showAvailability = true; @Input() showAvailability = true;
@Input() showShareAction = true;
@Input() favoriteIds: number[] = [];
@Input() comparedIds: number[] = [];
@Output() pageChange = new EventEmitter<number>(); @Output() pageChange = new EventEmitter<number>();
@Output() productSelected = new EventEmitter<Product>(); @Output() productSelected = new EventEmitter<Product>();
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
@Output() productPreview = new EventEmitter<number>(); @Output() productPreview = new EventEmitter<number>();
@Output() favoriteToggled = new EventEmitter<Product>();
@Output() compareToggled = new EventEmitter<Product>();
@Output() shareRequested = new EventEmitter<Product>();
get totalPages(): number { get totalPages(): number {
return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize))); return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize)));

View File

@@ -24,6 +24,17 @@
(suggestionSelected)="useSuggestion($event)" (suggestionSelected)="useSuggestion($event)"
(recentSelected)="useRecentSearch($event)" (recentSelected)="useRecentSearch($event)"
(historyCleared)="clearSearchHistory()" /> (historyCleared)="clearSearchHistory()" />
@if (userExperienceConfig().savedSearches.enabled && savedSearches().length > 0) {
<div class="catalog-saved-searches">
@for (saved of savedSearches(); track saved.id) {
<div class="catalog-saved-chip">
<button type="button" (click)="useSavedSearch(saved.id)">{{ saved.name }}</button>
<button type="button" class="catalog-saved-chip-remove" (click)="removeSavedSearch(saved.id)">×</button>
</div>
}
</div>
}
</header> </header>
@if (loading()) { @if (loading()) {
@@ -98,6 +109,10 @@
[selected]="state().sort" [selected]="state().sort"
(selectedChange)="changeSort($event)" /> (selectedChange)="changeSort($event)" />
@if (userExperienceConfig().savedSearches.enabled) {
<button type="button" class="catalog-save-search-btn" (click)="saveCurrentSearch()">{{ 'ux.saveSearch' | translate }}</button>
}
<app-catalog-layout-switcher <app-catalog-layout-switcher
[active]="state().layout" [active]="state().layout"
[available]="availableLayouts" [available]="availableLayouts"
@@ -122,10 +137,16 @@
[showRatings]="catalogConfig().showRatings" [showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts" [showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability" [showAvailability]="catalogConfig().showAvailability"
[showShareAction]="userExperienceConfig().share.enabled"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
(pageChange)="onResultsPageChange($event)" (pageChange)="onResultsPageChange($event)"
(productSelected)="selectProduct($event)" (productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)" (addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)" /> (productPreview)="previewProduct($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" />
</div> </div>
</section> </section>
} }

View File

@@ -12,6 +12,35 @@
margin-bottom: 24px; 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 { .catalog-root-link {
width: fit-content; width: fit-content;
color: #1e3c38; color: #1e3c38;
@@ -72,6 +101,17 @@
justify-content: space-between; 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 { .catalog-category-banner {
padding: 16px; padding: 16px;
background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb); background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb);

View File

@@ -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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { combineLatest } from 'rxjs'; 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 { ConfigService } from '../../../../core/config/config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade'; import { ProductFacade } from '../../../../facades/platform/product.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service'; import { LanguageService } from '../../../../services/language.service';
import { PrefetchService } from '../../../../services/prefetch.service'; import { PrefetchService } from '../../../../services/prefetch.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.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 { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component'; import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component';
import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component'; import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
@@ -23,6 +24,8 @@ import { CatalogSearchResultsComponent } from '../components/search-results/sear
import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component'; import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { CatalogSearchHistoryService } from '../services/catalog-search-history.service'; import { 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'; type CatalogViewMode = 'categories' | 'products';
@@ -55,8 +58,12 @@ export class CatalogContainerComponent {
private readonly prefetchService = inject(PrefetchService); private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
private readonly searchHistoryService = inject(CatalogSearchHistoryService); private readonly searchHistoryService = inject(CatalogSearchHistoryService);
private readonly uxFacade = inject(UserExperienceFacade);
private readonly shareService = inject(ProductShareService);
private readonly notifications = inject(UserNotificationService);
readonly catalogConfig = signal(this.resolveCatalogConfig()); readonly catalogConfig = signal(this.resolveCatalogConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly state = signal<CatalogState>(createInitialCatalogState()); readonly state = signal<CatalogState>(createInitialCatalogState());
readonly categories = signal<Category[]>([]); readonly categories = signal<Category[]>([]);
@@ -71,11 +78,14 @@ export class CatalogContainerComponent {
readonly searchSuggestions = signal<string[]>([]); readonly searchSuggestions = signal<string[]>([]);
readonly recentSearches = signal<string[]>([]); readonly recentSearches = signal<string[]>([]);
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []); readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
readonly savedSearches = this.uxFacade.savedSearches;
readonly viewMode = signal<CatalogViewMode>('categories'); readonly viewMode = signal<CatalogViewMode>('categories');
readonly loading = signal(true); readonly loading = signal(true);
readonly loadingProducts = signal(false); readonly loadingProducts = signal(false);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0); 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<SortDefinition[]>(() => { readonly sortDefinitions = computed<SortDefinition[]>(() => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
@@ -97,6 +107,7 @@ export class CatalogContainerComponent {
private dataSubscription?: Subscription; private dataSubscription?: Subscription;
private readonly backendFetchSize = 200; private readonly backendFetchSize = 200;
private pendingScrollY: number | null = null;
constructor() { constructor() {
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
@@ -107,6 +118,11 @@ export class CatalogContainerComponent {
.subscribe(([params, queryParams]) => { .subscribe(([params, queryParams]) => {
const categoryId = Number(params.get('id')) || null; const categoryId = Number(params.get('id')) || null;
const searchQuery = (queryParams.get('q') ?? '').trim(); const searchQuery = (queryParams.get('q') ?? '').trim();
if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
return;
}
this.enterCategory(categoryId); this.enterCategory(categoryId);
if (searchQuery.length > 0) { if (searchQuery.length > 0) {
@@ -195,6 +211,7 @@ export class CatalogContainerComponent {
onSearchQueryChange(query: string): void { onSearchQueryChange(query: string): void {
this.state.update(current => ({ ...current, search: query })); this.state.update(current => ({ ...current, search: query }));
this.persistContinueBrowsing();
const normalized = query.trim().toLowerCase(); const normalized = query.trim().toLowerCase();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) { if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
@@ -228,6 +245,7 @@ export class CatalogContainerComponent {
this.recentSearches.set(this.searchHistory().slice(0, 5)); this.recentSearches.set(this.searchHistory().slice(0, 5));
} }
this.persistContinueBrowsing();
this.loadCatalog(); this.loadCatalog();
} }
@@ -247,6 +265,106 @@ export class CatalogContainerComponent {
this.recentSearches.set([]); 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<void> {
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 { onFilterStateChange(next: CatalogFilterStateValue): void {
this.filterState.set(next); this.filterState.set(next);
this.state.update(current => ({ this.state.update(current => ({
@@ -259,11 +377,13 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.persistContinueBrowsing();
} }
resetFilters(): void { resetFilters(): void {
this.filterState.set({ values: {}, ranges: {}, toggles: {} }); this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.recomputeResults(); this.recomputeResults();
this.persistContinueBrowsing();
} }
changeSort(sortId: string): void { changeSort(sortId: string): void {
@@ -278,10 +398,12 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.persistContinueBrowsing();
} }
changeLayout(layout: CatalogLayoutMode): void { changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout })); this.state.update(current => ({ ...current, layout }));
this.persistContinueBrowsing();
} }
onResultsPageChange(page: number): void { onResultsPageChange(page: number): void {
@@ -295,6 +417,7 @@ export class CatalogContainerComponent {
})); }));
this.recomputeResults(); this.recomputeResults();
this.persistContinueBrowsing();
} }
private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void { private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void {
@@ -496,6 +619,12 @@ export class CatalogContainerComponent {
this.searchResult.set(result); this.searchResult.set(result);
this.searchSummary.set(result.summary); this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted)); this.searchSuggestions.set(this.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[] { 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 { private setError(message: string): void {
this.error.set(message); this.error.set(message);
this.loading.set(false); this.loading.set(false);

View File

@@ -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 { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model';
import { ConfigService } from '../../../../core/config/config.service'; import { ConfigService } from '../../../../core/config/config.service';
import { ProductFacade } from '../../../../facades/platform/product.facade'; import { ProductFacade } from '../../../../facades/platform/product.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../services'; import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service'; 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 { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -52,10 +53,12 @@ export class ProductDetailsContainerComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService); private readonly configService = inject(ConfigService);
private readonly productFacade = inject(ProductFacade); private readonly productFacade = inject(ProductFacade);
private readonly uxFacade = inject(UserExperienceFacade);
private readonly cartService = inject(CartService); private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService); private readonly languageService = inject(LanguageService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig()); readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly product = signal<Product | null>(null); readonly product = signal<Product | null>(null);
readonly relatedProducts = signal<Product[]>([]); readonly relatedProducts = signal<Product[]>([]);
@@ -178,6 +181,9 @@ export class ProductDetailsContainerComponent {
} }
this.product.set(product); this.product.set(product);
if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
}
this.initVariantSelection(product); this.initVariantSelection(product);
this.loadRelatedProducts(product); this.loadRelatedProducts(product);
this.loadEngagement(product.itemID); 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
}
};
}
} }

View File

@@ -0,0 +1,26 @@
@if (products.length > 0) {
<div class="compare-table-wrap">
<table class="compare-table">
<thead>
<tr>
<th>Attribute</th>
@for (product of products; track product.itemID) {
<th>
<div class="compare-product-title">{{ product.name }}</div>
</th>
}
</tr>
</thead>
<tbody>
@for (row of rows(); track row.key) {
<tr [class.compare-different]="isDifferentRow(row)">
<th>{{ row.label }}</th>
@for (value of row.values; track $index) {
<td>{{ value }}</td>
}
</tr>
}
</tbody>
</table>
</div>
}

View File

@@ -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;
}
}

View File

@@ -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<CompareRow[]>(() => {
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 };
}
}

View File

@@ -0,0 +1,46 @@
<main class="compare-page page-container">
<header class="compare-head">
<div>
<h1>{{ 'ux.compareTitle' | translate }}</h1>
<p>{{ products().length }} {{ 'ux.items' | translate }}</p>
</div>
@if (hasItems()) {
<button type="button" class="btn btn-ghost" (click)="clear()">{{ 'ux.clearCompare' | translate }}</button>
}
</header>
@if (hasItems()) {
<section class="compare-controls card">
<label>
<input type="checkbox" [checked]="hideIdentical()" (change)="hideIdentical.set($any($event.target).checked)" />
{{ 'ux.hideIdentical' | translate }}
</label>
<label>
<input type="checkbox" [checked]="highlightDifferences()" (change)="highlightDifferences.set($any($event.target).checked)" />
{{ 'ux.highlightDifferences' | translate }}
</label>
</section>
<section class="compare-products-list">
@for (product of products(); track product.itemID) {
<article class="compare-product-chip">
<a [routerLink]="['/product', product.itemID] | langRoute">{{ product.name }}</a>
<button type="button" (click)="remove(product.itemID)">×</button>
</article>
}
</section>
<app-compare-table
[products]="products()"
[hideIdentical]="hideIdentical()"
[highlightDifferences]="highlightDifferences()" />
} @else {
<section class="compare-empty card">
<h2>{{ 'ux.compareEmptyTitle' | translate }}</h2>
<p>{{ 'ux.compareEmptyDescription' | translate }}</p>
<a [routerLink]="'/catalog' | langRoute" class="btn btn-primary">{{ 'ux.goToCatalog' | translate }}</a>
</section>
}
</main>

View File

@@ -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);
}

View File

@@ -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<Product[]>(() => 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
};
}
}

View File

@@ -0,0 +1,10 @@
@if (notifications().length > 0) {
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
@for (note of notifications(); track note.id) {
<article class="floating-note" [class]="'floating-note floating-note-' + note.type">
<p>{{ note.message }}</p>
<button type="button" (click)="dismiss(note.id)" aria-label="Dismiss">×</button>
</article>
}
</aside>
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,26 @@
<section class="recently-viewed card section">
<header class="recently-viewed-head">
<h2>{{ title }}</h2>
@if (showClear && hasItems()) {
<button type="button" class="btn btn-ghost" (click)="clear()">{{ 'ux.clearRecentlyViewed' | translate }}</button>
}
</header>
@if (hasItems()) {
<div class="recently-viewed-grid">
@for (entry of items(); track entry.product.itemID) {
<app-product-card
[item]="entry.product"
[appearance]="'compact'"
[showDescription]="false"
[showStock]="false"
[showRating]="true"
[showDiscountBadge]="true"
[addToCartLabel]="'catalog.addToCart' | translate"
(addToCart)="addToCart(entry.product.itemID, $event.event)" />
}
</div>
} @else {
<p class="recently-viewed-empty">{{ 'ux.recentlyViewedEmpty' | translate }}</p>
}
</section>

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -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<ShareResult> {
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';
}
}

View File

@@ -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<UserNotification[]>([]);
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));
}
}

View File

@@ -0,0 +1,35 @@
<main class="wishlist-page page-container">
<header class="wishlist-head">
<div>
<h1>{{ 'ux.wishlistTitle' | translate }}</h1>
<p>{{ favorites().length }} {{ 'ux.items' | translate }}</p>
</div>
@if (hasItems()) {
<button type="button" class="btn btn-ghost" (click)="clearWishlist()">{{ 'ux.clearWishlist' | translate }}</button>
}
</header>
@if (hasItems()) {
<section class="wishlist-grid grid grid-4">
@for (entry of favorites(); track entry.product.itemID) {
<div class="wishlist-item">
<app-product-card
[item]="entry.product"
[showFavoriteAction]="true"
[showCompareAction]="false"
[showShareAction]="false"
[isFavorite]="true"
[addToCartLabel]="'catalog.addToCart' | translate"
(addToCart)="addToCart(entry.product.itemID, $event.event)"
(favoriteToggled)="removeFromWishlist(entry.product.itemID)" />
</div>
}
</section>
} @else {
<section class="wishlist-empty card">
<h2>{{ 'ux.wishlistEmptyTitle' | translate }}</h2>
<p>{{ 'ux.wishlistEmptyDescription' | translate }}</p>
<a [routerLink]="'/catalog' | langRoute" class="btn btn-primary">{{ 'ux.goToCatalog' | translate }}</a>
</section>
}
</main>

View File

@@ -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);
}

View File

@@ -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');
}
}

View File

@@ -8,6 +8,8 @@ export const en: Translations = {
contacts: 'Contacts', contacts: 'Contacts',
searchPlaceholder: 'Search...', searchPlaceholder: 'Search...',
catalog: 'Catalog', catalog: 'Catalog',
wishlist: 'Wishlist',
compare: 'Compare',
}, },
footer: { footer: {
description: 'A modern marketplace for comfortable shopping', description: 'A modern marketplace for comfortable shopping',
@@ -243,4 +245,21 @@ export const en: Translations = {
qrExpired: 'QR code expired. Click to refresh', qrExpired: 'QR code expired. Click to refresh',
qrError: 'Could not create login session. Click to retry', 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',
},
}; };

View File

@@ -8,6 +8,8 @@ export const hy: Translations = {
contacts: 'Կապ', contacts: 'Կապ',
searchPlaceholder: 'Փնտրել...', searchPlaceholder: 'Փնտրել...',
catalog: 'Կատալոգ', catalog: 'Կատալոգ',
wishlist: 'Ընտրյալ',
compare: 'Համեմատում',
}, },
footer: { footer: {
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար', description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
@@ -243,4 +245,21 @@ export const hy: Translations = {
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
}, },
ux: {
items: 'ապրանք',
wishlistTitle: 'Ընտրյալներ',
wishlistEmptyTitle: 'Ընտրյալները դատարկ են',
wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։',
clearWishlist: 'Մաքրել ընտրյալները',
compareTitle: 'Ապրանքների համեմատում',
compareEmptyTitle: 'Համեմատելու ապրանքներ չկան',
compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։',
clearCompare: 'Մաքրել համեմատումը',
hideIdentical: 'Թաքցնել նույն արժեքները',
highlightDifferences: 'Ընդգծել տարբերությունները',
clearRecentlyViewed: 'Մաքրել դիտվածները',
recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։',
saveSearch: 'Պահպանել որոնումը',
goToCatalog: 'Գնալ կատալոգ',
},
}; };

View File

@@ -8,6 +8,8 @@ export const ru: Translations = {
contacts: 'Контакты', contacts: 'Контакты',
searchPlaceholder: 'Искать...', searchPlaceholder: 'Искать...',
catalog: 'Каталог', catalog: 'Каталог',
wishlist: 'Избранное',
compare: 'Сравнение',
}, },
footer: { footer: {
description: 'Современный маркетплейс для комфортных покупок', description: 'Современный маркетплейс для комфортных покупок',
@@ -243,4 +245,21 @@ export const ru: Translations = {
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
}, },
ux: {
items: 'товаров',
wishlistTitle: 'Избранное',
wishlistEmptyTitle: 'Избранное пока пусто',
wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.',
clearWishlist: 'Очистить избранное',
compareTitle: 'Сравнение товаров',
compareEmptyTitle: 'Нет товаров для сравнения',
compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.',
clearCompare: 'Очистить сравнение',
hideIdentical: 'Скрыть одинаковые значения',
highlightDifferences: 'Подсвечивать различия',
clearRecentlyViewed: 'Очистить просмотренные',
recentlyViewedEmpty: 'Вы пока не просматривали товары.',
saveSearch: 'Сохранить поиск',
goToCatalog: 'Перейти в каталог',
},
}; };

View File

@@ -6,6 +6,8 @@ export interface Translations {
contacts: string; contacts: string;
searchPlaceholder: string; searchPlaceholder: string;
catalog: string; catalog: string;
wishlist: string;
compare: string;
}; };
footer: { footer: {
description: string; description: string;
@@ -241,4 +243,21 @@ export interface Translations {
qrExpired: string; qrExpired: string;
qrError: 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;
};
} }

View File

@@ -43,7 +43,7 @@ import { Category } from '../../core/categories/models/category-domain.model';
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false" [class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
> >
@if (resolveWidget(widget, section, model.id) | async; as resolved) { @if (resolveWidget(widget, section, model.id) | async; as resolved) {
<ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data }; outputs: { categorySelected: onCategorySelected.bind(this) }"></ng-container> <ng-container *ngComponentOutlet="$any(resolved).component; inputs: { section: $any(resolved).section, data: $any(resolved).data, categorySelectedCallback: onCategorySelected.bind(this) }"></ng-container>
} }
</div> </div>
} }

View File

@@ -14,6 +14,7 @@ import { SeoConfig } from './seo.model';
import { StaticPagesConfig } from './static-page.model'; import { StaticPagesConfig } from './static-page.model';
import { TenantConfig } from './tenant.model'; import { TenantConfig } from './tenant.model';
import { ThemeConfig } from './theme.model'; import { ThemeConfig } from './theme.model';
import { UserExperienceConfig } from './user-experience-config.model';
import { WidgetRegistryConfig } from './widget-registry.model'; import { WidgetRegistryConfig } from './widget-registry.model';
export interface BootstrapConfig { export interface BootstrapConfig {
@@ -33,6 +34,7 @@ export interface BootstrapConfig {
navigation: NavigationConfig; navigation: NavigationConfig;
footer?: FooterConfig; footer?: FooterConfig;
productPage?: ProductPageConfig; productPage?: ProductPageConfig;
userExperience?: UserExperienceConfig;
pages: PageConfig[]; pages: PageConfig[];
staticPages?: StaticPagesConfig; staticPages?: StaticPagesConfig;
widgetRegistry?: WidgetRegistryConfig; widgetRegistry?: WidgetRegistryConfig;

View File

@@ -16,5 +16,6 @@ export * from './seo.model';
export * from './static-page.model'; export * from './static-page.model';
export * from './tenant.model'; export * from './tenant.model';
export * from './theme.model'; export * from './theme.model';
export * from './user-experience-config.model';
export * from './widget.model'; export * from './widget.model';
export * from './widget-registry.model'; export * from './widget-registry.model';

View File

@@ -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<UserExperienceConfig> = {
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
}
};

View File

@@ -1,7 +1,7 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, map, of } from 'rxjs'; import { Observable, map, of } from 'rxjs';
import { catchError } from 'rxjs/operators'; 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 { RegisteredWidget } from '../contracts/widget-component.contract';
import { WidgetRegistryService } from './widget-registry.service'; import { WidgetRegistryService } from './widget-registry.service';
import { WidgetManifestService } from './widget-manifest.service'; import { WidgetManifestService } from './widget-manifest.service';
@@ -12,7 +12,8 @@ const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']>
'footer': FooterNavigationWidgetComponent, 'footer': FooterNavigationWidgetComponent,
'footer-navigation': FooterNavigationWidgetComponent, 'footer-navigation': FooterNavigationWidgetComponent,
'product-carousel': ProductCarouselWidgetComponent, 'product-carousel': ProductCarouselWidgetComponent,
'product-collection': ProductCarouselWidgetComponent 'product-collection': ProductCarouselWidgetComponent,
'recently-viewed': RecentlyViewedWidgetComponent
}; };
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })

View File

@@ -42,6 +42,8 @@ export class DataSourceResolverService {
case 'footer': case 'footer':
case 'footer-navigation': case 'footer-navigation':
return of(this.toFooterData(section, settings)); return of(this.toFooterData(section, settings));
case 'recently-viewed':
return of({ section, settings });
default: default:
return of({ section, settings }); return of({ section, settings });
} }

View File

@@ -18,7 +18,7 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
@if (widgetData.categories.length) { @if (widgetData.categories.length) {
<app-catalog-category-grid <app-catalog-category-grid
[categories]="widgetData.categories" [categories]="widgetData.categories"
(categorySelected)="onCategorySelected($event)" (categorySelected)="handleCategorySelected($event)"
/> />
} @else if (widgetData.emptyMessage) { } @else if (widgetData.emptyMessage) {
<p class="categories-widget__empty">{{ widgetData.emptyMessage }}</p> <p class="categories-widget__empty">{{ widgetData.emptyMessage }}</p>
@@ -37,9 +37,11 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
export class CategoriesWidgetComponent { export class CategoriesWidgetComponent {
@Input() section: SectionConfig | null = null; @Input() section: SectionConfig | null = null;
@Input() data: CategoriesWidgetData | null = null; @Input() data: CategoriesWidgetData | null = null;
@Input() categorySelectedCallback: ((category: unknown) => void) | null = null;
@Output() categorySelected = new EventEmitter<unknown>(); @Output() categorySelected = new EventEmitter<unknown>();
onCategorySelected(category: unknown): void { handleCategorySelected(category: unknown): void {
this.categorySelectedCallback?.(category);
this.categorySelected.emit(category); this.categorySelected.emit(category);
} }
} }

View File

@@ -2,4 +2,5 @@ export * from './categories-widget.component';
export * from './footer-navigation-widget.component'; export * from './footer-navigation-widget.component';
export * from './hero-widget.component'; export * from './hero-widget.component';
export * from './product-carousel-widget.component'; export * from './product-carousel-widget.component';
export * from './recently-viewed-widget.component';
export * from './unknown-widget.component'; export * from './unknown-widget.component';

View File

@@ -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: `
<app-recently-viewed-strip
[title]="title"
[maxItems]="maxItems"
[showClear]="showClear"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RecentlyViewedWidgetComponent {
@Input() section: SectionConfig | null = null;
@Input() data: { settings?: Record<string, unknown> } | 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;
}
}

View File

@@ -282,6 +282,33 @@
"enabled": true "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": { "widgetRegistry": {
"manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json"
}, },

View File

@@ -125,6 +125,27 @@
}, },
"enabled": true "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", "type": "banner",
"version": "1.0.0", "version": "1.0.0",