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

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,17 @@
(suggestionSelected)="useSuggestion($event)"
(recentSelected)="useRecentSearch($event)"
(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>
@if (loading()) {
@@ -98,6 +109,10 @@
[selected]="state().sort"
(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
[active]="state().layout"
[available]="availableLayouts"
@@ -122,10 +137,16 @@
[showRatings]="catalogConfig().showRatings"
[showDiscounts]="catalogConfig().showDiscounts"
[showAvailability]="catalogConfig().showAvailability"
[showShareAction]="userExperienceConfig().share.enabled"
[favoriteIds]="favoriteIds()"
[comparedIds]="comparedIds()"
(pageChange)="onResultsPageChange($event)"
(productSelected)="selectProduct($event)"
(addToCart)="addToCart($event)"
(productPreview)="previewProduct($event)" />
(productPreview)="previewProduct($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(compareToggled)="onCompareToggled($event)"
(shareRequested)="onShareRequested($event)" />
</div>
</section>
}

View File

@@ -12,6 +12,35 @@
margin-bottom: 24px;
}
.catalog-saved-searches {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.catalog-saved-chip {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--bg-primary);
padding: 4px 8px;
}
.catalog-saved-chip button {
border: 0;
background: transparent;
color: var(--text-primary);
font-weight: 600;
cursor: pointer;
}
.catalog-saved-chip-remove {
color: var(--text-secondary);
line-height: 1;
}
.catalog-root-link {
width: fit-content;
color: #1e3c38;
@@ -72,6 +101,17 @@
justify-content: space-between;
}
.catalog-save-search-btn {
min-height: 36px;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-primary);
color: var(--text-primary);
padding: 0 12px;
font-weight: 700;
cursor: pointer;
}
.catalog-category-banner {
padding: 16px;
background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb);

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 { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { combineLatest } from 'rxjs';
@@ -9,12 +9,13 @@ import { Product } from '../../../../core/products/models/product-domain.model';
import { ConfigService } from '../../../../core/config/config.service';
import { CategoryFacade } from '../../../../facades/platform/category.facade';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service';
import { PrefetchService } from '../../../../services/prefetch.service';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { DEFAULT_CATALOG_CONFIG } from '../../../../shared/models/config';
import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config';
import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component';
import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component';
import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component';
@@ -23,6 +24,8 @@ import { CatalogSearchResultsComponent } from '../components/search-results/sear
import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component';
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
import { CatalogSearchHistoryService } from '../services/catalog-search-history.service';
import { ProductShareService } from '../../user-experience/services/product-share.service';
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
type CatalogViewMode = 'categories' | 'products';
@@ -55,8 +58,12 @@ export class CatalogContainerComponent {
private readonly prefetchService = inject(PrefetchService);
private readonly languageService = inject(LanguageService);
private readonly searchHistoryService = inject(CatalogSearchHistoryService);
private readonly uxFacade = inject(UserExperienceFacade);
private readonly shareService = inject(ProductShareService);
private readonly notifications = inject(UserNotificationService);
readonly catalogConfig = signal(this.resolveCatalogConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly state = signal<CatalogState>(createInitialCatalogState());
readonly categories = signal<Category[]>([]);
@@ -71,11 +78,14 @@ export class CatalogContainerComponent {
readonly searchSuggestions = signal<string[]>([]);
readonly recentSearches = signal<string[]>([]);
readonly searchHistory = signal<string[]>(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []);
readonly savedSearches = this.uxFacade.savedSearches;
readonly viewMode = signal<CatalogViewMode>('categories');
readonly loading = signal(true);
readonly loadingProducts = signal(false);
readonly error = signal<string | null>(null);
readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0);
readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID));
readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID));
readonly sortDefinitions = computed<SortDefinition[]>(() => {
const labels: Record<string, string> = {
@@ -97,6 +107,7 @@ export class CatalogContainerComponent {
private dataSubscription?: Subscription;
private readonly backendFetchSize = 200;
private pendingScrollY: number | null = null;
constructor() {
this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe());
@@ -107,6 +118,11 @@ export class CatalogContainerComponent {
.subscribe(([params, queryParams]) => {
const categoryId = Number(params.get('id')) || null;
const searchQuery = (queryParams.get('q') ?? '').trim();
if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) {
return;
}
this.enterCategory(categoryId);
if (searchQuery.length > 0) {
@@ -195,6 +211,7 @@ export class CatalogContainerComponent {
onSearchQueryChange(query: string): void {
this.state.update(current => ({ ...current, search: query }));
this.persistContinueBrowsing();
const normalized = query.trim().toLowerCase();
if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) {
@@ -228,6 +245,7 @@ export class CatalogContainerComponent {
this.recentSearches.set(this.searchHistory().slice(0, 5));
}
this.persistContinueBrowsing();
this.loadCatalog();
}
@@ -247,6 +265,106 @@ export class CatalogContainerComponent {
this.recentSearches.set([]);
}
onFavoriteToggled(product: Product): void {
const result = this.uxFacade.toggleWishlist(product);
this.notifications.show(result.added ? 'Added to wishlist' : 'Removed from wishlist', result.added ? 'success' : 'info');
}
onCompareToggled(product: Product): void {
if (this.uxFacade.isInCompare(product.itemID)) {
this.uxFacade.removeFromCompare(product.itemID);
this.notifications.show('Removed from compare', 'info');
return;
}
const maxItems = this.userExperienceConfig().compare.maxItems;
const result = this.uxFacade.addToCompare(product, maxItems);
if (result.reason === 'limit') {
this.notifications.show(`Compare limit reached (${maxItems})`, 'warning');
return;
}
this.notifications.show('Added to compare', 'success');
}
async onShareRequested(product: Product): Promise<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 {
this.filterState.set(next);
this.state.update(current => ({
@@ -259,11 +377,13 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
this.persistContinueBrowsing();
}
resetFilters(): void {
this.filterState.set({ values: {}, ranges: {}, toggles: {} });
this.recomputeResults();
this.persistContinueBrowsing();
}
changeSort(sortId: string): void {
@@ -278,10 +398,12 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
this.persistContinueBrowsing();
}
changeLayout(layout: CatalogLayoutMode): void {
this.state.update(current => ({ ...current, layout }));
this.persistContinueBrowsing();
}
onResultsPageChange(page: number): void {
@@ -295,6 +417,7 @@ export class CatalogContainerComponent {
}));
this.recomputeResults();
this.persistContinueBrowsing();
}
private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void {
@@ -496,6 +619,12 @@ export class CatalogContainerComponent {
this.searchResult.set(result);
this.searchSummary.set(result.summary);
this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted));
if (this.pendingScrollY != null && typeof window !== 'undefined') {
const scrollY = this.pendingScrollY;
this.pendingScrollY = null;
setTimeout(() => window.scrollTo({ top: scrollY, behavior: 'auto' }), 0);
}
}
private buildSuggestions(query: string, products: Product[]): string[] {
@@ -646,6 +775,96 @@ export class CatalogContainerComponent {
};
}
private resolveUserExperienceConfig() {
const snapshot = this.configService.getBootstrapSnapshot() as any;
const raw = snapshot?.userExperience ?? {};
return {
...DEFAULT_USER_EXPERIENCE_CONFIG,
...raw,
wishlist: {
...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist,
...(raw.wishlist ?? {})
},
compare: {
...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
...(raw.compare ?? {}),
maxItems: Number.isFinite(raw.compare?.maxItems)
? Math.max(2, Number(raw.compare.maxItems))
: DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems
},
recentlyViewed: {
...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed,
...(raw.recentlyViewed ?? {})
},
share: {
...DEFAULT_USER_EXPERIENCE_CONFIG.share,
...(raw.share ?? {})
},
continueBrowsing: {
...DEFAULT_USER_EXPERIENCE_CONFIG.continueBrowsing,
...(raw.continueBrowsing ?? {})
},
savedSearches: {
...DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches,
...(raw.savedSearches ?? {}),
maxItems: Number.isFinite(raw.savedSearches?.maxItems)
? Math.max(1, Number(raw.savedSearches.maxItems))
: DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches.maxItems
}
};
}
private persistContinueBrowsing(): void {
if (!this.userExperienceConfig().continueBrowsing.enabled || this.viewMode() !== 'products') {
return;
}
const state = this.state();
this.uxFacade.saveContinueBrowsing({
routeKey: 'catalog',
query: state.search,
sort: state.sort,
layout: state.layout,
page: state.pagination.page,
pageSize: state.pagination.count,
categoryId: state.filters.categoryIds[0],
filterState: this.filterState(),
scrollY: typeof window !== 'undefined' ? window.scrollY : 0,
updatedAt: new Date().toISOString()
});
}
private restoreContinueBrowsing(): boolean {
const saved = this.uxFacade.getContinueBrowsing('catalog');
if (!saved) {
return false;
}
this.viewMode.set('products');
this.filterState.set(saved.filterState);
this.state.update(current => ({
...current,
search: saved.query,
sort: saved.sort as CatalogState['sort'],
layout: saved.layout as CatalogLayoutMode,
pagination: {
...current.pagination,
page: Math.max(1, saved.page),
count: Math.max(1, saved.pageSize),
skip: Math.max(0, (saved.page - 1) * saved.pageSize)
},
filters: {
...current.filters,
categoryIds: saved.categoryId == null ? [] : [saved.categoryId]
}
}));
this.pendingScrollY = Math.max(0, saved.scrollY || 0);
this.loadCatalog(saved.categoryId);
return true;
}
private setError(message: string): void {
this.error.set(message);
this.loading.set(false);

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 { ConfigService } from '../../../../core/config/config.service';
import { ProductFacade } from '../../../../facades/platform/product.facade';
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
import { TranslatePipe } from '../../../../i18n/translate.pipe';
import { LangRoutePipe } from '../../../../pipes/lang-route.pipe';
import { CartService } from '../../../../services';
import { LanguageService } from '../../../../services/language.service';
import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config';
import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils';
import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component';
import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component';
@@ -52,10 +53,12 @@ export class ProductDetailsContainerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly configService = inject(ConfigService);
private readonly productFacade = inject(ProductFacade);
private readonly uxFacade = inject(UserExperienceFacade);
private readonly cartService = inject(CartService);
private readonly languageService = inject(LanguageService);
readonly productPageConfigState = signal<Required<ProductPageConfig>>(this.resolveProductPageConfig());
readonly userExperienceConfig = signal(this.resolveUserExperienceConfig());
readonly product = signal<Product | null>(null);
readonly relatedProducts = signal<Product[]>([]);
@@ -178,6 +181,9 @@ export class ProductDetailsContainerComponent {
}
this.product.set(product);
if (this.userExperienceConfig().recentlyViewed.enabled) {
this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems);
}
this.initVariantSelection(product);
this.loadRelatedProducts(product);
this.loadEngagement(product.itemID);
@@ -461,4 +467,20 @@ export class ProductDetailsContainerComponent {
}
};
}
private resolveUserExperienceConfig() {
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
return {
...DEFAULT_USER_EXPERIENCE_CONFIG,
...raw,
recentlyViewed: {
...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed,
...(raw.recentlyViewed ?? {}),
maxItems: Number.isFinite(raw.recentlyViewed?.maxItems)
? Math.max(1, Number(raw.recentlyViewed.maxItems))
: DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed.maxItems
}
};
}
}

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