dev #54

Merged
JoeKung merged 7 commits from dev into main 2026-03-24 13:29:50 +01:00
7 changed files with 185 additions and 114 deletions
Showing only changes of commit bf593445bd - Show all commits

View File

@@ -56,6 +56,12 @@ public class PublicShopController {
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
} }
@GetMapping("/products/by-path/{publicPath}")
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
@RequestParam(required = false) String lang) {
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
}
@GetMapping("/products/{slug}/model") @GetMapping("/products/{slug}/model")
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException { public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);

View File

@@ -126,24 +126,40 @@ public class PublicShopCatalogService {
} }
public ShopProductDetailDto getProduct(String slug, String language) { public ShopProductDetailDto getProduct(String slug, String language) {
CategoryContext categoryContext = loadCategoryContext(language); String normalizedLanguage = normalizeLanguage(language);
PublicProductContext productContext = loadPublicProductContext(categoryContext, language); CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
ProductEntry entry = requirePublicProductEntry(
productContext.entriesBySlug().get(slug),
categoryContext
);
return toProductDetailDto(
entry,
productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(),
normalizedLanguage
);
}
ProductEntry entry = productContext.entriesBySlug().get(slug); public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
if (entry == null) { String normalizedLanguage = normalizeLanguage(language);
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
if (normalizedPublicPath == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
} }
ShopCategory category = entry.product().getCategory(); CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); ProductEntry entry = requirePublicProductEntry(
} productContext.entriesByPublicPath().get(normalizedPublicPath),
categoryContext
);
return toProductDetailDto( return toProductDetailDto(
entry, entry,
productContext.productMediaBySlug(), productContext.productMediaBySlug(),
productContext.variantColorHexByMaterialAndColor(), productContext.variantColorHexByMaterialAndColor(),
language normalizedLanguage
); );
} }
@@ -197,6 +213,7 @@ public class PublicShopCatalogService {
} }
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
String normalizedLanguage = normalizeLanguage(language);
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet()); List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
SHOP_PRODUCT_MEDIA_USAGE_TYPE, SHOP_PRODUCT_MEDIA_USAGE_TYPE,
@@ -207,8 +224,21 @@ public class PublicShopCatalogService {
Map<String, ProductEntry> entriesBySlug = entries.stream() Map<String, ProductEntry> entriesBySlug = entries.stream()
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
.collect(Collectors.toMap(
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
entry -> entry,
(left, right) -> left,
LinkedHashMap::new
));
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor); return new PublicProductContext(
entries,
entriesBySlug,
entriesByPublicPath,
productMediaBySlug,
variantColorHexByMaterialAndColor
);
} }
private Map<String, String> buildFilamentVariantColorHexMap() { private Map<String, String> buildFilamentVariantColorHexMap() {
@@ -515,6 +545,27 @@ public class PublicShopCatalogService {
return raw.toLowerCase(Locale.ROOT); return raw.toLowerCase(Locale.ROOT);
} }
private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
if (entry == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
ShopCategory category = entry.product().getCategory();
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
return entry;
}
private String normalizePublicPathSegment(String publicPathSegment) {
String normalized = trimToNull(publicPathSegment);
if (normalized == null) {
return null;
}
return normalized.toLowerCase(Locale.ROOT);
}
private String trimToNull(String value) { private String trimToNull(String value) {
String raw = String.valueOf(value == null ? "" : value).trim(); String raw = String.valueOf(value == null ? "" : value).trim();
if (raw.isEmpty()) { if (raw.isEmpty()) {
@@ -610,6 +661,7 @@ public class PublicShopCatalogService {
private record PublicProductContext( private record PublicProductContext(
List<ProductEntry> entries, List<ProductEntry> entries,
Map<String, ProductEntry> entriesBySlug, Map<String, ProductEntry> entriesBySlug,
Map<String, ProductEntry> entriesByPublicPath,
Map<String, List<PublicMediaUsageDto>> productMediaBySlug, Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
Map<String, String> variantColorHexByMaterialAndColor Map<String, String> variantColorHexByMaterialAndColor
) { ) {

View File

@@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
@@ -23,6 +24,7 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -91,6 +93,37 @@ class PublicShopCatalogServiceTest {
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it")); assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
} }
@Test
void getProductByPublicPath_shouldResolveLocalizedSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
assertEquals("bike-wall-hanger", response.slug());
assertEquals("12345678-bike-wall-hanger", response.publicPath());
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
}
@Test
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
ShopCategory category = buildCategory();
ShopProduct product = buildProduct(category);
ShopProductVariant variant = buildVariant(product);
stubPublicCatalog(category, product, variant);
ResponseStatusException exception = assertThrows(
ResponseStatusException.class,
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
);
assertEquals(404, exception.getStatusCode().value());
}
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) { private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category)); when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product)); when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));

