Files
ISA-Frontend/apps/isa-app/src/page/catalog/article-details/article-details.component.ts
Nino Righi b5c8dc4776 Merged PR 1968: #5307 Entscheidungs Dialog
#5307 Entscheidungs Dialog
2025-10-16 08:56:56 +00:00

670 lines
20 KiB
TypeScript

import {
Component,
ChangeDetectionStrategy,
OnInit,
OnDestroy,
ElementRef,
ViewChild,
inject,
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ApplicationService } from '@core/application';
import { DomainPrinterService } from '@domain/printer';
import { ItemDTO as PrinterItemDTO } from '@generated/swagger/print-api';
import { PrintModalComponent, PrintModalData } from '@modal/printer';
import { BranchDTO } from '@generated/swagger/checkout-api';
import { UiModalService } from '@ui/modal';
import { ModalReviewsComponent } from '@modal/reviews';
import {
BehaviorSubject,
combineLatest,
firstValueFrom,
Subscription,
} from 'rxjs';
import {
debounceTime,
filter,
first,
map,
shareReplay,
switchMap,
tap,
withLatestFrom,
} from 'rxjs/operators';
import { ArticleDetailsStore } from './article-details.store';
import { ModalImagesComponent } from '@modal/images';
import { ProductImageService } from '@cdn/product-image';
import { ModalAvailabilitiesComponent } from '@modal/availabilities';
import { slideYAnimation } from './slide.animation';
import { BreadcrumbService } from '@core/breadcrumb';
import { ItemDTO } from '@generated/swagger/cat-search-api';
import { DateAdapter } from '@ui/common';
import { DatePipe } from '@angular/common';
import { PurchaseOptionsModalService } from '@modal/purchase-options';
import { EnvironmentService } from '@core/environment';
import {
CheckoutNavigationService,
ProductCatalogNavigationService,
CustomerSearchNavigation,
} from '@shared/services/navigation';
import { DomainCheckoutService } from '@domain/checkout';
import { Store } from '@ngrx/store';
import moment, { Moment } from 'moment';
import {
NavigateAfterRewardSelection,
RewardSelectionPopUpService,
} from '@isa/checkout/shared/reward-selection-dialog';
import { injectConfirmationDialog, injectFeedbackDialog } from '@isa/ui/dialog';
@Component({
selector: 'page-article-details',
templateUrl: 'article-details.component.html',
styleUrls: ['article-details.component.scss'],
changeDetection: ChangeDetectionStrategy.Default,
providers: [ArticleDetailsStore, DatePipe],
animations: [slideYAnimation],
standalone: false,
})
export class ArticleDetailsComponent implements OnInit, OnDestroy {
private _rewardSelectionPopUpService = inject(RewardSelectionPopUpService);
private _customerSearchNavigation = inject(CustomerSearchNavigation);
private readonly subscriptions = new Subscription();
showRecommendations: boolean;
imageLoaded$ = new BehaviorSubject<boolean>(false);
fetchingAvailabilities$ = combineLatest([
this.store.fetchingDeliveryAvailability$,
this.store.fetchingDeliveryB2BAvailability$,
this.store.fetchingDeliveryDigAvailability$,
this.store.fetchingDownloadAvailability$,
this.store.fetchingPickUpAvailability$,
this.store.fetchingTakeAwayAvailability$,
]).pipe(map((values) => values.some((v) => v)));
isAvailable$ = combineLatest([
this.store.isDeliveryAvailabilityAvailable$,
this.store.isDeliveryDigAvailabilityAvailable$,
this.store.isDeliveryB2BAvailabilityAvailable$,
this.store.isPickUpAvailabilityAvailable$,
this.store.isTakeAwayAvailabilityAvailable$,
this.store.isDownloadAvailabilityAvailable$,
]).pipe(
map((values) => values.some((v) => v)),
shareReplay(),
);
showDeliveryTruck$ = combineLatest([
this.store.isDeliveryAvailabilityAvailable$,
this.store.isDeliveryDigAvailabilityAvailable$,
]).pipe(map(([delivery, digDelivery]) => delivery || digDelivery));
showDeliveryB2BTruck$ = combineLatest([
this.store.isDeliveryDigAvailabilityAvailable$,
this.store.isDeliveryB2BAvailabilityAvailable$,
]).pipe(map(([digDelivery, b2bDelivery]) => b2bDelivery && !digDelivery));
customerFeatures$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) =>
this._domainCheckoutService.getCustomerFeatures({ processId }),
),
);
showSubscriptionBadge$ = this.store.item$.pipe(
map((item) => item?.features?.find((i) => i.key === 'PFO')),
);
hasPromotionFeature$ = this.store.item$.pipe(
map((item) => !!item?.features?.find((i) => i.key === 'Promotion')),
);
promotionPoints$ = this.store.item$.pipe(
map((item) => item?.redemptionPoints),
);
showPromotionBadge$ = combineLatest([
this.hasPromotionFeature$,
this.promotionPoints$,
]).pipe(
map(
([hasPromotionFeature, promotionPoints]) =>
hasPromotionFeature && promotionPoints > 0,
),
);
showArchivBadge$ = this.store.item$.pipe(
map((item) => item?.features?.find((i) => i.key === 'ARC')),
);
isBadgeVisible$ = combineLatest([
this.showSubscriptionBadge$,
this.showPromotionBadge$,
this.showArchivBadge$,
]).pipe(
map(
([showSubscriptionBadge, showPromotionBadge, showArchivBadge]) =>
showSubscriptionBadge || showPromotionBadge || showArchivBadge,
),
);
contributors$ = this.store.item$.pipe(
map((item) => item?.product?.contributors?.split(';').map((m) => m.trim())),
);
/**
* Observable that emits the formatted publication date of an item.
*
* This observable filters the items to ensure they have a valid publication date,
* then maps the item to the appropriate date format. It considers both the
* `firstDayOfSale` and `publicationDate` fields, choosing the later of the two
* if both are valid. If neither date is valid, it returns an empty string.
*
* The date is formatted as 'DD. MMMM YYYY'.
*
* Cases:
* - If both `firstDayOfSale` and `publicationDate` are valid, the later date is taken.
* - If only `firstDayOfSale` is valid, it is taken.
* - If only `publicationDate` is valid, it is taken.
* - If neither date is valid, an empty string is returned.
*
* @observable
* @returns {Observable<string>} Formatted publication date or an empty string.
*/
publicationDate$ = this.store.item$.pipe(
filter((item) => !!item?.product?.publicationDate),
map((item) => {
const firstDayOfSale = item.catalogAvailability?.firstDayOfSale
? moment(item.catalogAvailability.firstDayOfSale)
: null;
const publicationDate = item.product?.publicationDate
? moment(item.product.publicationDate)
: null;
if (!firstDayOfSale && !publicationDate) {
return '';
}
const validDates = [firstDayOfSale, publicationDate].filter((date) =>
date?.isValid(),
);
const latestDate = validDates.length ? moment.max(validDates) : null;
return latestDate ? latestDate.format('DD. MMMM YYYY') : '';
}),
);
selectedBranchId$ = this.applicationService.activatedProcessId$.pipe(
switchMap((processId) =>
this.applicationService.getSelectedBranch$(processId),
),
);
get isTablet$() {
return this._environment.matchTablet$;
}
get resultsPath() {
return this._navigationService.getArticleSearchResultsPath(
this.applicationService.activatedProcessId,
).path;
}
showMore: boolean = false;
@ViewChild('detailsContainer', { read: ElementRef, static: false })
detailsContainer: ElementRef;
get detailsContainerNative(): HTMLElement {
return this.detailsContainer?.nativeElement;
}
stockTooltipText$ = combineLatest([
this.store.defaultBranch$,
this.selectedBranchId$,
]).pipe(
map(([defaultBranch, selectedBranch]) => {
if (
defaultBranch?.branchType !== 4 &&
selectedBranch &&
defaultBranch.id !== selectedBranch?.id
) {
return 'Sie sehen den Bestand einer anderen Filiale.';
}
return '';
}),
);
priceMaintained$ = combineLatest([
this.store.takeAwayAvailability$,
this.store.pickUpAvailability$,
this.store.deliveryAvailability$,
this.store.deliveryDigAvailability$,
this.store.deliveryB2BAvailability$,
this.store.downloadAvailability$,
]).pipe(
map((availabilities) => {
return (
availabilities?.some(
(availability) => (availability as any)?.priceMaintained,
) ?? false
);
}),
);
price$ = combineLatest([
this.store.item$,
this.store.takeAwayAvailability$,
this.store.pickUpAvailability$,
this.store.deliveryAvailability$,
this.store.deliveryDigAvailability$,
this.store.deliveryB2BAvailability$,
this.store.downloadAvailability$,
]).pipe(
map(
([
item,
takeAway,
pickUp,
delivery,
deliveryDig,
deliveryB2B,
download,
]) => {
const hasPickupOrTakeaway = takeAway?.inStock || pickUp?.inStock;
if (
hasPickupOrTakeaway &&
item?.catalogAvailability?.price?.value?.value
) {
return item?.catalogAvailability?.price;
}
if (takeAway?.price?.value?.value && takeAway?.inStock) {
return takeAway.price;
}
if (delivery?.price?.value?.value) {
return delivery.price;
}
if (deliveryDig?.price?.value?.value) {
return deliveryDig.price;
}
if (deliveryB2B?.price?.value?.value) {
return deliveryB2B.price;
}
if (download?.price?.value?.value) {
return download.price;
}
return null;
},
),
);
constructor(
public readonly applicationService: ApplicationService,
private activatedRoute: ActivatedRoute,
public readonly store: ArticleDetailsStore,
private domainPrinterService: DomainPrinterService,
private uiModal: UiModalService,
private productImageService: ProductImageService,
private breadcrumb: BreadcrumbService,
private _dateAdapter: DateAdapter,
private _datePipe: DatePipe,
private _purchaseOptionsModalService: PurchaseOptionsModalService,
private _navigationService: ProductCatalogNavigationService,
private _checkoutNavigationService: CheckoutNavigationService,
private _environment: EnvironmentService,
private _router: Router,
private _domainCheckoutService: DomainCheckoutService,
private _store: Store,
) {}
ngOnInit() {
const processIdSubscription = this.activatedRoute.parent.params
.pipe(
debounceTime(0),
switchMap((params) =>
this.applicationService
.getSelectedBranch$(Number(params.processId))
.pipe(map((selectedBranch) => ({ params, selectedBranch }))),
),
)
.subscribe(({ params, selectedBranch }) => {
const processId = Number(params.processId);
const processChanged = processId !== this.store.processId;
const branchChanged = selectedBranch?.id !== this.store?.branch?.id;
if (processChanged) {
this.store.setProcessId(processId);
}
if (branchChanged) {
this.store.setBranch(selectedBranch);
}
});
const id$ = this.activatedRoute.params.pipe(
tap((_) => (this.showRecommendations = false)),
map((params) => Number(params?.id) || undefined),
filter((f) => !!f),
);
const ean$ = this.activatedRoute.params.pipe(
tap((_) => (this.showRecommendations = false)),
map((params) => params?.ean || undefined),
filter((f) => !!f),
);
const more$ = this.activatedRoute.params.subscribe(
() => (this.showMore = false),
);
this.subscriptions.add(processIdSubscription);
this.subscriptions.add(more$);
this.subscriptions.add(this.store.loadDefaultBranch());
this.subscriptions.add(this.store.loadItemById(id$));
this.subscriptions.add(this.store.loadItemByEan(ean$));
this.subscriptions.add(
this.store.item$
.pipe(
withLatestFrom(this.isTablet$),
filter(([item, isTablet]) => !!item),
)
.subscribe(([item, isTablet]) =>
isTablet
? this.updateBreadcrumb(item)
: this.updateBreadcrumbDesktop(item),
),
);
}
ngOnDestroy() {
this.subscriptions.unsubscribe();
}
getDetailsPath(ean?: string) {
return this._navigationService.getArticleDetailsPathByEan({
processId: this.applicationService.activatedProcessId,
ean,
}).path;
}
async updateBreadcrumb(item: ItemDTO) {
const crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
'catalog',
'details',
`${item.id}`,
])
.pipe(first())
.toPromise();
for (const crumb of crumbs) {
await this.breadcrumb.removeBreadcrumbsAfter(crumb.id);
}
this.breadcrumb.addBreadcrumbIfNotExists({
key: this.applicationService.activatedProcessId,
name: item.product?.name,
path: this._navigationService.getArticleDetailsPath({
processId: this.applicationService.activatedProcessId,
itemId: item.id,
}).path,
params: this.activatedRoute.snapshot.queryParams,
tags: ['catalog', 'details', `${item.id}`],
section: 'customer',
});
}
async showTooltip() {
const text = await this.stockTooltipText$.pipe(first()).toPromise();
if (!text) {
// Show Tooltip attached to branch selector dropdown
this._store.dispatch({ type: 'OPEN_TOOLTIP_NO_BRANCH_SELECTED' });
}
}
async updateBreadcrumbDesktop(item: ItemDTO) {
const crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
'catalog',
'details',
])
.pipe(first())
.toPromise();
if (crumbs.length === 0) {
this.breadcrumb.addBreadcrumbIfNotExists({
key: this.applicationService.activatedProcessId,
name: item.product?.name,
path: this._navigationService.getArticleDetailsPath({
processId: this.applicationService.activatedProcessId,
itemId: item.id,
}).path,
params: this.activatedRoute.snapshot.queryParams,
tags: ['catalog', 'details', `${item.id}`],
section: 'customer',
});
} else {
const crumb = crumbs.find((_) => true);
this.breadcrumb.patchBreadcrumb(crumb.id, {
key: this.applicationService.activatedProcessId,
name: item.product?.name,
path: this._navigationService.getArticleDetailsPath({
processId: this.applicationService.activatedProcessId,
itemId: item.id,
}).path,
params: this.activatedRoute.snapshot.queryParams,
tags: ['catalog', 'details', `${item.id}`],
section: 'customer',
});
}
}
async print() {
const item = await this.store.item$.pipe(first()).toPromise();
this.uiModal.open({
content: PrintModalComponent,
data: {
printerType: 'Label',
// TODO: remove item: item as PrinterItemDTO when the backend is fixed
print: (printer) =>
this.domainPrinterService
.printProduct({ item: item as PrinterItemDTO, printer })
.toPromise(),
} as PrintModalData,
config: {
panelClass: [],
showScrollbarY: false,
},
});
}
async showReviews() {
const item = await this.store.item$.pipe(first()).toPromise();
this.uiModal.open({
content: ModalReviewsComponent,
title: `${item.reviews?.length} Rezensionen`,
data: item.reviews,
});
}
async showAvailabilities() {
const item = await this.store.item$.pipe(first()).toPromise();
const modal = this.uiModal.open<BranchDTO>({
content: ModalAvailabilitiesComponent,
title: 'Bestände in anderen Filialen',
data: {
item,
},
});
this.subscriptions.add(
modal.afterClosed$.subscribe((result) => {
if (result?.data) {
this.showPurchasingModal(result.data);
}
}),
);
}
async showImages() {
const item = await this.store.item$.pipe(first()).toPromise();
const images = item.images ? [...item.images] : [];
images.unshift({
url: this.productImageService.getImageUrl({
imageId: item.imageId,
width: 400,
height: 655,
}),
thumbUrl: this.productImageService.getImageUrl({
imageId: item.imageId,
width: 43,
height: 69,
}),
});
this.uiModal.open({
content: ModalImagesComponent,
title: item.product.name,
data: {
images,
},
});
}
async showPurchasingModal(selectedBranch?: BranchDTO) {
const processId = this.applicationService.activatedProcessId;
const item = await this.store.item$.pipe(first()).toPromise();
const shoppingCart = await firstValueFrom(
this._domainCheckoutService.getShoppingCart({
processId,
}),
);
const modalRef = await this._purchaseOptionsModalService.open({
type: 'add',
tabId: processId,
shoppingCartId: shoppingCart.id,
items: [item],
pickupBranch: selectedBranch,
inStoreBranch: selectedBranch,
preSelectOption: selectedBranch
? { option: 'in-store', showOptionOnly: true }
: undefined,
});
modalRef.afterClosed$.subscribe(async (result) => {
if (result?.data === 'continue') {
const customer = await this._domainCheckoutService
.getBuyer({ processId })
.pipe(first())
.toPromise();
if (customer) {
await this.#rewardSelectionPopUpFlow(processId);
} else {
await this.navigateToCustomerSearch();
}
} else if (result?.data === 'continue-shopping') {
this.navigateToResultList();
}
});
}
async navigateToShoppingCart() {
await this._checkoutNavigationService
.getCheckoutReviewPath(this.applicationService.activatedProcessId)
.navigate();
}
async navigateToCustomerSearch() {
const nav = this._customerSearchNavigation.defaultRoute({
processId: this.applicationService.activatedProcessId,
});
try {
const response = await this.customerFeatures$
.pipe(
first(),
switchMap((customerFeatures) => {
return this._domainCheckoutService.canSetCustomer({
processId: this.applicationService.activatedProcessId,
customerFeatures,
});
}),
)
.toPromise();
this._router.navigate(nav.path, {
queryParams: { filter_customertype: response.filter.customertype },
});
} catch (error) {
this._router.navigate(nav.path);
}
}
async navigateToResultList() {
const processId = this.applicationService.activatedProcessId;
let crumbs = await this.breadcrumb
.getBreadcrumbsByKeyAndTags$(this.applicationService.activatedProcessId, [
'catalog',
'details',
])
.pipe(first())
.toPromise();
const crumb = crumbs[crumbs.length - 1];
if (crumb) {
await this._navigationService
.getArticleSearchResultsPath(processId, { queryParams: crumb.params })
.navigate();
} else {
await this._navigationService
.getArticleSearchBasePath(processId)
.navigate();
}
}
scrollTop(div: HTMLDivElement) {
this.detailsContainerNative?.scrollTo({ top: 0, behavior: 'smooth' });
}
loadImage() {
this.imageLoaded$.next(true);
}
async #reloadShoppingCart(processId: number) {
await this._domainCheckoutService.reloadShoppingCart({ processId });
}
async #navigateToReward(processId: number) {
await this._router.navigate([`/${processId}`, 'reward', 'cart']);
}
async #rewardSelectionPopUpFlow(tabId: number) {
await this.#reloadShoppingCart(tabId);
const navigate: NavigateAfterRewardSelection =
await this._rewardSelectionPopUpService.popUp();
await this.#reloadShoppingCart(tabId);
switch (navigate) {
case NavigateAfterRewardSelection.CART:
await this.navigateToShoppingCart();
break;
case NavigateAfterRewardSelection.REWARD:
await this.#navigateToReward(tabId);
break;
case NavigateAfterRewardSelection.CATALOG:
await this.navigateToResultList();
break;
default:
break;
}
}
}