From bf593445bd5bc736b62da30b1cbb579dd6af2c8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 23 Mar 2026 18:07:07 +0100 Subject: [PATCH] fix(front-end): fix no index product --- .../controller/PublicShopController.java | 6 ++ .../shop/PublicShopCatalogService.java | 72 ++++++++++++++++--- .../shop/PublicShopCatalogServiceTest.java | 33 +++++++++ .../features/shop/product-detail.component.ts | 54 +++++++++----- .../shop/services/shop.service.spec.ts | 71 +++--------------- .../features/shop/services/shop.service.ts | 22 ++---- .../app/features/shop/shop-page.component.ts | 41 ++++++++--- 7 files changed, 185 insertions(+), 114 deletions(-) diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java index e4680f0..1043025 100644 --- a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -56,6 +56,12 @@ public class PublicShopController { return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); } + @GetMapping("/products/by-path/{publicPath}") + public ResponseEntity getProductByPublicPath(@PathVariable String publicPath, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang)); + } + @GetMapping("/products/{slug}/model") public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index 16687fe..1ecd99e 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -126,24 +126,40 @@ public class PublicShopCatalogService { } public ShopProductDetailDto getProduct(String slug, String language) { - CategoryContext categoryContext = loadCategoryContext(language); - PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + String normalizedLanguage = normalizeLanguage(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); - if (entry == null) { + public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) { + String normalizedLanguage = normalizeLanguage(language); + String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment); + if (normalizedPublicPath == 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"); - } + CategoryContext categoryContext = loadCategoryContext(normalizedLanguage); + PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage); + ProductEntry entry = requirePublicProductEntry( + productContext.entriesByPublicPath().get(normalizedPublicPath), + categoryContext + ); return toProductDetailDto( entry, productContext.productMediaBySlug(), productContext.variantColorHexByMaterialAndColor(), - language + normalizedLanguage ); } @@ -197,6 +213,7 @@ public class PublicShopCatalogService { } private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { + String normalizedLanguage = normalizeLanguage(language); List entries = loadPublicProducts(categoryContext.categoriesById().keySet()); Map> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( SHOP_PRODUCT_MEDIA_USAGE_TYPE, @@ -207,8 +224,21 @@ public class PublicShopCatalogService { Map entriesBySlug = entries.stream() .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); + Map 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 buildFilamentVariantColorHexMap() { @@ -515,6 +545,27 @@ public class PublicShopCatalogService { 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) { String raw = String.valueOf(value == null ? "" : value).trim(); if (raw.isEmpty()) { @@ -610,6 +661,7 @@ public class PublicShopCatalogService { private record PublicProductContext( List entries, Map entriesBySlug, + Map entriesByPublicPath, Map> productMediaBySlug, Map variantColorHexByMaterialAndColor ) { diff --git a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java index 22e883d..9c182d1 100644 --- a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.util.List; @@ -23,6 +24,7 @@ import java.util.Map; import java.util.UUID; 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.anyString; import static org.mockito.Mockito.when; @@ -91,6 +93,37 @@ class PublicShopCatalogServiceTest { 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) { when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category)); when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product)); diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 2d8423e..5345066 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -8,24 +8,24 @@ import { PLATFORM_ID, computed, inject, - input, signal, } from '@angular/core'; 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 { - EMPTY, catchError, combineLatest, + distinctUntilChanged, finalize, + map, of, switchMap, tap, } from 'rxjs'; import { SeoService } from '../../core/services/seo.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 { AppCardComponent } from '../../shared/components/app-card/app-card.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 injector = inject(Injector); private readonly location = inject(Location); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); @@ -78,8 +79,9 @@ export class ProductDetailComponent { private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); readonly shopService = inject(ShopService); - readonly categorySlug = input(); - readonly productSlug = input(); + readonly routeCategorySlug = signal( + this.readRouteParam('categorySlug'), + ); readonly loading = signal(true); readonly error = signal(null); @@ -213,10 +215,20 @@ export class ProductDetailComponent { }); 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, { injector: this.injector, - }), + }).pipe(distinctUntilChanged()), ]) .pipe( tap(() => { @@ -227,13 +239,9 @@ export class ProductDetailComponent { this.colorPopupOpen.set(false); this.modelModalOpen.set(false); }), - switchMap(([productSlug]) => { - if (productSlug === undefined) { - return EMPTY; - } - - const normalizedProductSlug = productSlug.trim(); - if (!normalizedProductSlug) { + switchMap(([routeParams]) => { + this.routeCategorySlug.set(routeParams.categorySlug); + if (!routeParams.productSlug) { this.languageService.clearLocalizedRouteOverrides(); this.error.set('SHOP.NOT_FOUND'); this.setResponseStatus(404); @@ -243,7 +251,7 @@ export class ProductDetailComponent { } return this.shopService - .getProductByPublicPath(normalizedProductSlug) + .getProductByPublicPath(routeParams.productSlug) .pipe( catchError((error) => { this.languageService.clearLocalizedRouteOverrides(); @@ -508,7 +516,8 @@ export class ProductDetailComponent { } productLinkRoot(): string[] { - const categorySlug = this.product()?.category.slug || this.categorySlug(); + const categorySlug = + this.product()?.category.slug || this.routeCategorySlug(); return this.shopRouteService.shopRootCommands(categorySlug); } @@ -834,4 +843,15 @@ export class ProductDetailComponent { 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; + } } diff --git a/frontend/src/app/features/shop/services/shop.service.spec.ts b/frontend/src/app/features/shop/services/shop.service.spec.ts index 58a6988..c85a0a5 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -5,7 +5,6 @@ import { } from '@angular/common/http/testing'; import { ShopCartResponse, - ShopProductCatalogResponse, ShopProductDetail, ShopService, } from './shop.service'; @@ -90,39 +89,6 @@ describe('ShopService', () => { 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 => ({ id: '12345678-abcd-4abc-9abc-1234567890ab', slug: 'desk-cable-clip', @@ -226,24 +192,15 @@ describe('ShopService', () => { response = product; }); - const catalogRequest = 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) => { + const request = httpMock.expectOne((request) => { return ( request.method === 'GET' && 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' ); }); - detailRequest.flush(buildProduct()); + request.flush(buildProduct()); expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); expect(response?.name).toBe('Supporto cavo scrivania'); @@ -259,18 +216,15 @@ describe('ShopService', () => { }, }); - const catalogRequest = httpMock.expectOne((request) => { + const request = httpMock.expectOne((request) => { return ( 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' ); }); - catalogRequest.flush(buildCatalog()); - - httpMock.expectNone( - 'http://localhost:8000/api/shop/products/desk-cable-clip', - ); + request.flush('Not found', { status: 404, statusText: 'Not Found' }); expect(errorResponse?.status).toBe(404); }); @@ -284,18 +238,15 @@ describe('ShopService', () => { }, }); - const catalogRequest = httpMock.expectOne((request) => { + const request = httpMock.expectOne((request) => { return ( 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' ); }); - catalogRequest.flush(buildCatalog()); - - httpMock.expectNone( - 'http://localhost:8000/api/shop/products/desk-cable-clip', - ); + request.flush('Not found', { status: 404, statusText: 'Not Found' }); expect(errorResponse?.status).toBe(404); }); }); diff --git a/frontend/src/app/features/shop/services/shop.service.ts b/frontend/src/app/features/shop/services/shop.service.ts index 131d25b..7b5d215 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -1,6 +1,6 @@ import { computed, inject, Injectable, signal } from '@angular/core'; 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 { PublicMediaUsageDto, @@ -290,21 +290,11 @@ export class ShopService { })); } - return this.getProductCatalog().pipe( - map((catalog) => - catalog.products.find( - (product) => - this.normalizePublicPath(product.publicPath) === normalizedPath, - ), - ), - switchMap((product) => { - if (!product) { - return throwError(() => ({ - status: 404, - })); - } - return this.getProduct(product.slug); - }), + return this.http.get( + `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`, + { + params: this.buildLangParams(), + }, ); } diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index b0ce242..13264f3 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -8,17 +8,18 @@ import { Injector, computed, inject, - input, signal, } from '@angular/core'; 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 { catchError, combineLatest, + distinctUntilChanged, finalize, forkJoin, + map, of, switchMap, tap, @@ -59,6 +60,7 @@ import { ShopRouteService } from './services/shop-route.service'; export class ShopPageComponent { private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); @@ -68,7 +70,9 @@ export class ShopPageComponent { private readonly shopRouteService = inject(ShopRouteService); readonly shopService = inject(ShopService); - readonly categorySlug = input(); + readonly routeCategorySlug = signal( + this.readRouteParam('categorySlug'), + ); readonly loading = signal(true); readonly error = signal(null); @@ -84,7 +88,7 @@ export class ShopPageComponent { readonly cartLoading = this.shopService.cartLoading; readonly cartItemCount = this.shopService.cartItemCount; readonly currentCategorySlug = computed( - () => this.selectedCategory()?.slug ?? this.categorySlug() ?? null, + () => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null, ); readonly cartItems = computed(() => (this.cart()?.items ?? []).filter( @@ -99,18 +103,22 @@ export class ShopPageComponent { }); combineLatest([ - toObservable(this.categorySlug, { injector: this.injector }), + this.route.paramMap.pipe( + map((params) => this.normalizeRouteParam(params.get('categorySlug'))), + distinctUntilChanged(), + ), toObservable(this.languageService.currentLang, { injector: this.injector, - }), + }).pipe(distinctUntilChanged()), ]) .pipe( tap(() => { this.loading.set(true); this.error.set(null); }), - switchMap(([categorySlug]) => - forkJoin({ + switchMap(([categorySlug]) => { + this.routeCategorySlug.set(categorySlug); + return forkJoin({ categories: this.shopService.getCategories(), catalog: this.shopService.getProductCatalog(categorySlug ?? null), }).pipe( @@ -128,8 +136,8 @@ export class ShopPageComponent { return of(null); }), finalize(() => this.loading.set(false)), - ), - ), + ); + }), takeUntilDestroyed(this.destroyRef), ) .subscribe((result) => { @@ -141,7 +149,7 @@ export class ShopPageComponent { this.categoryNodes.set( this.shopService.flattenCategoryTree( result.categories, - result.catalog.category?.slug ?? this.categorySlug() ?? null, + result.catalog.category?.slug ?? this.routeCategorySlug() ?? null, ), ); this.selectedCategory.set(result.catalog.category ?? null); @@ -410,4 +418,15 @@ export class ShopPageComponent { 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; + } }