feat(ux): implement sprint 11 user experience module
This commit is contained in:
@@ -18,6 +18,7 @@
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-floating-notifications />
|
||||
@defer (on viewport) {
|
||||
<app-footer></app-footer>
|
||||
} @placeholder {
|
||||
|
||||
@@ -38,6 +38,14 @@ const coreRoutes: Routes = [
|
||||
path: 'search',
|
||||
loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent)
|
||||
},
|
||||
{
|
||||
path: 'wishlist',
|
||||
loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent)
|
||||
},
|
||||
{
|
||||
path: 'compare',
|
||||
loadComponent: () => import('./features/website/user-experience/compare/containers/compare-page.component').then(m => m.ComparePageComponent)
|
||||
},
|
||||
{
|
||||
path: 'cart',
|
||||
loadComponent: () => import('./pages/cart/cart.component').then(m => m.CartComponent)
|
||||
|
||||
@@ -14,10 +14,11 @@ import { TranslateService } from './i18n/translate.service';
|
||||
import { PlatformRuntimeService } from './core/runtime/platform-runtime.service';
|
||||
import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade';
|
||||
import { ApiHealthService } from './services/api-health.service';
|
||||
import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe],
|
||||
imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
|
||||
<!-- Right 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 -->
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="dexar-cart-active" class="dexar-cart-btn" (click)="closeMenu()">
|
||||
<span class="dexar-cart-icon">
|
||||
|
||||
@@ -632,6 +632,50 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dexar-ux-btn {
|
||||
position: relative;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid #d3dad9;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
color: #1e3c38;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.dexar-ux-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #497671;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.dexar-ux-icon {
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dexar-ux-badge {
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
top: -6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: #497671;
|
||||
color: #ffffff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.dexar-cart-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,6 +8,9 @@ import { RegionSelectorComponent } from '../region-selector/region-selector.comp
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade';
|
||||
import { UserExperienceFacade } from '../../facades/platform/user-experience.facade';
|
||||
import { ConfigService } from '../../core/config/config.service';
|
||||
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config';
|
||||
|
||||
@Component({
|
||||
selector: 'app-header',
|
||||
@@ -25,6 +28,12 @@ export class HeaderComponent {
|
||||
private document = inject(DOCUMENT);
|
||||
private langService = inject(LanguageService);
|
||||
private uiRuntime = inject(UiRuntimeFacade);
|
||||
private uxFacade = inject(UserExperienceFacade);
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
readonly wishlistCount = this.uxFacade.wishlistCount;
|
||||
readonly compareCount = this.uxFacade.compareCount;
|
||||
readonly userExperienceConfig = this.resolveUserExperienceConfig();
|
||||
|
||||
constructor(private cartService: CartService, private router: Router) {
|
||||
this.cartItemCount = this.cartService.itemCount;
|
||||
@@ -89,6 +98,18 @@ export class HeaderComponent {
|
||||
});
|
||||
}
|
||||
|
||||
navigateToWishlist(): void {
|
||||
this.closeMenu();
|
||||
const lang = this.langService.currentLanguage();
|
||||
this.router.navigate([`/${lang}/wishlist`]);
|
||||
}
|
||||
|
||||
navigateToCompare(): void {
|
||||
this.closeMenu();
|
||||
const lang = this.langService.currentLanguage();
|
||||
this.router.navigate([`/${lang}/compare`]);
|
||||
}
|
||||
|
||||
formatCartTotal(total: number): string {
|
||||
const locale = this.langService.currentLanguage() === 'en'
|
||||
? 'en-US'
|
||||
@@ -104,4 +125,21 @@ export class HeaderComponent {
|
||||
|
||||
return `${amount} ${currencySymbol}`;
|
||||
}
|
||||
|
||||
private resolveUserExperienceConfig() {
|
||||
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {};
|
||||
|
||||
return {
|
||||
...DEFAULT_USER_EXPERIENCE_CONFIG,
|
||||
...raw,
|
||||
wishlist: {
|
||||
...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist,
|
||||
...(raw.wishlist ?? {})
|
||||
},
|
||||
compare: {
|
||||
...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
|
||||
...(raw.compare ?? {})
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,16 +9,19 @@
|
||||
<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">
|
||||
@if (showFavoritePlaceholder) {
|
||||
<button type="button" (click)="onFavoritePlaceholder($event)">Fav</button>
|
||||
@if (showFavoriteControl()) {
|
||||
<button type="button" class="product-action-btn product-action-favorite" [class.active]="isFavorite" (click)="onFavoritePlaceholder($event)">❤</button>
|
||||
}
|
||||
@if (showComparePlaceholder) {
|
||||
<button type="button" (click)="onComparePlaceholder($event)">Compare</button>
|
||||
@if (showCompareControl()) {
|
||||
<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) {
|
||||
<button type="button" (click)="onQuickViewPlaceholder($event)">Quick</button>
|
||||
<button type="button" class="product-action-btn" (click)="onQuickViewPlaceholder($event)">○</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -90,16 +90,46 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.product-actions-overlay button {
|
||||
min-height: 24px;
|
||||
.product-action-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(30, 60, 56, 0.15);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #1e3c38;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
padding: 0 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.product-action-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: #497671;
|
||||
}
|
||||
|
||||
.product-action-btn.active {
|
||||
border-color: #497671;
|
||||
color: #497671;
|
||||
}
|
||||
|
||||
.product-action-favorite.active {
|
||||
animation: favorite-pulse 280ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes favorite-pulse {
|
||||
0% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.product-badges-overlay {
|
||||
|
||||
@@ -28,6 +28,11 @@ export class ProductCardComponent {
|
||||
@Input() showFavoritePlaceholder = false;
|
||||
@Input() showComparePlaceholder = false;
|
||||
@Input() showQuickViewPlaceholder = false;
|
||||
@Input() showFavoriteAction = false;
|
||||
@Input() showCompareAction = false;
|
||||
@Input() showShareAction = false;
|
||||
@Input() isFavorite = false;
|
||||
@Input() isCompared = false;
|
||||
|
||||
@Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>();
|
||||
@Output() preview = new EventEmitter<number>();
|
||||
@@ -35,6 +40,9 @@ export class ProductCardComponent {
|
||||
@Output() favoritePlaceholder = new EventEmitter<number>();
|
||||
@Output() comparePlaceholder = 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 getDiscountedPrice = getDiscountedPrice;
|
||||
@@ -52,12 +60,14 @@ export class ProductCardComponent {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.favoritePlaceholder.emit(this.item.itemID);
|
||||
this.favoriteToggled.emit(this.item.itemID);
|
||||
}
|
||||
|
||||
onComparePlaceholder(event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.comparePlaceholder.emit(this.item.itemID);
|
||||
this.compareToggled.emit(this.item.itemID);
|
||||
}
|
||||
|
||||
onQuickViewPlaceholder(event: Event): void {
|
||||
@@ -65,4 +75,18 @@ export class ProductCardComponent {
|
||||
event.stopPropagation();
|
||||
this.quickViewPlaceholder.emit(this.item.itemID);
|
||||
}
|
||||
|
||||
onShare(event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.shareRequested.emit(this.item.itemID);
|
||||
}
|
||||
|
||||
showFavoriteControl(): boolean {
|
||||
return this.showFavoriteAction || this.showFavoritePlaceholder;
|
||||
}
|
||||
|
||||
showCompareControl(): boolean {
|
||||
return this.showCompareAction || this.showComparePlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
48
src/app/core/user-experience/models/user-experience.model.ts
Normal file
48
src/app/core/user-experience/models/user-experience.model.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
136
src/app/facades/platform/user-experience.facade.ts
Normal file
136
src/app/facades/platform/user-experience.facade.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ export const en: Translations = {
|
||||
contacts: 'Contacts',
|
||||
searchPlaceholder: 'Search...',
|
||||
catalog: 'Catalog',
|
||||
wishlist: 'Wishlist',
|
||||
compare: 'Compare',
|
||||
},
|
||||
footer: {
|
||||
description: 'A modern marketplace for comfortable shopping',
|
||||
@@ -243,4 +245,21 @@ export const en: Translations = {
|
||||
qrExpired: 'QR code expired. Click to refresh',
|
||||
qrError: 'Could not create login session. Click to retry',
|
||||
},
|
||||
ux: {
|
||||
items: 'items',
|
||||
wishlistTitle: 'Wishlist',
|
||||
wishlistEmptyTitle: 'Your wishlist is empty',
|
||||
wishlistEmptyDescription: 'Save products to revisit them anytime.',
|
||||
clearWishlist: 'Clear wishlist',
|
||||
compareTitle: 'Compare Products',
|
||||
compareEmptyTitle: 'No products to compare',
|
||||
compareEmptyDescription: 'Add products from catalog cards to compare specs and prices.',
|
||||
clearCompare: 'Clear compare',
|
||||
hideIdentical: 'Hide identical values',
|
||||
highlightDifferences: 'Highlight differences',
|
||||
clearRecentlyViewed: 'Clear recently viewed',
|
||||
recentlyViewedEmpty: 'No recently viewed products yet.',
|
||||
saveSearch: 'Save search',
|
||||
goToCatalog: 'Go to catalog',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ export const hy: Translations = {
|
||||
contacts: 'Կապ',
|
||||
searchPlaceholder: 'Փնտրել...',
|
||||
catalog: 'Կատալոգ',
|
||||
wishlist: 'Ընտրյալ',
|
||||
compare: 'Համեմատում',
|
||||
},
|
||||
footer: {
|
||||
description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար',
|
||||
@@ -243,4 +245,21 @@ export const hy: Translations = {
|
||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||
qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար',
|
||||
},
|
||||
ux: {
|
||||
items: 'ապրանք',
|
||||
wishlistTitle: 'Ընտրյալներ',
|
||||
wishlistEmptyTitle: 'Ընտրյալները դատարկ են',
|
||||
wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։',
|
||||
clearWishlist: 'Մաքրել ընտրյալները',
|
||||
compareTitle: 'Ապրանքների համեմատում',
|
||||
compareEmptyTitle: 'Համեմատելու ապրանքներ չկան',
|
||||
compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։',
|
||||
clearCompare: 'Մաքրել համեմատումը',
|
||||
hideIdentical: 'Թաքցնել նույն արժեքները',
|
||||
highlightDifferences: 'Ընդգծել տարբերությունները',
|
||||
clearRecentlyViewed: 'Մաքրել դիտվածները',
|
||||
recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։',
|
||||
saveSearch: 'Պահպանել որոնումը',
|
||||
goToCatalog: 'Գնալ կատալոգ',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ export const ru: Translations = {
|
||||
contacts: 'Контакты',
|
||||
searchPlaceholder: 'Искать...',
|
||||
catalog: 'Каталог',
|
||||
wishlist: 'Избранное',
|
||||
compare: 'Сравнение',
|
||||
},
|
||||
footer: {
|
||||
description: 'Современный маркетплейс для комфортных покупок',
|
||||
@@ -243,4 +245,21 @@ export const ru: Translations = {
|
||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||
qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить',
|
||||
},
|
||||
ux: {
|
||||
items: 'товаров',
|
||||
wishlistTitle: 'Избранное',
|
||||
wishlistEmptyTitle: 'Избранное пока пусто',
|
||||
wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.',
|
||||
clearWishlist: 'Очистить избранное',
|
||||
compareTitle: 'Сравнение товаров',
|
||||
compareEmptyTitle: 'Нет товаров для сравнения',
|
||||
compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.',
|
||||
clearCompare: 'Очистить сравнение',
|
||||
hideIdentical: 'Скрыть одинаковые значения',
|
||||
highlightDifferences: 'Подсвечивать различия',
|
||||
clearRecentlyViewed: 'Очистить просмотренные',
|
||||
recentlyViewedEmpty: 'Вы пока не просматривали товары.',
|
||||
saveSearch: 'Сохранить поиск',
|
||||
goToCatalog: 'Перейти в каталог',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ export interface Translations {
|
||||
contacts: string;
|
||||
searchPlaceholder: string;
|
||||
catalog: string;
|
||||
wishlist: string;
|
||||
compare: string;
|
||||
};
|
||||
footer: {
|
||||
description: string;
|
||||
@@ -241,4 +243,21 @@ export interface Translations {
|
||||
qrExpired: string;
|
||||
qrError: string;
|
||||
};
|
||||
ux: {
|
||||
items: string;
|
||||
wishlistTitle: string;
|
||||
wishlistEmptyTitle: string;
|
||||
wishlistEmptyDescription: string;
|
||||
clearWishlist: string;
|
||||
compareTitle: string;
|
||||
compareEmptyTitle: string;
|
||||
compareEmptyDescription: string;
|
||||
clearCompare: string;
|
||||
hideIdentical: string;
|
||||
highlightDifferences: string;
|
||||
clearRecentlyViewed: string;
|
||||
recentlyViewedEmpty: string;
|
||||
saveSearch: string;
|
||||
goToCatalog: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { Category } from '../../core/categories/models/category-domain.model';
|
||||
[class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false"
|
||||
>
|
||||
@if (resolveWidget(widget, section, model.id) | async; as resolved) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SeoConfig } from './seo.model';
|
||||
import { StaticPagesConfig } from './static-page.model';
|
||||
import { TenantConfig } from './tenant.model';
|
||||
import { ThemeConfig } from './theme.model';
|
||||
import { UserExperienceConfig } from './user-experience-config.model';
|
||||
import { WidgetRegistryConfig } from './widget-registry.model';
|
||||
|
||||
export interface BootstrapConfig {
|
||||
@@ -33,6 +34,7 @@ export interface BootstrapConfig {
|
||||
navigation: NavigationConfig;
|
||||
footer?: FooterConfig;
|
||||
productPage?: ProductPageConfig;
|
||||
userExperience?: UserExperienceConfig;
|
||||
pages: PageConfig[];
|
||||
staticPages?: StaticPagesConfig;
|
||||
widgetRegistry?: WidgetRegistryConfig;
|
||||
|
||||
@@ -16,5 +16,6 @@ export * from './seo.model';
|
||||
export * from './static-page.model';
|
||||
export * from './tenant.model';
|
||||
export * from './theme.model';
|
||||
export * from './user-experience-config.model';
|
||||
export * from './widget.model';
|
||||
export * from './widget-registry.model';
|
||||
|
||||
67
src/app/shared/models/config/user-experience-config.model.ts
Normal file
67
src/app/shared/models/config/user-experience-config.model.ts
Normal 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
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable, map, of } from 'rxjs';
|
||||
import { catchError } from 'rxjs/operators';
|
||||
import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui';
|
||||
import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent, RecentlyViewedWidgetComponent } from '../ui';
|
||||
import { RegisteredWidget } from '../contracts/widget-component.contract';
|
||||
import { WidgetRegistryService } from './widget-registry.service';
|
||||
import { WidgetManifestService } from './widget-manifest.service';
|
||||
@@ -12,7 +12,8 @@ const APPROVED_WIDGET_COMPONENTS: Record<string, RegisteredWidget['component']>
|
||||
'footer': FooterNavigationWidgetComponent,
|
||||
'footer-navigation': FooterNavigationWidgetComponent,
|
||||
'product-carousel': ProductCarouselWidgetComponent,
|
||||
'product-collection': ProductCarouselWidgetComponent
|
||||
'product-collection': ProductCarouselWidgetComponent,
|
||||
'recently-viewed': RecentlyViewedWidgetComponent
|
||||
};
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
|
||||
@@ -42,6 +42,8 @@ export class DataSourceResolverService {
|
||||
case 'footer':
|
||||
case 'footer-navigation':
|
||||
return of(this.toFooterData(section, settings));
|
||||
case 'recently-viewed':
|
||||
return of({ section, settings });
|
||||
default:
|
||||
return of({ section, settings });
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
||||
@if (widgetData.categories.length) {
|
||||
<app-catalog-category-grid
|
||||
[categories]="widgetData.categories"
|
||||
(categorySelected)="onCategorySelected($event)"
|
||||
(categorySelected)="handleCategorySelected($event)"
|
||||
/>
|
||||
} @else if (widgetData.emptyMessage) {
|
||||
<p class="categories-widget__empty">{{ widgetData.emptyMessage }}</p>
|
||||
@@ -37,9 +37,11 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract';
|
||||
export class CategoriesWidgetComponent {
|
||||
@Input() section: SectionConfig | null = null;
|
||||
@Input() data: CategoriesWidgetData | null = null;
|
||||
@Input() categorySelectedCallback: ((category: unknown) => void) | null = null;
|
||||
@Output() categorySelected = new EventEmitter<unknown>();
|
||||
|
||||
onCategorySelected(category: unknown): void {
|
||||
handleCategorySelected(category: unknown): void {
|
||||
this.categorySelectedCallback?.(category);
|
||||
this.categorySelected.emit(category);
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export * from './categories-widget.component';
|
||||
export * from './footer-navigation-widget.component';
|
||||
export * from './hero-widget.component';
|
||||
export * from './product-carousel-widget.component';
|
||||
export * from './recently-viewed-widget.component';
|
||||
export * from './unknown-widget.component';
|
||||
|
||||
36
src/app/widgets/ui/recently-viewed-widget.component.ts
Normal file
36
src/app/widgets/ui/recently-viewed-widget.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user