View File

@@ -8,24 +8,24 @@ import {
PLATFORM_ID, PLATFORM_ID,
computed, computed,
inject, inject,
input,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { import {
EMPTY,
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged,
finalize, finalize,
map,
of, of,
switchMap, switchMap,
tap, tap,
} from 'rxjs'; } from 'rxjs';
import { SeoService } from '../../core/services/seo.service'; import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { findColorHex, getColorHex } from '../../core/constants/colors.const'; import { findColorHex } from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -69,6 +69,7 @@ export class ProductDetailComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly location = inject(Location); private readonly location = inject(Location);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
@@ -78,8 +79,9 @@ export class ProductDetailComponent {
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly routeCategorySlug = signal<string | null>(
readonly productSlug = input<string | undefined>(); this.readRouteParam('categorySlug'),
);
readonly loading = signal(true); readonly loading = signal(true);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
@@ -213,10 +215,20 @@ export class ProductDetailComponent {
}); });
combineLatest([ combineLatest([
toObservable(this.productSlug, { injector: this.injector }), this.route.paramMap.pipe(
map((params) => ({
categorySlug: this.normalizeRouteParam(params.get('categorySlug')),
productSlug: this.normalizeRouteParam(params.get('productSlug')),
})),
distinctUntilChanged(
(previous, current) =>
previous.categorySlug === current.categorySlug &&
previous.productSlug === current.productSlug,
),
),
toObservable(this.languageService.currentLang, { toObservable(this.languageService.currentLang, {
injector: this.injector, injector: this.injector,
}), }).pipe(distinctUntilChanged()),
]) ])
.pipe( .pipe(
tap(() => { tap(() => {
@@ -227,13 +239,9 @@ export class ProductDetailComponent {
this.colorPopupOpen.set(false); this.colorPopupOpen.set(false);
this.modelModalOpen.set(false); this.modelModalOpen.set(false);
}), }),
switchMap(([productSlug]) => { switchMap(([routeParams]) => {
if (productSlug === undefined) { this.routeCategorySlug.set(routeParams.categorySlug);
return EMPTY; if (!routeParams.productSlug) {
}
const normalizedProductSlug = productSlug.trim();
if (!normalizedProductSlug) {
this.languageService.clearLocalizedRouteOverrides(); this.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND'); this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404); this.setResponseStatus(404);
@@ -243,7 +251,7 @@ export class ProductDetailComponent {
} }
return this.shopService return this.shopService
.getProductByPublicPath(normalizedProductSlug) .getProductByPublicPath(routeParams.productSlug)
.pipe( .pipe(
catchError((error) => { catchError((error) => {
this.languageService.clearLocalizedRouteOverrides(); this.languageService.clearLocalizedRouteOverrides();
@@ -508,7 +516,8 @@ export class ProductDetailComponent {
} }
productLinkRoot(): string[] { productLinkRoot(): string[] {
const categorySlug = this.product()?.category.slug || this.categorySlug(); const categorySlug =
this.product()?.category.slug || this.routeCategorySlug();
return this.shopRouteService.shopRootCommands(categorySlug); return this.shopRouteService.shopRootCommands(categorySlug);
} }
@@ -834,4 +843,15 @@ export class ProductDetailComponent {
this.responseInit.status = status; this.responseInit.status = status;
} }
} }
private readRouteParam(name: string): string | null {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}
} }

View File

@@ -5,7 +5,6 @@ import {
} from '@angular/common/http/testing'; } from '@angular/common/http/testing';
import { import {
ShopCartResponse, ShopCartResponse,
ShopProductCatalogResponse,
ShopProductDetail, ShopProductDetail,
ShopService, ShopService,
} from './shop.service'; } from './shop.service';
@@ -90,39 +89,6 @@ describe('ShopService', () => {
grandTotalChf: 36.8, grandTotalChf: 36.8,
}); });
const buildCatalog = (): ShopProductCatalogResponse => ({
categorySlug: null,
featuredOnly: false,
category: null,
products: [
{
id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip',
name: 'Supporto cavo scrivania',
excerpt: 'Accessorio tecnico',
isFeatured: true,
sortOrder: 0,
category: {
id: 'category-1',
slug: 'accessori',
name: 'Accessori',
},
priceFromChf: 9.9,
priceToChf: 12.5,
defaultVariant: null,
primaryImage: null,
model3d: null,
publicPath: '12345678-supporto-cavo-scrivania',
localizedPaths: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
fr: '/fr/shop/p/12345678-support-cable-bureau',
},
},
],
});
const buildProduct = (): ShopProductDetail => ({ const buildProduct = (): ShopProductDetail => ({
id: '12345678-abcd-4abc-9abc-1234567890ab', id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip', slug: 'desk-cable-clip',
@@ -226,24 +192,15 @@ describe('ShopService', () => {
response = product; response = product;
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = httpMock.expectOne((request) => {
return (
request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' &&
request.params.get('lang') === 'it'
);
});
catalogRequest.flush(buildCatalog());
const detailRequest = httpMock.expectOne((request) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === request.url ===
'http://localhost:8000/api/shop/products/desk-cable-clip' && 'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
request.params.get('lang') === 'it' request.params.get('lang') === 'it'
); );
}); });
detailRequest.flush(buildProduct()); request.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
expect(response?.name).toBe('Supporto cavo scrivania'); expect(response?.name).toBe('Supporto cavo scrivania');
@@ -259,18 +216,15 @@ describe('ShopService', () => {
}, },
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = httpMock.expectOne((request) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' && request.url ===
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
request.params.get('lang') === 'it' request.params.get('lang') === 'it'
); );
}); });
catalogRequest.flush(buildCatalog()); request.flush('Not found', { status: 404, statusText: 'Not Found' });
httpMock.expectNone(
'http://localhost:8000/api/shop/products/desk-cable-clip',
);
expect(errorResponse?.status).toBe(404); expect(errorResponse?.status).toBe(404);
}); });
@@ -284,18 +238,15 @@ describe('ShopService', () => {
}, },
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = httpMock.expectOne((request) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' && request.url ===
'http://localhost:8000/api/shop/products/by-path/12345678' &&
request.params.get('lang') === 'it' request.params.get('lang') === 'it'
); );
}); });
catalogRequest.flush(buildCatalog()); request.flush('Not found', { status: 404, statusText: 'Not Found' });
httpMock.expectNone(
'http://localhost:8000/api/shop/products/desk-cable-clip',
);
expect(errorResponse?.status).toBe(404); expect(errorResponse?.status).toBe(404);
}); });
}); });

