diff --git a/Sprint-11-User-Experience-Report.md b/Sprint-11-User-Experience-Report.md new file mode 100644 index 0000000..57e2175 --- /dev/null +++ b/Sprint-11-User-Experience-Report.md @@ -0,0 +1,173 @@ +# Sprint 11 - User Experience Module Report + +## Scope +Implemented reusable customer experience capabilities for marketplace storefront UX while preserving established architecture constraints. + +Implemented features: +- Wishlist (guest local storage + architecture-ready repository contract) +- Compare (configurable max count, reusable comparison table, compare page) +- Recently Viewed (auto tracking + reusable strip + widget compatibility) +- Continue Browsing (persist/restore filters, sort, page, scroll) +- Saved Searches (architecture + UI integration) +- Product Sharing (Web Share + clipboard fallback) +- Product Card action preparation without duplication +- Floating notifications and heart animation + +## Architectural Constraints Compliance +Confirmed constraints: +- No modifications to authentication, payment, bootstrap loading flow, widget engine core, section engine core. +- No direct HttpClient usage in UI feature components. +- Product data path remains facade-driven. +- Existing ProductFacade contracts preserved (only UX facade added separately). +- Bootstrap contains feature configuration only; no business/user data payload. + +## Implemented Modules and Components + +### Core Domain and Repository Contracts +Added: +- src/app/core/user-experience/models/user-experience.model.ts +- src/app/core/user-experience/repositories/user-experience.repository.ts +- src/app/core/user-experience/repositories/local-user-experience.repository.ts +- src/app/core/user-experience/user-experience-repository.token.ts +- src/app/facades/platform/user-experience.facade.ts + +Purpose: +- Create domain entities: FavoriteItem, ComparedProduct, RecentlyViewedItem, SavedSearch, ContinueBrowsingState. +- Provide repository abstraction for future authenticated backend synchronization. +- Implement guest mode via localStorage repository. + +### Bootstrap Config Extensions +Added: +- src/app/shared/models/config/user-experience-config.model.ts + +Updated: +- src/app/shared/models/config/bootstrap-config.model.ts +- src/app/shared/models/config/index.ts +- src/assets/mock/bootstrap/bootstrap.json + +New bootstrap block: +- userExperience.wishlist +- userExperience.compare +- userExperience.recentlyViewed +- userExperience.share +- userExperience.continueBrowsing +- userExperience.savedSearches + +### Wishlist +Added: +- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts +- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html +- src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss + +Integrated: +- Header badge + counter in header component. +- Route: /wishlist. + +### Compare +Added: +- src/app/features/website/user-experience/compare/components/compare-table.component.ts +- src/app/features/website/user-experience/compare/components/compare-table.component.html +- src/app/features/website/user-experience/compare/components/compare-table.component.scss +- src/app/features/website/user-experience/compare/containers/compare-page.component.ts +- src/app/features/website/user-experience/compare/containers/compare-page.component.html +- src/app/features/website/user-experience/compare/containers/compare-page.component.scss + +Integrated: +- Route: /compare. +- Configurable max items from bootstrap userExperience.compare.maxItems. +- Hide-identical and highlight-differences controls. +- Responsive table wrapper. + +### Recently Viewed +Added: +- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts +- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html +- src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss +- src/app/widgets/ui/recently-viewed-widget.component.ts + +Integrated: +- Auto-tracking in product details container after product load. +- Widget compatibility via widget registry bootstrap + manifest. + +Updated widget integration: +- src/app/widgets/ui/index.ts +- src/app/widgets/registry/widget-registry.bootstrap.service.ts +- src/app/widgets/resolvers/data-source-resolver.service.ts +- src/assets/mock/bootstrap/widget-manifest.json + +### Continue Browsing + Saved Searches +Integrated in catalog container: +- Persist selected filters, sort, page, layout, and scroll position. +- Restore state automatically on entry when route has no explicit category/query. +- Save current search and reuse/delete saved searches. + +Updated: +- src/app/features/website/catalog/containers/catalog-container.component.ts +- src/app/features/website/catalog/containers/catalog-container.component.html +- src/app/features/website/catalog/containers/catalog-container.component.scss + +### Product Sharing and Product Card Actions +Added: +- src/app/features/website/user-experience/services/product-share.service.ts + +Updated Product Card to reusable actions (without duplication): +- src/app/components/product-card/product-card.component.ts +- src/app/components/product-card/product-card.component.html +- src/app/components/product-card/product-card.component.scss + +Updated catalog grid/result propagation: +- src/app/features/website/catalog/components/product-grid/product-grid.component.ts +- src/app/features/website/catalog/components/product-grid/product-grid.component.html +- src/app/features/website/catalog/components/search-results/search-results.component.ts +- src/app/features/website/catalog/components/search-results/search-results.component.html + +### UI/Animation/Notification Enhancements +Added: +- src/app/features/website/user-experience/services/user-notification.service.ts +- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts +- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html +- src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss + +Integrated globally in app shell: +- src/app/app.ts +- src/app/app.html + +Heart animation: +- Added active pulse animation in product card favorite action. + +### Routes and Header +Updated: +- src/app/app.routes.ts (wishlist/compare routes) +- src/app/components/header/header.component.ts +- src/app/components/header/header.component.html +- src/app/components/header/header.component.scss + +### Localization +Updated translation contract and locale dictionaries: +- src/app/i18n/translations.ts +- src/app/i18n/en.ts +- src/app/i18n/ru.ts +- src/app/i18n/hy.ts + +## Documentation Updates +Updated to include Sprint 11 bootstrap and backend contract expectations: +- docs/platform/02-bootstrap-json-spec.md +- docs/platform/06-api-contracts.md +- docs/platform/13-backend-requirements.md +- docs/backend-platform/business-apis.md + +## Validation +Executed: +- npm run build +- npm run arch:check:boundaries +- npm run arch:check:cycles + +Results: +- Build: PASS (existing bundle budget warning remains) +- Architecture boundaries: PASS +- Circular dependencies: PASS + +## Notes +- Guest mode is fully functional using local storage. +- Authenticated synchronization is prepared by repository contracts and can be implemented via alternate repository provider without changing feature UI components. +- Bootstrap remains configuration-only and contains no user/business lists for wishlist/compare/recently viewed/saved searches. diff --git a/docs/backend-platform/business-apis.md b/docs/backend-platform/business-apis.md index de851bc..32c6dcd 100644 --- a/docs/backend-platform/business-apis.md +++ b/docs/backend-platform/business-apis.md @@ -130,6 +130,49 @@ Purpose: Must not change: - acknowledgement contract expected by frontend engagement form. +## /me/wishlist (future-ready) +Purpose: +- authenticated wishlist synchronization across devices. + +High-level response shape: +- wishlist product references +- optional addedAt metadata + +Tenant rule: +- wishlist entries must remain tenant-scoped. + +## /me/compare (future-ready) +Purpose: +- optional compare list synchronization for authenticated users. + +High-level response shape: +- compared product references +- optional addedAt metadata + +Tenant rule: +- compare list must be isolated by tenant + user. + +## /me/saved-searches (future-ready) +Purpose: +- persist and restore saved search presets. + +High-level response shape: +- saved query/filter/sort presets +- timestamps and id + +Tenant rule: +- saved searches must be tenant-scoped and user-scoped. + +## /me/recently-viewed (future-ready) +Purpose: +- synchronize recently viewed product history for authenticated users. + +High-level response shape: +- product references + viewedAt metadata + +Tenant rule: +- history must remain tenant-scoped and privacy-safe. + ## /categories Purpose: - category tree retrieval diff --git a/docs/platform/02-bootstrap-json-spec.md b/docs/platform/02-bootstrap-json-spec.md index 6f8bb57..3b19709 100644 --- a/docs/platform/02-bootstrap-json-spec.md +++ b/docs/platform/02-bootstrap-json-spec.md @@ -56,6 +56,7 @@ Bootstrap JSON является главным конфигурационным - footer - catalog - productPage +- userExperience - staticPages - widgetRegistry - visibility @@ -100,6 +101,24 @@ Bootstrap JSON является главным конфигурационным - productPage.tabs.items: array (description/specifications/reviews/questions/delivery/warranty) - productPage.relatedProducts.enabled: boolean +### User Experience Config (опционально) +- userExperience.wishlist.enabled: boolean +- userExperience.wishlist.headerBadgeEnabled: boolean +- userExperience.compare.enabled: boolean +- userExperience.compare.maxItems: number +- userExperience.compare.hideIdenticalDefault: boolean +- userExperience.compare.highlightDifferencesDefault: boolean +- userExperience.recentlyViewed.enabled: boolean +- userExperience.recentlyViewed.maxItems: number +- userExperience.recentlyViewed.widgetEnabled: boolean +- userExperience.share.enabled: boolean +- userExperience.continueBrowsing.enabled: boolean +- userExperience.savedSearches.enabled: boolean +- userExperience.savedSearches.maxItems: number + +Правило: +- Для userExperience допускаются только feature-конфиги (flags/limits/default behaviors), без пользовательских списков (wishlist/compare/recentlyViewed/saved searches) и без product payloads. + ## Пример полного минимального bootstrap ```json { diff --git a/docs/platform/06-api-contracts.md b/docs/platform/06-api-contracts.md index 5749126..1237c6b 100644 --- a/docs/platform/06-api-contracts.md +++ b/docs/platform/06-api-contracts.md @@ -50,6 +50,27 @@ - Backend may introduce new sort IDs via bootstrap `catalog.availableSorts`. - Frontend must render unknown sort keys safely if label mapping is provided. + ## User Experience API Expectations (architecture-ready) + - Wishlist (authenticated mode, future-ready): + - GET /me/wishlist + - POST /me/wishlist + - DELETE /me/wishlist/{itemId} + - Compare list (optional sync for authenticated mode): + - GET /me/compare + - POST /me/compare + - DELETE /me/compare/{itemId} + - Saved searches: + - GET /me/saved-searches + - POST /me/saved-searches + - DELETE /me/saved-searches/{id} + - Recently viewed sync (optional): + - GET /me/recently-viewed + - POST /me/recently-viewed + + Правила: + - Guest mode может хранить UX данные локально (local storage) без backend-запросов. + - UI не вызывает HttpClient напрямую: Feature -> Facade -> Repository/Provider. + ## Пример API ответа: категории ```json { diff --git a/docs/platform/13-backend-requirements.md b/docs/platform/13-backend-requirements.md index c079c4e..c8f853b 100644 --- a/docs/platform/13-backend-requirements.md +++ b/docs/platform/13-backend-requirements.md @@ -10,6 +10,7 @@ - Выдача навигации, статических страниц и feature flags. - Product Engagement API для рейтинга, отзывов и вопросов. - Advanced Search API для keyword/suggestions/filter metadata/sorting. +- User Experience API (future-ready): wishlist/compare/saved-searches/recently-viewed sync for authenticated users. ### Контракт статических страниц Backend должен поддерживать формат: @@ -48,6 +49,19 @@ Backend должен поддерживать формат: - GET /search/suggestions?q={term} (future-ready) - GET /catalog/filters?category={id}&q={term} (future-ready) +## User Experience endpoints (future-ready) +- GET /me/wishlist +- POST /me/wishlist +- DELETE /me/wishlist/{itemId} +- GET /me/compare +- POST /me/compare +- DELETE /me/compare/{itemId} +- GET /me/saved-searches +- POST /me/saved-searches +- DELETE /me/saved-searches/{id} +- GET /me/recently-viewed +- POST /me/recently-viewed + ## Catalog bootstrap contract expectations - Backend should populate `catalog.availableSorts` and `catalog.enabledFilters`. - Backend should not include product list or filter results inside bootstrap. diff --git a/src/app/app.html b/src/app/app.html index e0926f0..d620679 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -18,6 +18,7 @@ } + @defer (on viewport) { } @placeholder { diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 1a850e3..0c3b11d 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -38,6 +38,14 @@ const coreRoutes: Routes = [ path: 'search', loadComponent: () => import('./features/website/catalog/containers/catalog-container.component').then(m => m.CatalogContainerComponent) }, + { + path: 'wishlist', + loadComponent: () => import('./features/website/user-experience/wishlist/containers/wishlist-page.component').then(m => m.WishlistPageComponent) + }, + { + path: 'compare', + loadComponent: () => import('./features/website/user-experience/compare/containers/compare-page.component').then(m => m.ComparePageComponent) + }, { path: 'cart', loadComponent: () => import('./pages/cart/cart.component').then(m => m.CartComponent) diff --git a/src/app/app.ts b/src/app/app.ts index 5bc17a6..5918df3 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -14,10 +14,11 @@ import { TranslateService } from './i18n/translate.service'; import { PlatformRuntimeService } from './core/runtime/platform-runtime.service'; import { UiRuntimeFacade } from './facades/runtime/ui-runtime.facade'; import { ApiHealthService } from './services/api-health.service'; +import { FloatingNotificationsComponent } from './features/website/user-experience/components/floating-notifications/floating-notifications.component'; @Component({ selector: 'app-root', - imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe], + imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe, FloatingNotificationsComponent], templateUrl: './app.html', styleUrl: './app.scss' }) diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index 07c1db5..f733501 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -38,6 +38,24 @@
+ @if (userExperienceConfig.wishlist.enabled) { + + } + + @if (userExperienceConfig.compare.enabled) { + + } + diff --git a/src/app/components/header/header.component.scss b/src/app/components/header/header.component.scss index 4e426eb..0622c4e 100644 --- a/src/app/components/header/header.component.scss +++ b/src/app/components/header/header.component.scss @@ -632,6 +632,50 @@ flex-shrink: 0; } +.dexar-ux-btn { + position: relative; + width: 34px; + height: 34px; + border: 1px solid #d3dad9; + border-radius: 50%; + background: rgba(255, 255, 255, 0.84); + color: #1e3c38; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.18s ease, border-color 0.2s ease, background 0.2s ease; +} + +.dexar-ux-btn:hover { + transform: translateY(-1px); + border-color: #497671; + background: #ffffff; +} + +.dexar-ux-icon { + font-size: 15px; + line-height: 1; + font-weight: 700; +} + +.dexar-ux-badge { + position: absolute; + right: -6px; + top: -6px; + min-width: 16px; + height: 16px; + border-radius: 999px; + background: #497671; + color: #ffffff; + font-size: 10px; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + .dexar-cart-btn { display: flex; flex-direction: column; diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index ae9c30e..f8bcce0 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -8,6 +8,9 @@ import { RegionSelectorComponent } from '../region-selector/region-selector.comp import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { UiRuntimeFacade } from '../../facades/runtime/ui-runtime.facade'; +import { UserExperienceFacade } from '../../facades/platform/user-experience.facade'; +import { ConfigService } from '../../core/config/config.service'; +import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../shared/models/config'; @Component({ selector: 'app-header', @@ -25,6 +28,12 @@ export class HeaderComponent { private document = inject(DOCUMENT); private langService = inject(LanguageService); private uiRuntime = inject(UiRuntimeFacade); + private uxFacade = inject(UserExperienceFacade); + private configService = inject(ConfigService); + + readonly wishlistCount = this.uxFacade.wishlistCount; + readonly compareCount = this.uxFacade.compareCount; + readonly userExperienceConfig = this.resolveUserExperienceConfig(); constructor(private cartService: CartService, private router: Router) { this.cartItemCount = this.cartService.itemCount; @@ -89,6 +98,18 @@ export class HeaderComponent { }); } + navigateToWishlist(): void { + this.closeMenu(); + const lang = this.langService.currentLanguage(); + this.router.navigate([`/${lang}/wishlist`]); + } + + navigateToCompare(): void { + this.closeMenu(); + const lang = this.langService.currentLanguage(); + this.router.navigate([`/${lang}/compare`]); + } + formatCartTotal(total: number): string { const locale = this.langService.currentLanguage() === 'en' ? 'en-US' @@ -104,4 +125,21 @@ export class HeaderComponent { return `${amount} ${currencySymbol}`; } + + private resolveUserExperienceConfig() { + const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {}; + + return { + ...DEFAULT_USER_EXPERIENCE_CONFIG, + ...raw, + wishlist: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist, + ...(raw.wishlist ?? {}) + }, + compare: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.compare, + ...(raw.compare ?? {}) + } + }; + } } diff --git a/src/app/components/product-card/product-card.component.html b/src/app/components/product-card/product-card.component.html index 8ae4e03..14ad4c0 100644 --- a/src/app/components/product-card/product-card.component.html +++ b/src/app/components/product-card/product-card.component.html @@ -9,16 +9,19 @@ {{ item.remainings }} } - @if (showFavoritePlaceholder || showComparePlaceholder || showQuickViewPlaceholder) { + @if (showFavoriteControl() || showCompareControl() || showShareAction || showQuickViewPlaceholder) {
- @if (showFavoritePlaceholder) { - + @if (showFavoriteControl()) { + } - @if (showComparePlaceholder) { - + @if (showCompareControl()) { + + } + @if (showShareAction) { + } @if (showQuickViewPlaceholder) { - + }
} diff --git a/src/app/components/product-card/product-card.component.scss b/src/app/components/product-card/product-card.component.scss index 78e0cad..06742b2 100644 --- a/src/app/components/product-card/product-card.component.scss +++ b/src/app/components/product-card/product-card.component.scss @@ -90,16 +90,46 @@ gap: 6px; } -.product-actions-overlay button { - min-height: 24px; +.product-action-btn { + width: 30px; + height: 30px; border: 1px solid rgba(30, 60, 56, 0.15); - border-radius: 999px; - background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + background: rgba(255, 255, 255, 0.95); color: #1e3c38; - font-size: 0.72rem; + font-size: 0.9rem; font-weight: 700; - padding: 0 8px; + display: inline-flex; + align-items: center; + justify-content: center; cursor: pointer; + transition: transform 0.18s ease, border-color 0.2s ease, color 0.2s ease; +} + +.product-action-btn:hover { + transform: translateY(-1px); + border-color: #497671; +} + +.product-action-btn.active { + border-color: #497671; + color: #497671; +} + +.product-action-favorite.active { + animation: favorite-pulse 280ms ease-out; +} + +@keyframes favorite-pulse { + 0% { + transform: scale(0.9); + } + 60% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } } .product-badges-overlay { diff --git a/src/app/components/product-card/product-card.component.ts b/src/app/components/product-card/product-card.component.ts index 283a316..05875f6 100644 --- a/src/app/components/product-card/product-card.component.ts +++ b/src/app/components/product-card/product-card.component.ts @@ -28,6 +28,11 @@ export class ProductCardComponent { @Input() showFavoritePlaceholder = false; @Input() showComparePlaceholder = false; @Input() showQuickViewPlaceholder = false; + @Input() showFavoriteAction = false; + @Input() showCompareAction = false; + @Input() showShareAction = false; + @Input() isFavorite = false; + @Input() isCompared = false; @Output() addToCart = new EventEmitter<{ itemID: number; event: Event }>(); @Output() preview = new EventEmitter(); @@ -35,6 +40,9 @@ export class ProductCardComponent { @Output() favoritePlaceholder = new EventEmitter(); @Output() comparePlaceholder = new EventEmitter(); @Output() quickViewPlaceholder = new EventEmitter(); + @Output() favoriteToggled = new EventEmitter(); + @Output() compareToggled = new EventEmitter(); + @Output() shareRequested = new EventEmitter(); readonly getMainImage = getMainImage; readonly getDiscountedPrice = getDiscountedPrice; @@ -52,12 +60,14 @@ export class ProductCardComponent { event.preventDefault(); event.stopPropagation(); this.favoritePlaceholder.emit(this.item.itemID); + this.favoriteToggled.emit(this.item.itemID); } onComparePlaceholder(event: Event): void { event.preventDefault(); event.stopPropagation(); this.comparePlaceholder.emit(this.item.itemID); + this.compareToggled.emit(this.item.itemID); } onQuickViewPlaceholder(event: Event): void { @@ -65,4 +75,18 @@ export class ProductCardComponent { event.stopPropagation(); this.quickViewPlaceholder.emit(this.item.itemID); } + + onShare(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.shareRequested.emit(this.item.itemID); + } + + showFavoriteControl(): boolean { + return this.showFavoriteAction || this.showFavoritePlaceholder; + } + + showCompareControl(): boolean { + return this.showCompareAction || this.showComparePlaceholder; + } } diff --git a/src/app/core/user-experience/models/user-experience.model.ts b/src/app/core/user-experience/models/user-experience.model.ts new file mode 100644 index 0000000..9c03058 --- /dev/null +++ b/src/app/core/user-experience/models/user-experience.model.ts @@ -0,0 +1,48 @@ +import { Product } from '../../products/models/product-domain.model'; + +export interface FavoriteItem { + product: Product; + addedAt: string; + ownerType: 'guest' | 'user'; +} + +export interface ComparedProduct { + product: Product; + addedAt: string; +} + +export interface RecentlyViewedItem { + product: Product; + viewedAt: string; +} + +export interface SavedSearch { + id: string; + name: string; + query: string; + sort: string; + categoryId?: number; + filters: { + values: Record; + ranges: Record; + toggles: Record; + }; + createdAt: string; +} + +export interface ContinueBrowsingState { + routeKey: string; + query: string; + sort: string; + layout: string; + page: number; + pageSize: number; + categoryId?: number; + filterState: { + values: Record; + ranges: Record; + toggles: Record; + }; + scrollY: number; + updatedAt: string; +} diff --git a/src/app/core/user-experience/repositories/local-user-experience.repository.ts b/src/app/core/user-experience/repositories/local-user-experience.repository.ts new file mode 100644 index 0000000..4a793e2 --- /dev/null +++ b/src/app/core/user-experience/repositories/local-user-experience.repository.ts @@ -0,0 +1,113 @@ +import { Injectable } from '@angular/core'; +import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../models/user-experience.model'; +import { UserExperienceRepository } from './user-experience.repository'; + +const KEYS = { + wishlist: 'marketplace.ux.wishlist', + compare: 'marketplace.ux.compare', + recentlyViewed: 'marketplace.ux.recently-viewed', + savedSearches: 'marketplace.ux.saved-searches', + continueBrowsingPrefix: 'marketplace.ux.continue-browsing:' +} as const; + +@Injectable({ providedIn: 'root' }) +export class LocalUserExperienceRepository implements UserExperienceRepository { + getWishlist(): FavoriteItem[] { + return this.readArray(KEYS.wishlist); + } + + saveWishlist(items: FavoriteItem[]): void { + this.write(KEYS.wishlist, items); + } + + getComparedProducts(): ComparedProduct[] { + return this.readArray(KEYS.compare); + } + + saveComparedProducts(items: ComparedProduct[]): void { + this.write(KEYS.compare, items); + } + + getRecentlyViewed(): RecentlyViewedItem[] { + return this.readArray(KEYS.recentlyViewed); + } + + saveRecentlyViewed(items: RecentlyViewedItem[]): void { + this.write(KEYS.recentlyViewed, items); + } + + getSavedSearches(): SavedSearch[] { + return this.readArray(KEYS.savedSearches); + } + + saveSavedSearches(items: SavedSearch[]): void { + this.write(KEYS.savedSearches, items); + } + + getContinueBrowsing(routeKey: string): ContinueBrowsingState | null { + return this.readSingle(this.continueKey(routeKey)); + } + + saveContinueBrowsing(state: ContinueBrowsingState): void { + this.write(this.continueKey(state.routeKey), state); + } + + clearContinueBrowsing(routeKey: string): void { + if (!this.isBrowser()) { + return; + } + + localStorage.removeItem(this.continueKey(routeKey)); + } + + private readArray(key: string): T[] { + if (!this.isBrowser()) { + return []; + } + + try { + const raw = localStorage.getItem(key); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? (parsed as T[]) : []; + } catch { + return []; + } + } + + private readSingle(key: string): T | null { + if (!this.isBrowser()) { + return null; + } + + try { + const raw = localStorage.getItem(key); + if (!raw) { + return null; + } + + return JSON.parse(raw) as T; + } catch { + return null; + } + } + + private write(key: string, value: T): void { + if (!this.isBrowser()) { + return; + } + + localStorage.setItem(key, JSON.stringify(value)); + } + + private continueKey(routeKey: string): string { + return `${KEYS.continueBrowsingPrefix}${routeKey}`; + } + + private isBrowser(): boolean { + return typeof window !== 'undefined'; + } +} diff --git a/src/app/core/user-experience/repositories/user-experience.repository.ts b/src/app/core/user-experience/repositories/user-experience.repository.ts new file mode 100644 index 0000000..f26d931 --- /dev/null +++ b/src/app/core/user-experience/repositories/user-experience.repository.ts @@ -0,0 +1,19 @@ +import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../models/user-experience.model'; + +export interface UserExperienceRepository { + getWishlist(): FavoriteItem[]; + saveWishlist(items: FavoriteItem[]): void; + + getComparedProducts(): ComparedProduct[]; + saveComparedProducts(items: ComparedProduct[]): void; + + getRecentlyViewed(): RecentlyViewedItem[]; + saveRecentlyViewed(items: RecentlyViewedItem[]): void; + + getSavedSearches(): SavedSearch[]; + saveSavedSearches(items: SavedSearch[]): void; + + getContinueBrowsing(routeKey: string): ContinueBrowsingState | null; + saveContinueBrowsing(state: ContinueBrowsingState): void; + clearContinueBrowsing(routeKey: string): void; +} diff --git a/src/app/core/user-experience/user-experience-repository.token.ts b/src/app/core/user-experience/user-experience-repository.token.ts new file mode 100644 index 0000000..d7462ca --- /dev/null +++ b/src/app/core/user-experience/user-experience-repository.token.ts @@ -0,0 +1,12 @@ +import { InjectionToken, inject } from '@angular/core'; +import { LocalUserExperienceRepository } from './repositories/local-user-experience.repository'; +import { UserExperienceRepository } from './repositories/user-experience.repository'; + +export const USER_EXPERIENCE_REPOSITORY = new InjectionToken('USER_EXPERIENCE_REPOSITORY', { + providedIn: 'root', + factory: () => { + // Sprint 11: guest-first local storage; can be switched to authenticated repository later. + const localRepository = inject(LocalUserExperienceRepository); + return localRepository; + } +}); diff --git a/src/app/facades/platform/user-experience.facade.ts b/src/app/facades/platform/user-experience.facade.ts new file mode 100644 index 0000000..7a888d4 --- /dev/null +++ b/src/app/facades/platform/user-experience.facade.ts @@ -0,0 +1,136 @@ +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Product } from '../../core/products/models/product-domain.model'; +import { ComparedProduct, ContinueBrowsingState, FavoriteItem, RecentlyViewedItem, SavedSearch } from '../../core/user-experience/models/user-experience.model'; +import { USER_EXPERIENCE_REPOSITORY } from '../../core/user-experience/user-experience-repository.token'; + +@Injectable({ providedIn: 'root' }) +export class UserExperienceFacade { + private readonly repository = inject(USER_EXPERIENCE_REPOSITORY); + + private readonly wishlistState = signal(this.repository.getWishlist()); + private readonly compareState = signal(this.repository.getComparedProducts()); + private readonly recentlyViewedState = signal(this.repository.getRecentlyViewed()); + private readonly savedSearchesState = signal(this.repository.getSavedSearches()); + + readonly wishlist = this.wishlistState.asReadonly(); + readonly comparedProducts = this.compareState.asReadonly(); + readonly recentlyViewed = this.recentlyViewedState.asReadonly(); + readonly savedSearches = this.savedSearchesState.asReadonly(); + + readonly wishlistCount = computed(() => this.wishlistState().length); + readonly compareCount = computed(() => this.compareState().length); + + isInWishlist(productId: number): boolean { + return this.wishlistState().some(item => item.product.itemID === productId); + } + + isInCompare(productId: number): boolean { + return this.compareState().some(item => item.product.itemID === productId); + } + + toggleWishlist(product: Product, ownerType: FavoriteItem['ownerType'] = 'guest'): { added: boolean; count: number } { + const existing = this.wishlistState(); + const present = existing.some(item => item.product.itemID === product.itemID); + + const next = present + ? existing.filter(item => item.product.itemID !== product.itemID) + : [{ product, addedAt: new Date().toISOString(), ownerType }, ...existing]; + + this.wishlistState.set(next); + this.repository.saveWishlist(next); + + return { added: !present, count: next.length }; + } + + clearWishlist(): void { + this.wishlistState.set([]); + this.repository.saveWishlist([]); + } + + addToCompare(product: Product, maxItems: number): { added: boolean; reason?: 'limit'; count: number } { + const existing = this.compareState(); + + if (existing.some(item => item.product.itemID === product.itemID)) { + return { added: false, count: existing.length }; + } + + if (existing.length >= maxItems) { + return { added: false, reason: 'limit', count: existing.length }; + } + + const next = [{ product, addedAt: new Date().toISOString() }, ...existing]; + this.compareState.set(next); + this.repository.saveComparedProducts(next); + + return { added: true, count: next.length }; + } + + removeFromCompare(productId: number): void { + const next = this.compareState().filter(item => item.product.itemID !== productId); + this.compareState.set(next); + this.repository.saveComparedProducts(next); + } + + clearCompare(): void { + this.compareState.set([]); + this.repository.saveComparedProducts([]); + } + + trackRecentlyViewed(product: Product, maxItems: number): void { + const existing = this.recentlyViewedState().filter(item => item.product.itemID !== product.itemID); + const next = [{ product, viewedAt: new Date().toISOString() }, ...existing].slice(0, Math.max(1, maxItems)); + + this.recentlyViewedState.set(next); + this.repository.saveRecentlyViewed(next); + } + + clearRecentlyViewed(): void { + this.recentlyViewedState.set([]); + this.repository.saveRecentlyViewed([]); + } + + saveSearch(input: Omit, maxItems: number): SavedSearch { + const normalizedQuery = input.query.trim(); + const existing = this.savedSearchesState(); + const duplicateIndex = existing.findIndex(entry => + entry.query.toLowerCase() === normalizedQuery.toLowerCase() && + entry.sort === input.sort && + (entry.categoryId ?? null) === (input.categoryId ?? null) + ); + + if (duplicateIndex >= 0) { + return existing[duplicateIndex]; + } + + const nextEntry: SavedSearch = { + ...input, + query: normalizedQuery, + id: `saved-search-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + createdAt: new Date().toISOString() + }; + + const next = [nextEntry, ...existing].slice(0, Math.max(1, maxItems)); + this.savedSearchesState.set(next); + this.repository.saveSavedSearches(next); + + return nextEntry; + } + + removeSavedSearch(id: string): void { + const next = this.savedSearchesState().filter(item => item.id !== id); + this.savedSearchesState.set(next); + this.repository.saveSavedSearches(next); + } + + saveContinueBrowsing(state: ContinueBrowsingState): void { + this.repository.saveContinueBrowsing(state); + } + + getContinueBrowsing(routeKey: string): ContinueBrowsingState | null { + return this.repository.getContinueBrowsing(routeKey); + } + + clearContinueBrowsing(routeKey: string): void { + this.repository.clearContinueBrowsing(routeKey); + } +} diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.html b/src/app/features/website/catalog/components/product-grid/product-grid.component.html index a6c56d0..230427b 100644 --- a/src/app/features/website/catalog/components/product-grid/product-grid.component.html +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.html @@ -10,13 +10,19 @@ [showStock]="showAvailability" [showRating]="showRatings" [showDiscountBadge]="showDiscounts" - [showFavoritePlaceholder]="showActionsPlaceholder" - [showComparePlaceholder]="showActionsPlaceholder" + [showFavoriteAction]="showActionsPlaceholder" + [showCompareAction]="showActionsPlaceholder" + [showShareAction]="showShareAction" [showQuickViewPlaceholder]="showActionsPlaceholder" + [isFavorite]="isFavorite(product)" + [isCompared]="isCompared(product)" [addToCartLabel]="'catalog.addToCart' | translate" (selected)="productSelected.emit(product)" (addToCart)="onAddToCart(product, $event.event)" (preview)="productPreview.emit($event)" + (favoriteToggled)="favoriteToggled.emit(product)" + (compareToggled)="compareToggled.emit(product)" + (shareRequested)="shareRequested.emit(product)" />
} diff --git a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts index 0b3aa6f..a1b34a7 100644 --- a/src/app/features/website/catalog/components/product-grid/product-grid.component.ts +++ b/src/app/features/website/catalog/components/product-grid/product-grid.component.ts @@ -21,10 +21,16 @@ export class CatalogProductGridComponent { @Input() showDiscounts = true; @Input() showAvailability = true; @Input() showActionsPlaceholder = false; + @Input() showShareAction = false; + @Input() favoriteIds: number[] = []; + @Input() comparedIds: number[] = []; @Output() productSelected = new EventEmitter(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() productPreview = new EventEmitter(); + @Output() favoriteToggled = new EventEmitter(); + @Output() compareToggled = new EventEmitter(); + @Output() shareRequested = new EventEmitter(); private readonly languageService = inject(LanguageService); @@ -42,6 +48,14 @@ export class CatalogProductGridComponent { this.addToCart.emit({ product, event }); } + isFavorite(product: Product): boolean { + return this.favoriteIds.includes(product.itemID); + } + + isCompared(product: Product): boolean { + return this.comparedIds.includes(product.itemID); + } + layoutClass(): string { switch (this.layout) { case 'large-grid': diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.html b/src/app/features/website/catalog/components/search-results/search-results.component.html index a699d78..1659bce 100644 --- a/src/app/features/website/catalog/components/search-results/search-results.component.html +++ b/src/app/features/website/catalog/components/search-results/search-results.component.html @@ -18,9 +18,15 @@ [showDiscounts]="showDiscounts" [showAvailability]="showAvailability" [showActionsPlaceholder]="true" + [showShareAction]="showShareAction" + [favoriteIds]="favoriteIds" + [comparedIds]="comparedIds" (productSelected)="productSelected.emit($event)" (addToCart)="addToCart.emit($event)" - (productPreview)="productPreview.emit($event)" /> + (productPreview)="productPreview.emit($event)" + (favoriteToggled)="favoriteToggled.emit($event)" + (compareToggled)="compareToggled.emit($event)" + (shareRequested)="shareRequested.emit($event)" /> } @else {

No results found

diff --git a/src/app/features/website/catalog/components/search-results/search-results.component.ts b/src/app/features/website/catalog/components/search-results/search-results.component.ts index 17e9824..a080491 100644 --- a/src/app/features/website/catalog/components/search-results/search-results.component.ts +++ b/src/app/features/website/catalog/components/search-results/search-results.component.ts @@ -22,11 +22,17 @@ export class CatalogSearchResultsComponent { @Input() showRatings = true; @Input() showDiscounts = true; @Input() showAvailability = true; + @Input() showShareAction = true; + @Input() favoriteIds: number[] = []; + @Input() comparedIds: number[] = []; @Output() pageChange = new EventEmitter(); @Output() productSelected = new EventEmitter(); @Output() addToCart = new EventEmitter<{ product: Product; event: Event }>(); @Output() productPreview = new EventEmitter(); + @Output() favoriteToggled = new EventEmitter(); + @Output() compareToggled = new EventEmitter(); + @Output() shareRequested = new EventEmitter(); get totalPages(): number { return Math.max(1, Math.ceil(this.total / Math.max(1, this.pageSize))); diff --git a/src/app/features/website/catalog/containers/catalog-container.component.html b/src/app/features/website/catalog/containers/catalog-container.component.html index 01dd0c4..a1adbab 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.html +++ b/src/app/features/website/catalog/containers/catalog-container.component.html @@ -24,6 +24,17 @@ (suggestionSelected)="useSuggestion($event)" (recentSelected)="useRecentSearch($event)" (historyCleared)="clearSearchHistory()" /> + + @if (userExperienceConfig().savedSearches.enabled && savedSearches().length > 0) { +
+ @for (saved of savedSearches(); track saved.id) { +
+ + +
+ } +
+ } @if (loading()) { @@ -98,6 +109,10 @@ [selected]="state().sort" (selectedChange)="changeSort($event)" /> + @if (userExperienceConfig().savedSearches.enabled) { + + } + + (productPreview)="previewProduct($event)" + (favoriteToggled)="onFavoriteToggled($event)" + (compareToggled)="onCompareToggled($event)" + (shareRequested)="onShareRequested($event)" />
} diff --git a/src/app/features/website/catalog/containers/catalog-container.component.scss b/src/app/features/website/catalog/containers/catalog-container.component.scss index 6dc5879..c91d027 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.scss +++ b/src/app/features/website/catalog/containers/catalog-container.component.scss @@ -12,6 +12,35 @@ margin-bottom: 24px; } +.catalog-saved-searches { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.catalog-saved-chip { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--border-color); + border-radius: 999px; + background: var(--bg-primary); + padding: 4px 8px; +} + +.catalog-saved-chip button { + border: 0; + background: transparent; + color: var(--text-primary); + font-weight: 600; + cursor: pointer; +} + +.catalog-saved-chip-remove { + color: var(--text-secondary); + line-height: 1; +} + .catalog-root-link { width: fit-content; color: #1e3c38; @@ -72,6 +101,17 @@ justify-content: space-between; } +.catalog-save-search-btn { + min-height: 36px; + border: 1px solid var(--border-color); + border-radius: var(--radius-sm); + background: var(--bg-primary); + color: var(--text-primary); + padding: 0 12px; + font-weight: 700; + cursor: pointer; +} + .catalog-category-banner { padding: 16px; background: linear-gradient(120deg, color-mix(in srgb, var(--primary-color) 14%, white), #f8fbfb); diff --git a/src/app/features/website/catalog/containers/catalog-container.component.ts b/src/app/features/website/catalog/containers/catalog-container.component.ts index 32132b2..7c9d2f6 100644 --- a/src/app/features/website/catalog/containers/catalog-container.component.ts +++ b/src/app/features/website/catalog/containers/catalog-container.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, DestroyRef, computed, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, DestroyRef, HostListener, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { combineLatest } from 'rxjs'; @@ -9,12 +9,13 @@ import { Product } from '../../../../core/products/models/product-domain.model'; import { ConfigService } from '../../../../core/config/config.service'; import { CategoryFacade } from '../../../../facades/platform/category.facade'; import { ProductFacade } from '../../../../facades/platform/product.facade'; +import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { CartService } from '../../../../services'; import { LanguageService } from '../../../../services/language.service'; import { PrefetchService } from '../../../../services/prefetch.service'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; -import { DEFAULT_CATALOG_CONFIG } from '../../../../shared/models/config'; +import { DEFAULT_CATALOG_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../shared/models/config'; import { CatalogCategoryGridComponent } from '../components/category-grid/category-grid.component'; import { CatalogFiltersPanelComponent, CatalogFilterStateValue } from '../components/filters-panel/filters-panel.component'; import { CatalogLayoutSwitcherComponent } from '../components/layout-switcher/layout-switcher.component'; @@ -23,6 +24,8 @@ import { CatalogSearchResultsComponent } from '../components/search-results/sear import { CatalogSortingControlComponent } from '../components/sorting-control/sorting-control.component'; import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model'; import { CatalogSearchHistoryService } from '../services/catalog-search-history.service'; +import { ProductShareService } from '../../user-experience/services/product-share.service'; +import { UserNotificationService } from '../../user-experience/services/user-notification.service'; type CatalogViewMode = 'categories' | 'products'; @@ -55,8 +58,12 @@ export class CatalogContainerComponent { private readonly prefetchService = inject(PrefetchService); private readonly languageService = inject(LanguageService); private readonly searchHistoryService = inject(CatalogSearchHistoryService); + private readonly uxFacade = inject(UserExperienceFacade); + private readonly shareService = inject(ProductShareService); + private readonly notifications = inject(UserNotificationService); readonly catalogConfig = signal(this.resolveCatalogConfig()); + readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly state = signal(createInitialCatalogState()); readonly categories = signal([]); @@ -71,11 +78,14 @@ export class CatalogContainerComponent { readonly searchSuggestions = signal([]); readonly recentSearches = signal([]); readonly searchHistory = signal(this.catalogConfig().searchHistoryEnabled ? this.searchHistoryService.getHistory() : []); + readonly savedSearches = this.uxFacade.savedSearches; readonly viewMode = signal('categories'); readonly loading = signal(true); readonly loadingProducts = signal(false); readonly error = signal(null); readonly noResults = computed(() => !this.loadingProducts() && this.viewMode() === 'products' && this.products().length === 0); + readonly favoriteIds = computed(() => this.uxFacade.wishlist().map(item => item.product.itemID)); + readonly comparedIds = computed(() => this.uxFacade.comparedProducts().map(item => item.product.itemID)); readonly sortDefinitions = computed(() => { const labels: Record = { @@ -97,6 +107,7 @@ export class CatalogContainerComponent { private dataSubscription?: Subscription; private readonly backendFetchSize = 200; + private pendingScrollY: number | null = null; constructor() { this.destroyRef.onDestroy(() => this.dataSubscription?.unsubscribe()); @@ -107,6 +118,11 @@ export class CatalogContainerComponent { .subscribe(([params, queryParams]) => { const categoryId = Number(params.get('id')) || null; const searchQuery = (queryParams.get('q') ?? '').trim(); + + if (categoryId == null && searchQuery.length === 0 && this.userExperienceConfig().continueBrowsing.enabled && this.restoreContinueBrowsing()) { + return; + } + this.enterCategory(categoryId); if (searchQuery.length > 0) { @@ -195,6 +211,7 @@ export class CatalogContainerComponent { onSearchQueryChange(query: string): void { this.state.update(current => ({ ...current, search: query })); + this.persistContinueBrowsing(); const normalized = query.trim().toLowerCase(); if (!this.catalogConfig().suggestionsEnabled || normalized.length < 2) { @@ -228,6 +245,7 @@ export class CatalogContainerComponent { this.recentSearches.set(this.searchHistory().slice(0, 5)); } + this.persistContinueBrowsing(); this.loadCatalog(); } @@ -247,6 +265,106 @@ export class CatalogContainerComponent { this.recentSearches.set([]); } + onFavoriteToggled(product: Product): void { + const result = this.uxFacade.toggleWishlist(product); + this.notifications.show(result.added ? 'Added to wishlist' : 'Removed from wishlist', result.added ? 'success' : 'info'); + } + + onCompareToggled(product: Product): void { + if (this.uxFacade.isInCompare(product.itemID)) { + this.uxFacade.removeFromCompare(product.itemID); + this.notifications.show('Removed from compare', 'info'); + return; + } + + const maxItems = this.userExperienceConfig().compare.maxItems; + const result = this.uxFacade.addToCompare(product, maxItems); + if (result.reason === 'limit') { + this.notifications.show(`Compare limit reached (${maxItems})`, 'warning'); + return; + } + + this.notifications.show('Added to compare', 'success'); + } + + async onShareRequested(product: Product): Promise { + if (!this.userExperienceConfig().share.enabled) { + return; + } + + const lang = this.languageService.currentLanguage(); + const path = `/${lang}/product/${product.itemID}`; + const productUrl = typeof window !== 'undefined' + ? new URL(path, window.location.origin).toString() + : path; + + const result = await this.shareService.shareProduct(product, productUrl); + if (result === 'native') { + this.notifications.show('Product shared', 'success'); + return; + } + + if (result === 'copied') { + this.notifications.show('Product link copied', 'success'); + return; + } + + this.notifications.show('Sharing is not supported on this device', 'warning'); + } + + saveCurrentSearch(): void { + if (!this.userExperienceConfig().savedSearches.enabled) { + return; + } + + const query = this.state().search.trim(); + if (!query.length) { + this.notifications.show('Enter a search query before saving', 'warning'); + return; + } + + const categoryId = this.state().filters.categoryIds[0]; + const saved = this.uxFacade.saveSearch({ + name: query, + query, + sort: this.state().sort, + categoryId, + filters: this.filterState() + }, this.userExperienceConfig().savedSearches.maxItems); + + this.notifications.show(`Saved search: ${saved.name}`, 'success'); + } + + useSavedSearch(id: string): void { + const target = this.savedSearches().find(item => item.id === id); + if (!target) { + return; + } + + this.viewMode.set('products'); + this.state.update(current => ({ + ...current, + search: target.query, + sort: target.sort as CatalogState['sort'], + pagination: { + ...current.pagination, + page: 1, + skip: 0 + } + })); + this.filterState.set(target.filters); + this.loadCatalog(target.categoryId); + } + + removeSavedSearch(id: string): void { + this.uxFacade.removeSavedSearch(id); + } + + @HostListener('window:scroll') + onWindowScroll(): void { + this.persistContinueBrowsing(); + } + onFilterStateChange(next: CatalogFilterStateValue): void { this.filterState.set(next); this.state.update(current => ({ @@ -259,11 +377,13 @@ export class CatalogContainerComponent { })); this.recomputeResults(); + this.persistContinueBrowsing(); } resetFilters(): void { this.filterState.set({ values: {}, ranges: {}, toggles: {} }); this.recomputeResults(); + this.persistContinueBrowsing(); } changeSort(sortId: string): void { @@ -278,10 +398,12 @@ export class CatalogContainerComponent { })); this.recomputeResults(); + this.persistContinueBrowsing(); } changeLayout(layout: CatalogLayoutMode): void { this.state.update(current => ({ ...current, layout })); + this.persistContinueBrowsing(); } onResultsPageChange(page: number): void { @@ -295,6 +417,7 @@ export class CatalogContainerComponent { })); this.recomputeResults(); + this.persistContinueBrowsing(); } private renderCategories(category: Category | null, categories: Category[], breadcrumb: Category[]): void { @@ -496,6 +619,12 @@ export class CatalogContainerComponent { this.searchResult.set(result); this.searchSummary.set(result.summary); this.searchSuggestions.set(this.buildSuggestions(this.state().search, sorted)); + + if (this.pendingScrollY != null && typeof window !== 'undefined') { + const scrollY = this.pendingScrollY; + this.pendingScrollY = null; + setTimeout(() => window.scrollTo({ top: scrollY, behavior: 'auto' }), 0); + } } private buildSuggestions(query: string, products: Product[]): string[] { @@ -646,6 +775,96 @@ export class CatalogContainerComponent { }; } + private resolveUserExperienceConfig() { + const snapshot = this.configService.getBootstrapSnapshot() as any; + const raw = snapshot?.userExperience ?? {}; + + return { + ...DEFAULT_USER_EXPERIENCE_CONFIG, + ...raw, + wishlist: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.wishlist, + ...(raw.wishlist ?? {}) + }, + compare: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.compare, + ...(raw.compare ?? {}), + maxItems: Number.isFinite(raw.compare?.maxItems) + ? Math.max(2, Number(raw.compare.maxItems)) + : DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems + }, + recentlyViewed: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed, + ...(raw.recentlyViewed ?? {}) + }, + share: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.share, + ...(raw.share ?? {}) + }, + continueBrowsing: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.continueBrowsing, + ...(raw.continueBrowsing ?? {}) + }, + savedSearches: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches, + ...(raw.savedSearches ?? {}), + maxItems: Number.isFinite(raw.savedSearches?.maxItems) + ? Math.max(1, Number(raw.savedSearches.maxItems)) + : DEFAULT_USER_EXPERIENCE_CONFIG.savedSearches.maxItems + } + }; + } + + private persistContinueBrowsing(): void { + if (!this.userExperienceConfig().continueBrowsing.enabled || this.viewMode() !== 'products') { + return; + } + + const state = this.state(); + this.uxFacade.saveContinueBrowsing({ + routeKey: 'catalog', + query: state.search, + sort: state.sort, + layout: state.layout, + page: state.pagination.page, + pageSize: state.pagination.count, + categoryId: state.filters.categoryIds[0], + filterState: this.filterState(), + scrollY: typeof window !== 'undefined' ? window.scrollY : 0, + updatedAt: new Date().toISOString() + }); + } + + private restoreContinueBrowsing(): boolean { + const saved = this.uxFacade.getContinueBrowsing('catalog'); + if (!saved) { + return false; + } + + this.viewMode.set('products'); + this.filterState.set(saved.filterState); + this.state.update(current => ({ + ...current, + search: saved.query, + sort: saved.sort as CatalogState['sort'], + layout: saved.layout as CatalogLayoutMode, + pagination: { + ...current.pagination, + page: Math.max(1, saved.page), + count: Math.max(1, saved.pageSize), + skip: Math.max(0, (saved.page - 1) * saved.pageSize) + }, + filters: { + ...current.filters, + categoryIds: saved.categoryId == null ? [] : [saved.categoryId] + } + })); + + this.pendingScrollY = Math.max(0, saved.scrollY || 0); + this.loadCatalog(saved.categoryId); + return true; + } + private setError(message: string): void { this.error.set(message); this.loading.set(false); diff --git a/src/app/features/website/product/containers/product-details-container.component.ts b/src/app/features/website/product/containers/product-details-container.component.ts index b27abfe..49414d3 100644 --- a/src/app/features/website/product/containers/product-details-container.component.ts +++ b/src/app/features/website/product/containers/product-details-container.component.ts @@ -5,11 +5,12 @@ import { Product } from '../../../../core/products/models/product-domain.model'; import { EngagementListResult, Question, RatingSummary, Review, SubmitQuestionInput, SubmitReviewInput } from '../../../../core/products/models/product-engagement.model'; import { ConfigService } from '../../../../core/config/config.service'; import { ProductFacade } from '../../../../facades/platform/product.facade'; +import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade'; import { TranslatePipe } from '../../../../i18n/translate.pipe'; import { LangRoutePipe } from '../../../../pipes/lang-route.pipe'; import { CartService } from '../../../../services'; import { LanguageService } from '../../../../services/language.service'; -import { DEFAULT_PRODUCT_PAGE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; +import { DEFAULT_PRODUCT_PAGE_CONFIG, DEFAULT_USER_EXPERIENCE_CONFIG, ProductPageConfig } from '../../../../shared/models/config'; import { getStockStatus, getTranslatedField } from '../../../../utils/item.utils'; import { ProductDeliveryInformationComponent } from '../components/delivery-information/delivery-information.component'; import { ProductGalleryComponent } from '../components/product-gallery/product-gallery.component'; @@ -52,10 +53,12 @@ export class ProductDetailsContainerComponent { private readonly destroyRef = inject(DestroyRef); private readonly configService = inject(ConfigService); private readonly productFacade = inject(ProductFacade); + private readonly uxFacade = inject(UserExperienceFacade); private readonly cartService = inject(CartService); private readonly languageService = inject(LanguageService); readonly productPageConfigState = signal>(this.resolveProductPageConfig()); + readonly userExperienceConfig = signal(this.resolveUserExperienceConfig()); readonly product = signal(null); readonly relatedProducts = signal([]); @@ -178,6 +181,9 @@ export class ProductDetailsContainerComponent { } this.product.set(product); + if (this.userExperienceConfig().recentlyViewed.enabled) { + this.uxFacade.trackRecentlyViewed(product, this.userExperienceConfig().recentlyViewed.maxItems); + } this.initVariantSelection(product); this.loadRelatedProducts(product); this.loadEngagement(product.itemID); @@ -461,4 +467,20 @@ export class ProductDetailsContainerComponent { } }; } + + private resolveUserExperienceConfig() { + const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience ?? {}; + + return { + ...DEFAULT_USER_EXPERIENCE_CONFIG, + ...raw, + recentlyViewed: { + ...DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed, + ...(raw.recentlyViewed ?? {}), + maxItems: Number.isFinite(raw.recentlyViewed?.maxItems) + ? Math.max(1, Number(raw.recentlyViewed.maxItems)) + : DEFAULT_USER_EXPERIENCE_CONFIG.recentlyViewed.maxItems + } + }; + } } diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.html b/src/app/features/website/user-experience/compare/components/compare-table.component.html new file mode 100644 index 0000000..f00ad9b --- /dev/null +++ b/src/app/features/website/user-experience/compare/components/compare-table.component.html @@ -0,0 +1,26 @@ +@if (products.length > 0) { +
+ + + + + @for (product of products; track product.itemID) { + + } + + + + @for (row of rows(); track row.key) { + + + @for (value of row.values; track $index) { + + } + + } + +
Attribute +
{{ product.name }}
+
{{ row.label }}{{ value }}
+
+} diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.scss b/src/app/features/website/user-experience/compare/components/compare-table.component.scss new file mode 100644 index 0000000..606f12e --- /dev/null +++ b/src/app/features/website/user-experience/compare/components/compare-table.component.scss @@ -0,0 +1,43 @@ +.compare-table-wrap { + overflow-x: auto; + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-primary); +} + +.compare-table { + width: 100%; + min-width: 680px; + border-collapse: collapse; +} + +.compare-table th, +.compare-table td { + border-bottom: 1px solid var(--border-color); + padding: 12px; + text-align: left; + vertical-align: top; +} + +.compare-table th { + color: var(--text-primary); + background: color-mix(in srgb, var(--bg-secondary) 60%, white); +} + +.compare-product-title { + font-size: 0.95rem; + font-weight: 700; + line-height: 1.3; +} + +.compare-different td { + background: color-mix(in srgb, var(--warning-color) 9%, white); +} + +@media (max-width: 640px) { + .compare-table th, + .compare-table td { + padding: 10px; + font-size: 0.9rem; + } +} diff --git a/src/app/features/website/user-experience/compare/components/compare-table.component.ts b/src/app/features/website/user-experience/compare/components/compare-table.component.ts new file mode 100644 index 0000000..62b3313 --- /dev/null +++ b/src/app/features/website/user-experience/compare/components/compare-table.component.ts @@ -0,0 +1,63 @@ +import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core'; +import { Product } from '../../../../../core/products/models/product-domain.model'; + +interface CompareRow { + key: string; + label: string; + values: string[]; + identical: boolean; +} + +@Component({ + selector: 'app-compare-table', + standalone: true, + imports: [], + templateUrl: './compare-table.component.html', + styleUrls: ['./compare-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CompareTableComponent { + @Input() products: Product[] = []; + @Input() hideIdentical = false; + @Input() highlightDifferences = true; + + readonly rows = computed(() => { + const products = this.products; + if (products.length === 0) { + return []; + } + + const baseRows: CompareRow[] = [ + this.toRow('price', 'Price', products.map(product => `${product.price.toFixed(2)} ${product.currency}`)), + this.toRow('rating', 'Rating', products.map(product => `${(product.rating ?? 0).toFixed(1)}`)), + this.toRow('stock', 'Stock', products.map(product => product.remainings ?? 'unknown')), + this.toRow('discount', 'Discount', products.map(product => `${product.discount ?? 0}%`)), + this.toRow('color', 'Color', products.map(product => product.colour ?? '—')), + this.toRow('size', 'Size', products.map(product => product.size ?? '—')) + ]; + + const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))]; + + for (const key of dynamicKeys) { + const values = products.map(product => { + const value = (product.descriptionFields ?? []).find(field => field.key === key)?.value; + return value?.trim() || '—'; + }); + + baseRows.push(this.toRow(`attr:${key}`, key, values)); + } + + return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows; + }); + + isDifferentRow(row: CompareRow): boolean { + return this.highlightDifferences && !row.identical; + } + + private toRow(key: string, label: string, values: string[]): CompareRow { + const normalized = values.map(value => value.trim().toLowerCase()); + const identical = normalized.every(value => value === normalized[0]); + + return { key, label, values, identical }; + } +} diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.html b/src/app/features/website/user-experience/compare/containers/compare-page.component.html new file mode 100644 index 0000000..0fcd0b8 --- /dev/null +++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.html @@ -0,0 +1,46 @@ +
+
+
+

{{ 'ux.compareTitle' | translate }}

+

{{ products().length }} {{ 'ux.items' | translate }}

+
+ + @if (hasItems()) { + + } +
+ + @if (hasItems()) { +
+ + + +
+ +
+ @for (product of products(); track product.itemID) { + + } +
+ + + } @else { +
+

{{ 'ux.compareEmptyTitle' | translate }}

+

{{ 'ux.compareEmptyDescription' | translate }}

+ {{ 'ux.goToCatalog' | translate }} +
+ } +
diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.scss b/src/app/features/website/user-experience/compare/containers/compare-page.component.scss new file mode 100644 index 0000000..5df494f --- /dev/null +++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.scss @@ -0,0 +1,84 @@ +.compare-page { + display: grid; + gap: 14px; +} + +.compare-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.compare-head h1, +.compare-head p { + margin: 0; +} + +.compare-head p { + color: var(--text-secondary); +} + +.compare-controls { + display: flex; + gap: 18px; + align-items: center; + flex-wrap: wrap; + padding: 12px; +} + +.compare-controls label { + display: inline-flex; + gap: 8px; + align-items: center; + color: var(--text-primary); + font-weight: 600; +} + +.compare-products-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.compare-product-chip { + display: inline-flex; + align-items: center; + gap: 8px; + border: 1px solid var(--border-color); + border-radius: 999px; + padding: 4px 10px; + background: var(--bg-primary); +} + +.compare-product-chip a { + color: var(--text-primary); + text-decoration: none; + font-weight: 600; +} + +.compare-product-chip button { + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.compare-empty { + min-height: 220px; + display: grid; + place-content: center; + gap: 8px; + text-align: center; + padding: 24px; +} + +.compare-empty h2, +.compare-empty p { + margin: 0; +} + +.compare-empty p { + color: var(--text-secondary); +} diff --git a/src/app/features/website/user-experience/compare/containers/compare-page.component.ts b/src/app/features/website/user-experience/compare/containers/compare-page.component.ts new file mode 100644 index 0000000..56e4baa --- /dev/null +++ b/src/app/features/website/user-experience/compare/containers/compare-page.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ConfigService } from '../../../../../core/config/config.service'; +import { Product } from '../../../../../core/products/models/product-domain.model'; +import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe'; +import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config'; +import { CompareTableComponent } from '../components/compare-table.component'; + +@Component({ + selector: 'app-compare-page', + standalone: true, + imports: [RouterLink, TranslatePipe, LangRoutePipe, CompareTableComponent], + templateUrl: './compare-page.component.html', + styleUrls: ['./compare-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ComparePageComponent { + private readonly uxFacade = inject(UserExperienceFacade); + private readonly configService = inject(ConfigService); + + private readonly compareConfig = this.resolveCompareConfig(); + + readonly hideIdentical = signal(this.compareConfig.hideIdenticalDefault); + readonly highlightDifferences = signal(this.compareConfig.highlightDifferencesDefault); + + readonly compared = this.uxFacade.comparedProducts; + readonly products = computed(() => this.compared().map(entry => entry.product)); + readonly hasItems = computed(() => this.products().length > 0); + + remove(productId: number): void { + this.uxFacade.removeFromCompare(productId); + } + + clear(): void { + this.uxFacade.clearCompare(); + } + + private resolveCompareConfig() { + const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {}; + return { + ...DEFAULT_USER_EXPERIENCE_CONFIG.compare, + ...raw, + maxItems: Number.isFinite(raw.maxItems) ? Math.max(2, Number(raw.maxItems)) : DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems + }; + } +} diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html new file mode 100644 index 0000000..a4f6982 --- /dev/null +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.html @@ -0,0 +1,10 @@ +@if (notifications().length > 0) { + +} diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss new file mode 100644 index 0000000..61e4bf1 --- /dev/null +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.scss @@ -0,0 +1,70 @@ +.floating-notifications { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1200; + display: grid; + gap: 10px; + width: min(360px, calc(100vw - 24px)); +} + +.floating-note { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + align-items: center; + border-radius: 12px; + border: 1px solid var(--border-color); + background: #ffffff; + box-shadow: 0 10px 24px rgba(30, 60, 56, 0.2); + padding: 10px 12px; + animation: note-in 220ms ease-out both; +} + +.floating-note p { + margin: 0; + color: var(--text-primary); + font-weight: 600; + line-height: 1.3; +} + +.floating-note button { + border: 0; + background: transparent; + color: var(--text-secondary); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; +} + +.floating-note-success { + border-color: color-mix(in srgb, var(--success-color) 50%, white); +} + +.floating-note-warning { + border-color: color-mix(in srgb, var(--warning-color) 50%, white); +} + +.floating-note-info { + border-color: color-mix(in srgb, var(--primary-color) 45%, white); +} + +@keyframes note-in { + from { + opacity: 0; + transform: translateY(6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@media (max-width: 640px) { + .floating-notifications { + right: 10px; + left: 10px; + bottom: 10px; + width: auto; + } +} diff --git a/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts new file mode 100644 index 0000000..24bc528 --- /dev/null +++ b/src/app/features/website/user-experience/components/floating-notifications/floating-notifications.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { UserNotificationService } from '../../services/user-notification.service'; + +@Component({ + selector: 'app-floating-notifications', + standalone: true, + templateUrl: './floating-notifications.component.html', + styleUrls: ['./floating-notifications.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FloatingNotificationsComponent { + private readonly notificationsService = inject(UserNotificationService); + + readonly notifications = this.notificationsService.notifications; + + dismiss(id: string): void { + this.notificationsService.dismiss(id); + } +} diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html new file mode 100644 index 0000000..f0c00e6 --- /dev/null +++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.html @@ -0,0 +1,26 @@ +
+
+

{{ title }}

+ @if (showClear && hasItems()) { + + } +
+ + @if (hasItems()) { +
+ @for (entry of items(); track entry.product.itemID) { + + } +
+ } @else { +

{{ 'ux.recentlyViewedEmpty' | translate }}

+ } +
diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss new file mode 100644 index 0000000..3633020 --- /dev/null +++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.scss @@ -0,0 +1,30 @@ +.recently-viewed { + padding: 14px; + display: grid; + gap: 12px; +} + +.recently-viewed-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.recently-viewed-head h2 { + margin: 0; + font-size: 1.25rem; + color: var(--text-primary); +} + +.recently-viewed-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; +} + +.recently-viewed-empty { + margin: 0; + color: var(--text-secondary); +} diff --git a/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts new file mode 100644 index 0000000..3fe3207 --- /dev/null +++ b/src/app/features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component.ts @@ -0,0 +1,44 @@ +import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@angular/core'; +import { ProductCardComponent } from '../../../../../components/product-card/product-card.component'; +import { Product } from '../../../../../core/products/models/product-domain.model'; +import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { CartService } from '../../../../../services'; +import { UserNotificationService } from '../../services/user-notification.service'; + +@Component({ + selector: 'app-recently-viewed-strip', + standalone: true, + imports: [ProductCardComponent, TranslatePipe], + templateUrl: './recently-viewed-strip.component.html', + styleUrls: ['./recently-viewed-strip.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RecentlyViewedStripComponent { + @Input() title = 'Recently Viewed'; + @Input() maxItems = 8; + @Input() showClear = false; + + private readonly uxFacade = inject(UserExperienceFacade); + private readonly cartService = inject(CartService); + private readonly notifications = inject(UserNotificationService); + + readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems))); + readonly hasItems = computed(() => this.items().length > 0); + + addToCart(itemID: number, event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.cartService.addItem(itemID); + this.notifications.show('Added to cart', 'success'); + } + + clear(): void { + this.uxFacade.clearRecentlyViewed(); + this.notifications.show('Recently viewed cleared', 'info'); + } + + itemProduct(index: number): Product { + return this.items()[index].product; + } +} diff --git a/src/app/features/website/user-experience/services/product-share.service.ts b/src/app/features/website/user-experience/services/product-share.service.ts new file mode 100644 index 0000000..12b85e4 --- /dev/null +++ b/src/app/features/website/user-experience/services/product-share.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@angular/core'; +import { Product } from '../../../../core/products/models/product-domain.model'; + +export type ShareResult = 'native' | 'copied' | 'unsupported'; + +@Injectable({ providedIn: 'root' }) +export class ProductShareService { + async shareProduct(product: Product, productUrl: string): Promise { + if (typeof window === 'undefined') { + return 'unsupported'; + } + + const title = product.name; + const text = product.simpleDescription ?? product.name; + + if (typeof navigator !== 'undefined' && 'share' in navigator) { + try { + await navigator.share({ title, text, url: productUrl }); + return 'native'; + } catch { + // User cancellation and unsupported payload should fall back to clipboard copy. + } + } + + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(productUrl); + return 'copied'; + } + + return 'unsupported'; + } +} diff --git a/src/app/features/website/user-experience/services/user-notification.service.ts b/src/app/features/website/user-experience/services/user-notification.service.ts new file mode 100644 index 0000000..05a9728 --- /dev/null +++ b/src/app/features/website/user-experience/services/user-notification.service.ts @@ -0,0 +1,32 @@ +import { Injectable, signal } from '@angular/core'; + +export type UserNotificationType = 'success' | 'info' | 'warning'; + +export interface UserNotification { + id: string; + message: string; + type: UserNotificationType; +} + +@Injectable({ providedIn: 'root' }) +export class UserNotificationService { + private readonly state = signal([]); + + readonly notifications = this.state.asReadonly(); + + show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500): void { + const next: UserNotification = { + id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + message, + type + }; + + this.state.update(items => [next, ...items].slice(0, 4)); + + setTimeout(() => this.dismiss(next.id), durationMs); + } + + dismiss(id: string): void { + this.state.update(items => items.filter(item => item.id !== id)); + } +} diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html new file mode 100644 index 0000000..4687661 --- /dev/null +++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.html @@ -0,0 +1,35 @@ +
+
+
+

{{ 'ux.wishlistTitle' | translate }}

+

{{ favorites().length }} {{ 'ux.items' | translate }}

+
+ @if (hasItems()) { + + } +
+ + @if (hasItems()) { +
+ @for (entry of favorites(); track entry.product.itemID) { +
+ +
+ } +
+ } @else { +
+

{{ 'ux.wishlistEmptyTitle' | translate }}

+

{{ 'ux.wishlistEmptyDescription' | translate }}

+ {{ 'ux.goToCatalog' | translate }} +
+ } +
diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss new file mode 100644 index 0000000..3d0371e --- /dev/null +++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.scss @@ -0,0 +1,47 @@ +.wishlist-page { + display: grid; + gap: 18px; +} + +.wishlist-head { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 12px; + flex-wrap: wrap; +} + +.wishlist-head h1, +.wishlist-head p { + margin: 0; +} + +.wishlist-head p { + color: var(--text-secondary); +} + +.wishlist-grid { + align-items: stretch; +} + +.wishlist-item { + min-width: 0; +} + +.wishlist-empty { + min-height: 220px; + display: grid; + place-content: center; + gap: 8px; + text-align: center; + padding: 24px; +} + +.wishlist-empty h2, +.wishlist-empty p { + margin: 0; +} + +.wishlist-empty p { + color: var(--text-secondary); +} diff --git a/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts new file mode 100644 index 0000000..8b09c36 --- /dev/null +++ b/src/app/features/website/user-experience/wishlist/containers/wishlist-page.component.ts @@ -0,0 +1,47 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { ProductCardComponent } from '../../../../../components/product-card/product-card.component'; +import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade'; +import { TranslatePipe } from '../../../../../i18n/translate.pipe'; +import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe'; +import { CartService } from '../../../../../services'; +import { UserNotificationService } from '../../services/user-notification.service'; + +@Component({ + selector: 'app-wishlist-page', + standalone: true, + imports: [RouterLink, ProductCardComponent, TranslatePipe, LangRoutePipe], + templateUrl: './wishlist-page.component.html', + styleUrls: ['./wishlist-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WishlistPageComponent { + private readonly cartService = inject(CartService); + private readonly uxFacade = inject(UserExperienceFacade); + private readonly notifications = inject(UserNotificationService); + + readonly favorites = this.uxFacade.wishlist; + readonly hasItems = computed(() => this.favorites().length > 0); + + addToCart(itemId: number, event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.cartService.addItem(itemId); + this.notifications.show('Added to cart', 'success'); + } + + removeFromWishlist(itemId: number): void { + const target = this.favorites().find(entry => entry.product.itemID === itemId); + if (!target) { + return; + } + + this.uxFacade.toggleWishlist(target.product); + this.notifications.show('Removed from wishlist', 'info'); + } + + clearWishlist(): void { + this.uxFacade.clearWishlist(); + this.notifications.show('Wishlist cleared', 'info'); + } +} diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index ab024bc..18ad85d 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -8,6 +8,8 @@ export const en: Translations = { contacts: 'Contacts', searchPlaceholder: 'Search...', catalog: 'Catalog', + wishlist: 'Wishlist', + compare: 'Compare', }, footer: { description: 'A modern marketplace for comfortable shopping', @@ -243,4 +245,21 @@ export const en: Translations = { qrExpired: 'QR code expired. Click to refresh', qrError: 'Could not create login session. Click to retry', }, + ux: { + items: 'items', + wishlistTitle: 'Wishlist', + wishlistEmptyTitle: 'Your wishlist is empty', + wishlistEmptyDescription: 'Save products to revisit them anytime.', + clearWishlist: 'Clear wishlist', + compareTitle: 'Compare Products', + compareEmptyTitle: 'No products to compare', + compareEmptyDescription: 'Add products from catalog cards to compare specs and prices.', + clearCompare: 'Clear compare', + hideIdentical: 'Hide identical values', + highlightDifferences: 'Highlight differences', + clearRecentlyViewed: 'Clear recently viewed', + recentlyViewedEmpty: 'No recently viewed products yet.', + saveSearch: 'Save search', + goToCatalog: 'Go to catalog', + }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 2912350..05996e1 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -8,6 +8,8 @@ export const hy: Translations = { contacts: 'Կապ', searchPlaceholder: 'Փնտրել...', catalog: 'Կատալոգ', + wishlist: 'Ընտրյալ', + compare: 'Համեմատում', }, footer: { description: 'Ժամանակակից մարքեթփլեյս հարմար գնումների համար', @@ -243,4 +245,21 @@ export const hy: Translations = { qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար', qrError: 'Չհաջողվեց ստեղծել մուտքի սեսիա։ Սեղմեք՝ կրկնելու համար', }, + ux: { + items: 'ապրանք', + wishlistTitle: 'Ընտրյալներ', + wishlistEmptyTitle: 'Ընտրյալները դատարկ են', + wishlistEmptyDescription: 'Պահպանեք ապրանքները, որպեսզի արագ վերադառնաք դրանց։', + clearWishlist: 'Մաքրել ընտրյալները', + compareTitle: 'Ապրանքների համեմատում', + compareEmptyTitle: 'Համեմատելու ապրանքներ չկան', + compareEmptyDescription: 'Ավելացրեք ապրանքներ կատալոգից՝ բնութագրերը և գները համեմատելու համար։', + clearCompare: 'Մաքրել համեմատումը', + hideIdentical: 'Թաքցնել նույն արժեքները', + highlightDifferences: 'Ընդգծել տարբերությունները', + clearRecentlyViewed: 'Մաքրել դիտվածները', + recentlyViewedEmpty: 'Դուք դեռ ապրանքներ չեք դիտել։', + saveSearch: 'Պահպանել որոնումը', + goToCatalog: 'Գնալ կատալոգ', + }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 9fea86f..43b767e 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -8,6 +8,8 @@ export const ru: Translations = { contacts: 'Контакты', searchPlaceholder: 'Искать...', catalog: 'Каталог', + wishlist: 'Избранное', + compare: 'Сравнение', }, footer: { description: 'Современный маркетплейс для комфортных покупок', @@ -243,4 +245,21 @@ export const ru: Translations = { qrExpired: 'QR-код устарел. Нажмите, чтобы обновить', qrError: 'Не удалось создать сессию входа. Нажмите, чтобы повторить', }, + ux: { + items: 'товаров', + wishlistTitle: 'Избранное', + wishlistEmptyTitle: 'Избранное пока пусто', + wishlistEmptyDescription: 'Сохраняйте товары, чтобы быстро вернуться к ним позже.', + clearWishlist: 'Очистить избранное', + compareTitle: 'Сравнение товаров', + compareEmptyTitle: 'Нет товаров для сравнения', + compareEmptyDescription: 'Добавьте товары из каталога, чтобы сравнить характеристики и цену.', + clearCompare: 'Очистить сравнение', + hideIdentical: 'Скрыть одинаковые значения', + highlightDifferences: 'Подсвечивать различия', + clearRecentlyViewed: 'Очистить просмотренные', + recentlyViewedEmpty: 'Вы пока не просматривали товары.', + saveSearch: 'Сохранить поиск', + goToCatalog: 'Перейти в каталог', + }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 77dfe34..9a62053 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -6,6 +6,8 @@ export interface Translations { contacts: string; searchPlaceholder: string; catalog: string; + wishlist: string; + compare: string; }; footer: { description: string; @@ -241,4 +243,21 @@ export interface Translations { qrExpired: string; qrError: string; }; + ux: { + items: string; + wishlistTitle: string; + wishlistEmptyTitle: string; + wishlistEmptyDescription: string; + clearWishlist: string; + compareTitle: string; + compareEmptyTitle: string; + compareEmptyDescription: string; + clearCompare: string; + hideIdentical: string; + highlightDifferences: string; + clearRecentlyViewed: string; + recentlyViewedEmpty: string; + saveSearch: string; + goToCatalog: string; + }; } diff --git a/src/app/layouts/containers/dynamic-page-layout.component.ts b/src/app/layouts/containers/dynamic-page-layout.component.ts index 2a0587e..1aeef10 100644 --- a/src/app/layouts/containers/dynamic-page-layout.component.ts +++ b/src/app/layouts/containers/dynamic-page-layout.component.ts @@ -43,7 +43,7 @@ import { Category } from '../../core/categories/models/category-domain.model'; [class.dynamic-widget--mobile-hidden]="widget.visibility?.mobile === false" > @if (resolveWidget(widget, section, model.id) | async; as resolved) { - + } } diff --git a/src/app/shared/models/config/bootstrap-config.model.ts b/src/app/shared/models/config/bootstrap-config.model.ts index fd80e0c..3e45bc9 100644 --- a/src/app/shared/models/config/bootstrap-config.model.ts +++ b/src/app/shared/models/config/bootstrap-config.model.ts @@ -14,6 +14,7 @@ import { SeoConfig } from './seo.model'; import { StaticPagesConfig } from './static-page.model'; import { TenantConfig } from './tenant.model'; import { ThemeConfig } from './theme.model'; +import { UserExperienceConfig } from './user-experience-config.model'; import { WidgetRegistryConfig } from './widget-registry.model'; export interface BootstrapConfig { @@ -33,6 +34,7 @@ export interface BootstrapConfig { navigation: NavigationConfig; footer?: FooterConfig; productPage?: ProductPageConfig; + userExperience?: UserExperienceConfig; pages: PageConfig[]; staticPages?: StaticPagesConfig; widgetRegistry?: WidgetRegistryConfig; diff --git a/src/app/shared/models/config/index.ts b/src/app/shared/models/config/index.ts index 3288da9..19028cf 100644 --- a/src/app/shared/models/config/index.ts +++ b/src/app/shared/models/config/index.ts @@ -16,5 +16,6 @@ export * from './seo.model'; export * from './static-page.model'; export * from './tenant.model'; export * from './theme.model'; +export * from './user-experience-config.model'; export * from './widget.model'; export * from './widget-registry.model'; diff --git a/src/app/shared/models/config/user-experience-config.model.ts b/src/app/shared/models/config/user-experience-config.model.ts new file mode 100644 index 0000000..fb96246 --- /dev/null +++ b/src/app/shared/models/config/user-experience-config.model.ts @@ -0,0 +1,67 @@ +export interface WishlistFeatureConfig { + enabled?: boolean; + headerBadgeEnabled?: boolean; +} + +export interface CompareFeatureConfig { + enabled?: boolean; + maxItems?: number; + hideIdenticalDefault?: boolean; + highlightDifferencesDefault?: boolean; +} + +export interface RecentlyViewedFeatureConfig { + enabled?: boolean; + maxItems?: number; + widgetEnabled?: boolean; +} + +export interface ShareFeatureConfig { + enabled?: boolean; +} + +export interface ContinueBrowsingFeatureConfig { + enabled?: boolean; +} + +export interface SavedSearchesFeatureConfig { + enabled?: boolean; + maxItems?: number; +} + +export interface UserExperienceConfig { + wishlist?: WishlistFeatureConfig; + compare?: CompareFeatureConfig; + recentlyViewed?: RecentlyViewedFeatureConfig; + share?: ShareFeatureConfig; + continueBrowsing?: ContinueBrowsingFeatureConfig; + savedSearches?: SavedSearchesFeatureConfig; +} + +export const DEFAULT_USER_EXPERIENCE_CONFIG: Required = { + wishlist: { + enabled: true, + headerBadgeEnabled: true + }, + compare: { + enabled: true, + maxItems: 4, + hideIdenticalDefault: false, + highlightDifferencesDefault: true + }, + recentlyViewed: { + enabled: true, + maxItems: 12, + widgetEnabled: true + }, + share: { + enabled: true + }, + continueBrowsing: { + enabled: true + }, + savedSearches: { + enabled: true, + maxItems: 10 + } +}; diff --git a/src/app/widgets/registry/widget-registry.bootstrap.service.ts b/src/app/widgets/registry/widget-registry.bootstrap.service.ts index d0da558..c9a3f8d 100644 --- a/src/app/widgets/registry/widget-registry.bootstrap.service.ts +++ b/src/app/widgets/registry/widget-registry.bootstrap.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { Observable, map, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; -import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent } from '../ui'; +import { CategoriesWidgetComponent, FooterNavigationWidgetComponent, HeroWidgetComponent, ProductCarouselWidgetComponent, RecentlyViewedWidgetComponent } from '../ui'; import { RegisteredWidget } from '../contracts/widget-component.contract'; import { WidgetRegistryService } from './widget-registry.service'; import { WidgetManifestService } from './widget-manifest.service'; @@ -12,7 +12,8 @@ const APPROVED_WIDGET_COMPONENTS: Record 'footer': FooterNavigationWidgetComponent, 'footer-navigation': FooterNavigationWidgetComponent, 'product-carousel': ProductCarouselWidgetComponent, - 'product-collection': ProductCarouselWidgetComponent + 'product-collection': ProductCarouselWidgetComponent, + 'recently-viewed': RecentlyViewedWidgetComponent }; @Injectable({ providedIn: 'root' }) diff --git a/src/app/widgets/resolvers/data-source-resolver.service.ts b/src/app/widgets/resolvers/data-source-resolver.service.ts index d04a6e3..89c8dae 100644 --- a/src/app/widgets/resolvers/data-source-resolver.service.ts +++ b/src/app/widgets/resolvers/data-source-resolver.service.ts @@ -42,6 +42,8 @@ export class DataSourceResolverService { case 'footer': case 'footer-navigation': return of(this.toFooterData(section, settings)); + case 'recently-viewed': + return of({ section, settings }); default: return of({ section, settings }); } diff --git a/src/app/widgets/ui/categories-widget.component.ts b/src/app/widgets/ui/categories-widget.component.ts index 4b04085..ec59878 100644 --- a/src/app/widgets/ui/categories-widget.component.ts +++ b/src/app/widgets/ui/categories-widget.component.ts @@ -18,7 +18,7 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract'; @if (widgetData.categories.length) { } @else if (widgetData.emptyMessage) {

{{ widgetData.emptyMessage }}

@@ -37,9 +37,11 @@ import { CategoriesWidgetData } from '../contracts/widget-data.contract'; export class CategoriesWidgetComponent { @Input() section: SectionConfig | null = null; @Input() data: CategoriesWidgetData | null = null; + @Input() categorySelectedCallback: ((category: unknown) => void) | null = null; @Output() categorySelected = new EventEmitter(); - onCategorySelected(category: unknown): void { + handleCategorySelected(category: unknown): void { + this.categorySelectedCallback?.(category); this.categorySelected.emit(category); } } \ No newline at end of file diff --git a/src/app/widgets/ui/index.ts b/src/app/widgets/ui/index.ts index 500c8c5..e3a77ef 100644 --- a/src/app/widgets/ui/index.ts +++ b/src/app/widgets/ui/index.ts @@ -2,4 +2,5 @@ export * from './categories-widget.component'; export * from './footer-navigation-widget.component'; export * from './hero-widget.component'; export * from './product-carousel-widget.component'; +export * from './recently-viewed-widget.component'; export * from './unknown-widget.component'; diff --git a/src/app/widgets/ui/recently-viewed-widget.component.ts b/src/app/widgets/ui/recently-viewed-widget.component.ts new file mode 100644 index 0000000..af3b7d2 --- /dev/null +++ b/src/app/widgets/ui/recently-viewed-widget.component.ts @@ -0,0 +1,36 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { SectionConfig } from '../../shared/models/config'; +import { RecentlyViewedStripComponent } from '../../features/website/user-experience/components/recently-viewed-strip/recently-viewed-strip.component'; + +@Component({ + selector: 'app-recently-viewed-widget', + standalone: true, + imports: [RecentlyViewedStripComponent], + template: ` + + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RecentlyViewedWidgetComponent { + @Input() section: SectionConfig | null = null; + @Input() data: { settings?: Record } | null = null; + + get title(): string { + const raw = this.data?.settings?.['title']; + return typeof raw === 'string' && raw.trim().length > 0 ? raw : 'Recently Viewed'; + } + + get maxItems(): number { + const raw = this.data?.settings?.['maxItems']; + const value = typeof raw === 'number' ? raw : Number(raw); + return Number.isFinite(value) ? Math.max(1, value) : 8; + } + + get showClear(): boolean { + return this.data?.settings?.['showClear'] === true; + } +} diff --git a/src/assets/mock/bootstrap/bootstrap.json b/src/assets/mock/bootstrap/bootstrap.json index 1998043..e947c11 100644 --- a/src/assets/mock/bootstrap/bootstrap.json +++ b/src/assets/mock/bootstrap/bootstrap.json @@ -282,6 +282,33 @@ "enabled": true } }, + "userExperience": { + "wishlist": { + "enabled": true, + "headerBadgeEnabled": true + }, + "compare": { + "enabled": true, + "maxItems": 4, + "hideIdenticalDefault": false, + "highlightDifferencesDefault": true + }, + "recentlyViewed": { + "enabled": true, + "maxItems": 12, + "widgetEnabled": true + }, + "share": { + "enabled": true + }, + "continueBrowsing": { + "enabled": true + }, + "savedSearches": { + "enabled": true, + "maxItems": 10 + } + }, "widgetRegistry": { "manifestUrl": "/assets/mock/bootstrap/widget-manifest.json" }, diff --git a/src/assets/mock/bootstrap/widget-manifest.json b/src/assets/mock/bootstrap/widget-manifest.json index e910bcb..61337a1 100644 --- a/src/assets/mock/bootstrap/widget-manifest.json +++ b/src/assets/mock/bootstrap/widget-manifest.json @@ -125,6 +125,27 @@ }, "enabled": true }, + { + "type": "recently-viewed", + "version": "1.0.0", + "componentKey": "recently-viewed", + "supportedLayouts": ["stack", "grid", "carousel"], + "supportedDataSources": ["future"], + "settingsSchema": { + "type": "object", + "properties": { + "title": { "type": "string" }, + "maxItems": { "type": "number" }, + "showClear": { "type": "boolean" } + } + }, + "defaultSettings": { + "title": "Recently Viewed", + "maxItems": 8, + "showClear": false + }, + "enabled": true + }, { "type": "banner", "version": "1.0.0",