302 lines
8.5 KiB
TypeScript
302 lines
8.5 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import {
|
|
Component,
|
|
DestroyRef,
|
|
Injector,
|
|
computed,
|
|
inject,
|
|
input,
|
|
signal,
|
|
} from '@angular/core';
|
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
|
import { Router, RouterLink } from '@angular/router';
|
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
|
import {
|
|
catchError,
|
|
combineLatest,
|
|
finalize,
|
|
forkJoin,
|
|
of,
|
|
switchMap,
|
|
tap,
|
|
} from 'rxjs';
|
|
import { SeoService } from '../../core/services/seo.service';
|
|
import { LanguageService } from '../../core/services/language.service';
|
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
|
import { ProductCardComponent } from './components/product-card/product-card.component';
|
|
import {
|
|
ShopCategoryDetail,
|
|
ShopCategoryNavNode,
|
|
ShopCategoryTree,
|
|
ShopCartItem,
|
|
ShopProductSummary,
|
|
ShopService,
|
|
} from './services/shop.service';
|
|
import { ShopRouteService } from './services/shop-route.service';
|
|
|
|
@Component({
|
|
selector: 'app-shop-page',
|
|
standalone: true,
|
|
imports: [
|
|
CommonModule,
|
|
TranslateModule,
|
|
RouterLink,
|
|
AppButtonComponent,
|
|
AppCardComponent,
|
|
ProductCardComponent,
|
|
],
|
|
templateUrl: './shop-page.component.html',
|
|
styleUrl: './shop-page.component.scss',
|
|
})
|
|
export class ShopPageComponent {
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly injector = inject(Injector);
|
|
private readonly router = inject(Router);
|
|
private readonly translate = inject(TranslateService);
|
|
private readonly seoService = inject(SeoService);
|
|
private readonly languageService = inject(LanguageService);
|
|
private readonly shopRouteService = inject(ShopRouteService);
|
|
readonly shopService = inject(ShopService);
|
|
|
|
readonly categorySlug = input<string | undefined>();
|
|
|
|
readonly loading = signal(true);
|
|
readonly error = signal<string | null>(null);
|
|
readonly categories = signal<ShopCategoryTree[]>([]);
|
|
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
|
readonly selectedCategory = signal<ShopCategoryDetail | null>(null);
|
|
readonly products = signal<ShopProductSummary[]>([]);
|
|
|
|
readonly cartMutating = signal(false);
|
|
readonly busyLineItemId = signal<string | null>(null);
|
|
|
|
readonly cart = this.shopService.cart;
|
|
readonly cartLoading = this.shopService.cartLoading;
|
|
readonly cartItemCount = this.shopService.cartItemCount;
|
|
readonly currentCategorySlug = computed(
|
|
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
|
|
);
|
|
readonly cartItems = computed(() =>
|
|
(this.cart()?.items ?? []).filter(
|
|
(item) => item.lineItemType === 'SHOP_PRODUCT',
|
|
),
|
|
);
|
|
readonly cartHasItems = computed(() => this.cartItems().length > 0);
|
|
|
|
constructor() {
|
|
if (!this.shopService.cartLoaded()) {
|
|
this.shopService
|
|
.loadCart()
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe({
|
|
error: () => {
|
|
this.shopService.cart.set(null);
|
|
},
|
|
});
|
|
}
|
|
|
|
combineLatest([
|
|
toObservable(this.categorySlug, { injector: this.injector }),
|
|
toObservable(this.languageService.currentLang, {
|
|
injector: this.injector,
|
|
}),
|
|
])
|
|
.pipe(
|
|
tap(() => {
|
|
this.loading.set(true);
|
|
this.error.set(null);
|
|
}),
|
|
switchMap(([categorySlug]) =>
|
|
forkJoin({
|
|
categories: this.shopService.getCategories(),
|
|
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
|
}).pipe(
|
|
catchError((error) => {
|
|
this.categories.set([]);
|
|
this.categoryNodes.set([]);
|
|
this.selectedCategory.set(null);
|
|
this.products.set([]);
|
|
this.error.set(
|
|
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
|
);
|
|
this.applyDefaultSeo();
|
|
return of(null);
|
|
}),
|
|
finalize(() => this.loading.set(false)),
|
|
),
|
|
),
|
|
takeUntilDestroyed(this.destroyRef),
|
|
)
|
|
.subscribe((result) => {
|
|
if (!result) {
|
|
return;
|
|
}
|
|
|
|
this.categories.set(result.categories);
|
|
this.categoryNodes.set(
|
|
this.shopService.flattenCategoryTree(
|
|
result.categories,
|
|
result.catalog.category?.slug ?? this.categorySlug() ?? null,
|
|
),
|
|
);
|
|
this.selectedCategory.set(result.catalog.category ?? null);
|
|
this.products.set(result.catalog.products);
|
|
this.applySeo(result.catalog.category ?? null);
|
|
});
|
|
}
|
|
|
|
productCartQuantity(productId: string): number {
|
|
return this.shopService.quantityForProduct(productId);
|
|
}
|
|
|
|
cartItemName(item: ShopCartItem): string {
|
|
return (
|
|
item.displayName || item.shopProductName || item.originalFilename || '-'
|
|
);
|
|
}
|
|
|
|
cartItemVariant(item: ShopCartItem): string | null {
|
|
return item.shopVariantLabel || item.shopVariantColorName || null;
|
|
}
|
|
|
|
cartItemColor(item: ShopCartItem): string | null {
|
|
return item.shopVariantColorName || item.colorCode || null;
|
|
}
|
|
|
|
cartItemColorHex(item: ShopCartItem): string {
|
|
return item.shopVariantColorHex || '#c9ced6';
|
|
}
|
|
|
|
navigateToCategory(slug?: string | null): void {
|
|
this.router.navigate(this.shopRouteService.shopRootCommands(slug));
|
|
}
|
|
|
|
increaseQuantity(item: ShopCartItem): void {
|
|
this.updateItemQuantity(item, (item.quantity ?? 0) + 1);
|
|
}
|
|
|
|
decreaseQuantity(item: ShopCartItem): void {
|
|
const nextQuantity = Math.max(1, (item.quantity ?? 1) - 1);
|
|
this.updateItemQuantity(item, nextQuantity);
|
|
}
|
|
|
|
removeItem(item: ShopCartItem): void {
|
|
this.cartMutating.set(true);
|
|
this.busyLineItemId.set(item.id);
|
|
this.shopService
|
|
.removeCartItem(item.id)
|
|
.pipe(
|
|
finalize(() => {
|
|
this.cartMutating.set(false);
|
|
this.busyLineItemId.set(null);
|
|
}),
|
|
takeUntilDestroyed(this.destroyRef),
|
|
)
|
|
.subscribe({
|
|
error: () => {
|
|
this.error.set('SHOP.CART_UPDATE_ERROR');
|
|
},
|
|
});
|
|
}
|
|
|
|
clearCart(): void {
|
|
this.cartMutating.set(true);
|
|
this.busyLineItemId.set(null);
|
|
this.shopService
|
|
.clearCart()
|
|
.pipe(
|
|
finalize(() => {
|
|
this.cartMutating.set(false);
|
|
}),
|
|
takeUntilDestroyed(this.destroyRef),
|
|
)
|
|
.subscribe({
|
|
error: () => {
|
|
this.error.set('SHOP.CART_UPDATE_ERROR');
|
|
},
|
|
});
|
|
}
|
|
|
|
goToCheckout(): void {
|
|
const sessionId = this.shopService.cartSessionId();
|
|
if (!sessionId) {
|
|
return;
|
|
}
|
|
this.router.navigate(['/checkout'], {
|
|
queryParams: {
|
|
session: sessionId,
|
|
},
|
|
});
|
|
}
|
|
|
|
trackByCategory(_index: number, item: ShopCategoryNavNode): string {
|
|
return item.id;
|
|
}
|
|
|
|
trackByProduct(_index: number, product: ShopProductSummary): string {
|
|
return product.id;
|
|
}
|
|
|
|
trackByCartItem(_index: number, item: ShopCartItem): string {
|
|
return item.id;
|
|
}
|
|
|
|
private updateItemQuantity(item: ShopCartItem, quantity: number): void {
|
|
this.cartMutating.set(true);
|
|
this.busyLineItemId.set(item.id);
|
|
this.shopService
|
|
.updateCartItem(item.id, quantity)
|
|
.pipe(
|
|
finalize(() => {
|
|
this.cartMutating.set(false);
|
|
this.busyLineItemId.set(null);
|
|
}),
|
|
takeUntilDestroyed(this.destroyRef),
|
|
)
|
|
.subscribe({
|
|
error: () => {
|
|
this.error.set('SHOP.CART_UPDATE_ERROR');
|
|
},
|
|
});
|
|
}
|
|
|
|
private applySeo(category: ShopCategoryDetail | null): void {
|
|
if (!category) {
|
|
this.applyDefaultSeo();
|
|
return;
|
|
}
|
|
|
|
const title =
|
|
category.seoTitle ||
|
|
`${category.name} | ${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
|
const description =
|
|
category.seoDescription ||
|
|
category.description ||
|
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
|
const robots =
|
|
category.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
|
|
|
this.seoService.applyPageSeo({
|
|
title,
|
|
description,
|
|
robots,
|
|
ogTitle: category.ogTitle || title,
|
|
ogDescription: category.ogDescription || description,
|
|
});
|
|
}
|
|
|
|
private applyDefaultSeo(): void {
|
|
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
|
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
|
|
|
this.seoService.applyPageSeo({
|
|
title,
|
|
description,
|
|
robots: 'index, follow',
|
|
ogTitle: title,
|
|
ogDescription: description,
|
|
});
|
|
}
|
|
}
|