View File

@@ -1,6 +1,6 @@
import { computed, inject, Injectable, signal } from '@angular/core'; import { computed, inject, Injectable, signal } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http'; import { HttpClient, HttpParams } from '@angular/common/http';
import { map, Observable, switchMap, tap, throwError } from 'rxjs'; import { map, Observable, tap, throwError } from 'rxjs';
import { environment } from '../../../../environments/environment'; import { environment } from '../../../../environments/environment';
import { import {
PublicMediaUsageDto, PublicMediaUsageDto,
@@ -290,21 +290,11 @@ export class ShopService {
})); }));
} }
return this.getProductCatalog().pipe( return this.http.get<ShopProductDetail>(
map((catalog) => `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
catalog.products.find( {
(product) => params: this.buildLangParams(),
this.normalizePublicPath(product.publicPath) === normalizedPath, },
),
),
switchMap((product) => {
if (!product) {
return throwError(() => ({
status: 404,
}));
}
return this.getProduct(product.slug);
}),
); );
} }

View File

@@ -8,17 +8,18 @@ import {
Injector, Injector,
computed, computed,
inject, inject,
input,
signal, signal,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
import { Router, RouterLink } from '@angular/router'; import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { import {
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged,
finalize, finalize,
forkJoin, forkJoin,
map,
of, of,
switchMap, switchMap,
tap, tap,
@@ -59,6 +60,7 @@ import { ShopRouteService } from './services/shop-route.service';
export class ShopPageComponent { export class ShopPageComponent {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector); private readonly injector = inject(Injector);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); private readonly router = inject(Router);
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly seoService = inject(SeoService); private readonly seoService = inject(SeoService);
@@ -68,7 +70,9 @@ export class ShopPageComponent {
private readonly shopRouteService = inject(ShopRouteService); private readonly shopRouteService = inject(ShopRouteService);
readonly shopService = inject(ShopService); readonly shopService = inject(ShopService);
readonly categorySlug = input<string | undefined>(); readonly routeCategorySlug = signal<string | null>(
this.readRouteParam('categorySlug'),
);
readonly loading = signal(true); readonly loading = signal(true);
readonly error = signal<string | null>(null); readonly error = signal<string | null>(null);
@@ -84,7 +88,7 @@ export class ShopPageComponent {
readonly cartLoading = this.shopService.cartLoading; readonly cartLoading = this.shopService.cartLoading;
readonly cartItemCount = this.shopService.cartItemCount; readonly cartItemCount = this.shopService.cartItemCount;
readonly currentCategorySlug = computed( readonly currentCategorySlug = computed(
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null, () => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
); );
readonly cartItems = computed(() => readonly cartItems = computed(() =>
(this.cart()?.items ?? []).filter( (this.cart()?.items ?? []).filter(
@@ -99,18 +103,22 @@ export class ShopPageComponent {
}); });
combineLatest([ combineLatest([
toObservable(this.categorySlug, { injector: this.injector }), this.route.paramMap.pipe(
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
distinctUntilChanged(),
),
toObservable(this.languageService.currentLang, { toObservable(this.languageService.currentLang, {
injector: this.injector, injector: this.injector,
}), }).pipe(distinctUntilChanged()),
]) ])
.pipe( .pipe(
tap(() => { tap(() => {
this.loading.set(true); this.loading.set(true);
this.error.set(null); this.error.set(null);
}), }),
switchMap(([categorySlug]) => switchMap(([categorySlug]) => {
forkJoin({ this.routeCategorySlug.set(categorySlug);
return forkJoin({
categories: this.shopService.getCategories(), categories: this.shopService.getCategories(),
catalog: this.shopService.getProductCatalog(categorySlug ?? null), catalog: this.shopService.getProductCatalog(categorySlug ?? null),
}).pipe( }).pipe(
@@ -128,8 +136,8 @@ export class ShopPageComponent {
return of(null); return of(null);
}), }),
finalize(() => this.loading.set(false)), finalize(() => this.loading.set(false)),
), );
), }),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
.subscribe((result) => { .subscribe((result) => {
@@ -141,7 +149,7 @@ export class ShopPageComponent {
this.categoryNodes.set( this.categoryNodes.set(
this.shopService.flattenCategoryTree( this.shopService.flattenCategoryTree(
result.categories, result.categories,
result.catalog.category?.slug ?? this.categorySlug() ?? null, result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
), ),
); );
this.selectedCategory.set(result.catalog.category ?? null); this.selectedCategory.set(result.catalog.category ?? null);
@@ -410,4 +418,15 @@ export class ShopPageComponent {
window.setTimeout(restore, 60); window.setTimeout(restore, 60);
}); });
} }
private readRouteParam(name: string): string | null {
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
}
private normalizeRouteParam(
value: string | null | undefined,
): string | null {
const normalized = String(value ?? '').trim();
return normalized || null;
}
} }