feat: build and wire the Quick View modal
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
The Quick View button (search results page only) emitted quickViewPlaceholder with zero listeners anywhere up the chain - the button did nothing. Built a minimal QuickViewDialogComponent (image, name, price incl. discount, short description, Add to Cart, link to full product page) and wired the event through product-grid -> search-results -> catalog-container, which fetches the product via ProductFacade and opens the dialog. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@
|
|||||||
(selected)="productSelected.emit(product)"
|
(selected)="productSelected.emit(product)"
|
||||||
(addToCart)="onAddToCart(product, $event.event)"
|
(addToCart)="onAddToCart(product, $event.event)"
|
||||||
(preview)="productPreview.emit($event)"
|
(preview)="productPreview.emit($event)"
|
||||||
|
(quickViewPlaceholder)="quickView.emit($event)"
|
||||||
(favoriteToggled)="favoriteToggled.emit(product)"
|
(favoriteToggled)="favoriteToggled.emit(product)"
|
||||||
(compareToggled)="compareToggled.emit(product)"
|
(compareToggled)="compareToggled.emit(product)"
|
||||||
(shareRequested)="shareRequested.emit(product)"
|
(shareRequested)="shareRequested.emit(product)"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export class CatalogProductGridComponent {
|
|||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@Output() productPreview = new EventEmitter<number>();
|
@Output() productPreview = new EventEmitter<number>();
|
||||||
|
@Output() quickView = new EventEmitter<number>();
|
||||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||||
@Output() compareToggled = new EventEmitter<Product>();
|
@Output() compareToggled = new EventEmitter<Product>();
|
||||||
@Output() shareRequested = new EventEmitter<Product>();
|
@Output() shareRequested = new EventEmitter<Product>();
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
(productSelected)="productSelected.emit($event)"
|
(productSelected)="productSelected.emit($event)"
|
||||||
(addToCart)="addToCart.emit($event)"
|
(addToCart)="addToCart.emit($event)"
|
||||||
(productPreview)="productPreview.emit($event)"
|
(productPreview)="productPreview.emit($event)"
|
||||||
|
(quickView)="quickView.emit($event)"
|
||||||
(favoriteToggled)="favoriteToggled.emit($event)"
|
(favoriteToggled)="favoriteToggled.emit($event)"
|
||||||
(compareToggled)="compareToggled.emit($event)"
|
(compareToggled)="compareToggled.emit($event)"
|
||||||
(shareRequested)="shareRequested.emit($event)" />
|
(shareRequested)="shareRequested.emit($event)" />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class CatalogSearchResultsComponent {
|
|||||||
@Output() productSelected = new EventEmitter<Product>();
|
@Output() productSelected = new EventEmitter<Product>();
|
||||||
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
@Output() addToCart = new EventEmitter<{ product: Product; event: Event }>();
|
||||||
@Output() productPreview = new EventEmitter<number>();
|
@Output() productPreview = new EventEmitter<number>();
|
||||||
|
@Output() quickView = new EventEmitter<number>();
|
||||||
@Output() favoriteToggled = new EventEmitter<Product>();
|
@Output() favoriteToggled = new EventEmitter<Product>();
|
||||||
@Output() compareToggled = new EventEmitter<Product>();
|
@Output() compareToggled = new EventEmitter<Product>();
|
||||||
@Output() shareRequested = new EventEmitter<Product>();
|
@Output() shareRequested = new EventEmitter<Product>();
|
||||||
|
|||||||
@@ -229,6 +229,7 @@
|
|||||||
(productSelected)="selectProduct($event)"
|
(productSelected)="selectProduct($event)"
|
||||||
(addToCart)="addToCart($event)"
|
(addToCart)="addToCart($event)"
|
||||||
(productPreview)="previewProduct($event)"
|
(productPreview)="previewProduct($event)"
|
||||||
|
(quickView)="openQuickView($event)"
|
||||||
(favoriteToggled)="onFavoriteToggled($event)"
|
(favoriteToggled)="onFavoriteToggled($event)"
|
||||||
(compareToggled)="onCompareToggled($event)"
|
(compareToggled)="onCompareToggled($event)"
|
||||||
(shareRequested)="onShareRequested($event)" />
|
(shareRequested)="onShareRequested($event)" />
|
||||||
@@ -295,3 +296,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<app-quick-view-dialog
|
||||||
|
[product]="quickViewProduct()"
|
||||||
|
[loading]="quickViewLoading()"
|
||||||
|
(closed)="closeQuickView()"
|
||||||
|
(addToCart)="addQuickViewToCart($event)" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Product } from '../../../../core/products/models/product-domain.model';
|
|||||||
import { ConfigService } from '../../../../core/config/config.service';
|
import { ConfigService } from '../../../../core/config/config.service';
|
||||||
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
import { FeatureConfigService } from '../../../../core/config/feature-config.service';
|
||||||
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
import { CategoryFacade } from '../../../../facades/platform/category.facade';
|
||||||
|
import { ProductFacade } from '../../../../facades/platform/product.facade';
|
||||||
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
import { SearchFacade } from '../../../../facades/platform/search.facade';
|
||||||
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
import { UserExperienceFacade } from '../../../../facades/platform/user-experience.facade';
|
||||||
import { CartService } from '../../../../services';
|
import { CartService } from '../../../../services';
|
||||||
@@ -32,6 +33,7 @@ import { EmptyStateComponent } from '../../../../shared/ui/empty-state/empty-sta
|
|||||||
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
import { SkeletonComponent } from '../../../../shared/ui/skeleton/skeleton.component';
|
||||||
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
import { ButtonComponent } from '../../../../shared/ui/button/button.component';
|
||||||
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
|
import { IconComponent } from '../../../../shared/ui/icon/icon.component';
|
||||||
|
import { QuickViewDialogComponent } from '../../product/components/quick-view-dialog/quick-view-dialog.component';
|
||||||
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
import { CatalogState, createInitialCatalogState } from '../models/catalog-state.model';
|
||||||
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
import { ProductShareService } from '../../user-experience/services/product-share.service';
|
||||||
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
import { UserNotificationService } from '../../user-experience/services/user-notification.service';
|
||||||
@@ -56,7 +58,8 @@ type CatalogLoadingStrategy = 'pagination' | 'loadMore' | 'infiniteScroll';
|
|||||||
EmptyStateComponent,
|
EmptyStateComponent,
|
||||||
SkeletonComponent,
|
SkeletonComponent,
|
||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
IconComponent
|
IconComponent,
|
||||||
|
QuickViewDialogComponent
|
||||||
],
|
],
|
||||||
templateUrl: './catalog-container.component.html',
|
templateUrl: './catalog-container.component.html',
|
||||||
styleUrls: ['./catalog-container.component.scss'],
|
styleUrls: ['./catalog-container.component.scss'],
|
||||||
@@ -68,6 +71,7 @@ export class CatalogContainerComponent {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly configService = inject(ConfigService);
|
private readonly configService = inject(ConfigService);
|
||||||
private readonly categoryFacade = inject(CategoryFacade);
|
private readonly categoryFacade = inject(CategoryFacade);
|
||||||
|
private readonly productFacade = inject(ProductFacade);
|
||||||
private readonly searchFacade = inject(SearchFacade);
|
private readonly searchFacade = inject(SearchFacade);
|
||||||
private readonly featureConfig = inject(FeatureConfigService);
|
private readonly featureConfig = inject(FeatureConfigService);
|
||||||
private readonly cartService = inject(CartService);
|
private readonly cartService = inject(CartService);
|
||||||
@@ -287,6 +291,31 @@ export class CatalogContainerComponent {
|
|||||||
this.prefetchService.prefetchItem(productId);
|
this.prefetchService.prefetchItem(productId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly quickViewProduct = signal<Product | null>(null);
|
||||||
|
readonly quickViewLoading = signal(false);
|
||||||
|
|
||||||
|
openQuickView(productId: number): void {
|
||||||
|
this.quickViewProduct.set(null);
|
||||||
|
this.quickViewLoading.set(true);
|
||||||
|
this.productFacade.getProduct(productId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({
|
||||||
|
next: product => {
|
||||||
|
this.quickViewProduct.set(product);
|
||||||
|
this.quickViewLoading.set(false);
|
||||||
|
},
|
||||||
|
error: () => this.quickViewLoading.set(false)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeQuickView(): void {
|
||||||
|
this.quickViewProduct.set(null);
|
||||||
|
this.quickViewLoading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
addQuickViewToCart(product: Product): void {
|
||||||
|
this.cartService.addItem(product.itemID);
|
||||||
|
this.closeQuickView();
|
||||||
|
}
|
||||||
|
|
||||||
retry(): void {
|
retry(): void {
|
||||||
this.enterCategory(this.state().category?.id ?? null);
|
this.enterCategory(this.state().category?.id ?? null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<app-dialog
|
||||||
|
[open]="!!product || loading"
|
||||||
|
[titleText]="'catalog.quickView' | translate"
|
||||||
|
size="md"
|
||||||
|
(closed)="closed.emit()"
|
||||||
|
>
|
||||||
|
@if (loading) {
|
||||||
|
<div class="quick-view__loading" role="status" aria-live="polite">{{ 'common.loading' | translate }}</div>
|
||||||
|
} @else if (product) {
|
||||||
|
<div class="quick-view">
|
||||||
|
<img class="quick-view__image" [src]="mainImage" [alt]="product.name" />
|
||||||
|
<div class="quick-view__body">
|
||||||
|
<h3 class="quick-view__title">{{ product.name }}</h3>
|
||||||
|
<p class="quick-view__price">
|
||||||
|
@if (hasDiscount) {
|
||||||
|
<span class="quick-view__price-original">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
<span class="quick-view__price-final">{{ discountedPrice | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
} @else {
|
||||||
|
<span class="quick-view__price-final">{{ product.price | number:'1.2-2' }} {{ product.currency }}</span>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
@if (product.simpleDescription) {
|
||||||
|
<p class="quick-view__description">{{ product.simpleDescription }}</p>
|
||||||
|
}
|
||||||
|
<div class="quick-view__actions">
|
||||||
|
<app-button variant="primary" (click)="addToCart.emit(product)">{{ 'carousel.addToCart' | translate }}</app-button>
|
||||||
|
<a class="quick-view__link" [routerLink]="('/product/' + product.itemID) | langRoute" (click)="closed.emit()">
|
||||||
|
{{ 'catalog.quickViewDetails' | translate }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</app-dialog>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
.quick-view {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
background: var(--bg-secondary, #f3f3f3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price-original {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__price-final {
|
||||||
|
font-weight: var(--font-weight-bold, 700);
|
||||||
|
font-size: var(--font-size-lg, 1.125rem);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__description {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__link {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: var(--font-size-sm, 0.875rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-view__loading {
|
||||||
|
padding: 40px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||||
|
import { DecimalPipe } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { Product } from '../../../../../core/products/models/product-domain.model';
|
||||||
|
import { DialogComponent } from '../../../../../shared/ui/dialog/dialog.component';
|
||||||
|
import { ButtonComponent } from '../../../../../shared/ui/button/button.component';
|
||||||
|
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
|
||||||
|
import { LangRoutePipe } from '../../../../../pipes/lang-route.pipe';
|
||||||
|
import { getDiscountedPrice, getMainImage } from '../../../../../utils/item.utils';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-quick-view-dialog',
|
||||||
|
standalone: true,
|
||||||
|
imports: [DialogComponent, ButtonComponent, TranslatePipe, LangRoutePipe, DecimalPipe, RouterLink],
|
||||||
|
templateUrl: './quick-view-dialog.component.html',
|
||||||
|
styleUrl: './quick-view-dialog.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush
|
||||||
|
})
|
||||||
|
export class QuickViewDialogComponent {
|
||||||
|
@Input() product: Product | null = null;
|
||||||
|
@Input() loading = false;
|
||||||
|
|
||||||
|
@Output() closed = new EventEmitter<void>();
|
||||||
|
@Output() addToCart = new EventEmitter<Product>();
|
||||||
|
|
||||||
|
get mainImage(): string {
|
||||||
|
return this.product ? getMainImage(this.product) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get discountedPrice(): number {
|
||||||
|
return this.product ? getDiscountedPrice(this.product) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasDiscount(): boolean {
|
||||||
|
return !!this.product?.discount && this.product.discount > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -273,6 +273,7 @@ export const en: Translations = {
|
|||||||
compare: 'Compare',
|
compare: 'Compare',
|
||||||
share: 'Share',
|
share: 'Share',
|
||||||
quickView: 'Quick view',
|
quickView: 'Quick view',
|
||||||
|
quickViewDetails: 'View full details',
|
||||||
stockHigh: 'In stock',
|
stockHigh: 'In stock',
|
||||||
stockMedium: 'Limited stock',
|
stockMedium: 'Limited stock',
|
||||||
stockLow: 'Almost gone',
|
stockLow: 'Almost gone',
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export const hy: Translations = {
|
|||||||
compare: 'Համեմատել',
|
compare: 'Համեմատել',
|
||||||
share: 'Կիսվել',
|
share: 'Կիսվել',
|
||||||
quickView: 'Արագ դիտում',
|
quickView: 'Արագ դիտում',
|
||||||
|
quickViewDetails: 'Տեսնել ամբողջությամբ',
|
||||||
stockHigh: 'Առկա է',
|
stockHigh: 'Առկա է',
|
||||||
stockMedium: 'Սահմանափակ քանակ',
|
stockMedium: 'Սահմանափակ քանակ',
|
||||||
stockLow: 'Գրեթե սպառված է',
|
stockLow: 'Գրեթե սպառված է',
|
||||||
|
|||||||
@@ -273,6 +273,7 @@ export const ru: Translations = {
|
|||||||
compare: 'Сравнить',
|
compare: 'Сравнить',
|
||||||
share: 'Поделиться',
|
share: 'Поделиться',
|
||||||
quickView: 'Быстрый просмотр',
|
quickView: 'Быстрый просмотр',
|
||||||
|
quickViewDetails: 'Смотреть полностью',
|
||||||
stockHigh: 'В наличии',
|
stockHigh: 'В наличии',
|
||||||
stockMedium: 'Ограниченно',
|
stockMedium: 'Ограниченно',
|
||||||
stockLow: 'Почти распродано',
|
stockLow: 'Почти распродано',
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ export interface Translations {
|
|||||||
compare: string;
|
compare: string;
|
||||||
share: string;
|
share: string;
|
||||||
quickView: string;
|
quickView: string;
|
||||||
|
quickViewDetails: string;
|
||||||
stockHigh: string;
|
stockHigh: string;
|
||||||
stockMedium: string;
|
stockMedium: string;
|
||||||
stockLow: string;
|
stockLow: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user