feat(ux): implement sprint 11 user experience module
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
@if (products.length > 0) {
|
||||
<div class="compare-table-wrap">
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Attribute</th>
|
||||
@for (product of products; track product.itemID) {
|
||||
<th>
|
||||
<div class="compare-product-title">{{ product.name }}</div>
|
||||
</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows(); track row.key) {
|
||||
<tr [class.compare-different]="isDifferentRow(row)">
|
||||
<th>{{ row.label }}</th>
|
||||
@for (value of row.values; track $index) {
|
||||
<td>{{ value }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
.compare-table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.compare-table {
|
||||
width: 100%;
|
||||
min-width: 680px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.compare-table th,
|
||||
.compare-table td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.compare-table th {
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-secondary) 60%, white);
|
||||
}
|
||||
|
||||
.compare-product-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.compare-different td {
|
||||
background: color-mix(in srgb, var(--warning-color) 9%, white);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compare-table th,
|
||||
.compare-table td {
|
||||
padding: 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, computed } from '@angular/core';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
|
||||
interface CompareRow {
|
||||
key: string;
|
||||
label: string;
|
||||
values: string[];
|
||||
identical: boolean;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-compare-table',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
templateUrl: './compare-table.component.html',
|
||||
styleUrls: ['./compare-table.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompareTableComponent {
|
||||
@Input() products: Product[] = [];
|
||||
@Input() hideIdentical = false;
|
||||
@Input() highlightDifferences = true;
|
||||
|
||||
readonly rows = computed<CompareRow[]>(() => {
|
||||
const products = this.products;
|
||||
if (products.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const baseRows: CompareRow[] = [
|
||||
this.toRow('price', 'Price', products.map(product => `${product.price.toFixed(2)} ${product.currency}`)),
|
||||
this.toRow('rating', 'Rating', products.map(product => `${(product.rating ?? 0).toFixed(1)}`)),
|
||||
this.toRow('stock', 'Stock', products.map(product => product.remainings ?? 'unknown')),
|
||||
this.toRow('discount', 'Discount', products.map(product => `${product.discount ?? 0}%`)),
|
||||
this.toRow('color', 'Color', products.map(product => product.colour ?? '—')),
|
||||
this.toRow('size', 'Size', products.map(product => product.size ?? '—'))
|
||||
];
|
||||
|
||||
const dynamicKeys = [...new Set(products.flatMap(product => (product.descriptionFields ?? []).map(field => field.key)))];
|
||||
|
||||
for (const key of dynamicKeys) {
|
||||
const values = products.map(product => {
|
||||
const value = (product.descriptionFields ?? []).find(field => field.key === key)?.value;
|
||||
return value?.trim() || '—';
|
||||
});
|
||||
|
||||
baseRows.push(this.toRow(`attr:${key}`, key, values));
|
||||
}
|
||||
|
||||
return this.hideIdentical ? baseRows.filter(row => !row.identical) : baseRows;
|
||||
});
|
||||
|
||||
isDifferentRow(row: CompareRow): boolean {
|
||||
return this.highlightDifferences && !row.identical;
|
||||
}
|
||||
|
||||
private toRow(key: string, label: string, values: string[]): CompareRow {
|
||||
const normalized = values.map(value => value.trim().toLowerCase());
|
||||
const identical = normalized.every(value => value === normalized[0]);
|
||||
|
||||
return { key, label, values, identical };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<main class="compare-page page-container">
|
||||
<header class="compare-head">
|
||||
<div>
|
||||
<h1>{{ 'ux.compareTitle' | translate }}</h1>
|
||||
<p>{{ products().length }} {{ 'ux.items' | translate }}</p>
|
||||
</div>
|
||||
|
||||
@if (hasItems()) {
|
||||
<button type="button" class="btn btn-ghost" (click)="clear()">{{ 'ux.clearCompare' | translate }}</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (hasItems()) {
|
||||
<section class="compare-controls card">
|
||||
<label>
|
||||
<input type="checkbox" [checked]="hideIdentical()" (change)="hideIdentical.set($any($event.target).checked)" />
|
||||
{{ 'ux.hideIdentical' | translate }}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" [checked]="highlightDifferences()" (change)="highlightDifferences.set($any($event.target).checked)" />
|
||||
{{ 'ux.highlightDifferences' | translate }}
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section class="compare-products-list">
|
||||
@for (product of products(); track product.itemID) {
|
||||
<article class="compare-product-chip">
|
||||
<a [routerLink]="['/product', product.itemID] | langRoute">{{ product.name }}</a>
|
||||
<button type="button" (click)="remove(product.itemID)">×</button>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
||||
<app-compare-table
|
||||
[products]="products()"
|
||||
[hideIdentical]="hideIdentical()"
|
||||
[highlightDifferences]="highlightDifferences()" />
|
||||
} @else {
|
||||
<section class="compare-empty card">
|
||||
<h2>{{ 'ux.compareEmptyTitle' | translate }}</h2>
|
||||
<p>{{ 'ux.compareEmptyDescription' | translate }}</p>
|
||||
<a [routerLink]="'/catalog' | langRoute" class="btn btn-primary">{{ 'ux.goToCatalog' | translate }}</a>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,84 @@
|
||||
.compare-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.compare-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.compare-head h1,
|
||||
.compare-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-head p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.compare-controls {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.compare-controls label {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compare-products-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compare-product-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.compare-product-chip a {
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compare-product-chip button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compare-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.compare-empty h2,
|
||||
.compare-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.compare-empty p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ConfigService } from '../../../../../core/config/config.service';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||
import { DEFAULT_USER_EXPERIENCE_CONFIG } from '../../../../../shared/models/config';
|
||||
import { CompareTableComponent } from '../components/compare-table.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-compare-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink, TranslatePipe, LangRoutePipe, CompareTableComponent],
|
||||
templateUrl: './compare-page.component.html',
|
||||
styleUrls: ['./compare-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ComparePageComponent {
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly configService = inject(ConfigService);
|
||||
|
||||
private readonly compareConfig = this.resolveCompareConfig();
|
||||
|
||||
readonly hideIdentical = signal(this.compareConfig.hideIdenticalDefault);
|
||||
readonly highlightDifferences = signal(this.compareConfig.highlightDifferencesDefault);
|
||||
|
||||
readonly compared = this.uxFacade.comparedProducts;
|
||||
readonly products = computed<Product[]>(() => this.compared().map(entry => entry.product));
|
||||
readonly hasItems = computed(() => this.products().length > 0);
|
||||
|
||||
remove(productId: number): void {
|
||||
this.uxFacade.removeFromCompare(productId);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.uxFacade.clearCompare();
|
||||
}
|
||||
|
||||
private resolveCompareConfig() {
|
||||
const raw = (this.configService.getBootstrapSnapshot() as any)?.userExperience?.compare ?? {};
|
||||
return {
|
||||
...DEFAULT_USER_EXPERIENCE_CONFIG.compare,
|
||||
...raw,
|
||||
maxItems: Number.isFinite(raw.maxItems) ? Math.max(2, Number(raw.maxItems)) : DEFAULT_USER_EXPERIENCE_CONFIG.compare.maxItems
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@if (notifications().length > 0) {
|
||||
<aside class="floating-notifications" aria-live="polite" aria-atomic="true">
|
||||
@for (note of notifications(); track note.id) {
|
||||
<article class="floating-note" [class]="'floating-note floating-note-' + note.type">
|
||||
<p>{{ note.message }}</p>
|
||||
<button type="button" (click)="dismiss(note.id)" aria-label="Dismiss">×</button>
|
||||
</article>
|
||||
}
|
||||
</aside>
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.floating-notifications {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.floating-note {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: #ffffff;
|
||||
box-shadow: 0 10px 24px rgba(30, 60, 56, 0.2);
|
||||
padding: 10px 12px;
|
||||
animation: note-in 220ms ease-out both;
|
||||
}
|
||||
|
||||
.floating-note p {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.floating-note button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.floating-note-success {
|
||||
border-color: color-mix(in srgb, var(--success-color) 50%, white);
|
||||
}
|
||||
|
||||
.floating-note-warning {
|
||||
border-color: color-mix(in srgb, var(--warning-color) 50%, white);
|
||||
}
|
||||
|
||||
.floating-note-info {
|
||||
border-color: color-mix(in srgb, var(--primary-color) 45%, white);
|
||||
}
|
||||
|
||||
@keyframes note-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.floating-notifications {
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
bottom: 10px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||
import { UserNotificationService } from '../../services/user-notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-floating-notifications',
|
||||
standalone: true,
|
||||
templateUrl: './floating-notifications.component.html',
|
||||
styleUrls: ['./floating-notifications.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingNotificationsComponent {
|
||||
private readonly notificationsService = inject(UserNotificationService);
|
||||
|
||||
readonly notifications = this.notificationsService.notifications;
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.notificationsService.dismiss(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<section class="recently-viewed card section">
|
||||
<header class="recently-viewed-head">
|
||||
<h2>{{ title }}</h2>
|
||||
@if (showClear && hasItems()) {
|
||||
<button type="button" class="btn btn-ghost" (click)="clear()">{{ 'ux.clearRecentlyViewed' | translate }}</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (hasItems()) {
|
||||
<div class="recently-viewed-grid">
|
||||
@for (entry of items(); track entry.product.itemID) {
|
||||
<app-product-card
|
||||
[item]="entry.product"
|
||||
[appearance]="'compact'"
|
||||
[showDescription]="false"
|
||||
[showStock]="false"
|
||||
[showRating]="true"
|
||||
[showDiscountBadge]="true"
|
||||
[addToCartLabel]="'catalog.addToCart' | translate"
|
||||
(addToCart)="addToCart(entry.product.itemID, $event.event)" />
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p class="recently-viewed-empty">{{ 'ux.recentlyViewedEmpty' | translate }}</p>
|
||||
}
|
||||
</section>
|
||||
@@ -0,0 +1,30 @@
|
||||
.recently-viewed {
|
||||
padding: 14px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recently-viewed-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.recently-viewed-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.recently-viewed-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.recently-viewed-empty {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ChangeDetectionStrategy, Component, Input, computed, inject } from '@angular/core';
|
||||
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
|
||||
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { CartService } from '../../../../../services';
|
||||
import { UserNotificationService } from '../../services/user-notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-recently-viewed-strip',
|
||||
standalone: true,
|
||||
imports: [ProductCardComponent, TranslatePipe],
|
||||
templateUrl: './recently-viewed-strip.component.html',
|
||||
styleUrls: ['./recently-viewed-strip.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class RecentlyViewedStripComponent {
|
||||
@Input() title = 'Recently Viewed';
|
||||
@Input() maxItems = 8;
|
||||
@Input() showClear = false;
|
||||
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
|
||||
readonly items = computed(() => this.uxFacade.recentlyViewed().slice(0, Math.max(1, this.maxItems)));
|
||||
readonly hasItems = computed(() => this.items().length > 0);
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cartService.addItem(itemID);
|
||||
this.notifications.show('Added to cart', 'success');
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.uxFacade.clearRecentlyViewed();
|
||||
this.notifications.show('Recently viewed cleared', 'info');
|
||||
}
|
||||
|
||||
itemProduct(index: number): Product {
|
||||
return this.items()[index].product;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Product } from '../../../../core/products/models/product-domain.model';
|
||||
|
||||
export type ShareResult = 'native' | 'copied' | 'unsupported';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ProductShareService {
|
||||
async shareProduct(product: Product, productUrl: string): Promise<ShareResult> {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
const title = product.name;
|
||||
const text = product.simpleDescription ?? product.name;
|
||||
|
||||
if (typeof navigator !== 'undefined' && 'share' in navigator) {
|
||||
try {
|
||||
await navigator.share({ title, text, url: productUrl });
|
||||
return 'native';
|
||||
} catch {
|
||||
// User cancellation and unsupported payload should fall back to clipboard copy.
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(productUrl);
|
||||
return 'copied';
|
||||
}
|
||||
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
export type UserNotificationType = 'success' | 'info' | 'warning';
|
||||
|
||||
export interface UserNotification {
|
||||
id: string;
|
||||
message: string;
|
||||
type: UserNotificationType;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UserNotificationService {
|
||||
private readonly state = signal<UserNotification[]>([]);
|
||||
|
||||
readonly notifications = this.state.asReadonly();
|
||||
|
||||
show(message: string, type: UserNotificationType = 'info', durationMs: number = 2500): void {
|
||||
const next: UserNotification = {
|
||||
id: `note-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
message,
|
||||
type
|
||||
};
|
||||
|
||||
this.state.update(items => [next, ...items].slice(0, 4));
|
||||
|
||||
setTimeout(() => this.dismiss(next.id), durationMs);
|
||||
}
|
||||
|
||||
dismiss(id: string): void {
|
||||
this.state.update(items => items.filter(item => item.id !== id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<main class="wishlist-page page-container">
|
||||
<header class="wishlist-head">
|
||||
<div>
|
||||
<h1>{{ 'ux.wishlistTitle' | translate }}</h1>
|
||||
<p>{{ favorites().length }} {{ 'ux.items' | translate }}</p>
|
||||
</div>
|
||||
@if (hasItems()) {
|
||||
<button type="button" class="btn btn-ghost" (click)="clearWishlist()">{{ 'ux.clearWishlist' | translate }}</button>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (hasItems()) {
|
||||
<section class="wishlist-grid grid grid-4">
|
||||
@for (entry of favorites(); track entry.product.itemID) {
|
||||
<div class="wishlist-item">
|
||||
<app-product-card
|
||||
[item]="entry.product"
|
||||
[showFavoriteAction]="true"
|
||||
[showCompareAction]="false"
|
||||
[showShareAction]="false"
|
||||
[isFavorite]="true"
|
||||
[addToCartLabel]="'catalog.addToCart' | translate"
|
||||
(addToCart)="addToCart(entry.product.itemID, $event.event)"
|
||||
(favoriteToggled)="removeFromWishlist(entry.product.itemID)" />
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
} @else {
|
||||
<section class="wishlist-empty card">
|
||||
<h2>{{ 'ux.wishlistEmptyTitle' | translate }}</h2>
|
||||
<p>{{ 'ux.wishlistEmptyDescription' | translate }}</p>
|
||||
<a [routerLink]="'/catalog' | langRoute" class="btn btn-primary">{{ 'ux.goToCatalog' | translate }}</a>
|
||||
</section>
|
||||
}
|
||||
</main>
|
||||
@@ -0,0 +1,47 @@
|
||||
.wishlist-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.wishlist-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wishlist-head h1,
|
||||
.wishlist-head p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wishlist-head p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wishlist-grid {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.wishlist-item {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wishlist-empty {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.wishlist-empty h2,
|
||||
.wishlist-empty p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wishlist-empty p {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { ProductCardComponent } from '../../../../../components/product-card/product-card.component';
|
||||
import { UserExperienceFacade } from '../../../../../facades/platform/user-experience.facade';
|
||||
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||
import { CartService } from '../../../../../services';
|
||||
import { UserNotificationService } from '../../services/user-notification.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-wishlist-page',
|
||||
standalone: true,
|
||||
imports: [RouterLink, ProductCardComponent, TranslatePipe, LangRoutePipe],
|
||||
templateUrl: './wishlist-page.component.html',
|
||||
styleUrls: ['./wishlist-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WishlistPageComponent {
|
||||
private readonly cartService = inject(CartService);
|
||||
private readonly uxFacade = inject(UserExperienceFacade);
|
||||
private readonly notifications = inject(UserNotificationService);
|
||||
|
||||
readonly favorites = this.uxFacade.wishlist;
|
||||
readonly hasItems = computed(() => this.favorites().length > 0);
|
||||
|
||||
addToCart(itemId: number, event: Event): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.cartService.addItem(itemId);
|
||||
this.notifications.show('Added to cart', 'success');
|
||||
}
|
||||
|
||||
removeFromWishlist(itemId: number): void {
|
||||
const target = this.favorites().find(entry => entry.product.itemID === itemId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.uxFacade.toggleWishlist(target.product);
|
||||
this.notifications.show('Removed from wishlist', 'info');
|
||||
}
|
||||
|
||||
clearWishlist(): void {
|
||||
this.uxFacade.clearWishlist();
|
||||
this.notifications.show('Wishlist cleared', 'info');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user