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 1/5] 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; + } } From 81f6f78c49b31da88d87cdd32721ba8054a1dab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Mon, 23 Mar 2026 19:11:26 +0100 Subject: [PATCH 2/5] fix(front-end): fix no index product 2 --- .../interceptors/server-origin.interceptor.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index d2d1585..836debb 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -5,6 +5,12 @@ import { resolveRequestOrigin, } from '../../../core/request-origin'; +const FORWARDED_REQUEST_HEADERS = [ + 'authorization', + 'cookie', + 'accept-language', +] as const; + function isAbsoluteUrl(url: string): boolean { return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); } @@ -14,6 +20,20 @@ function normalizeRelativePath(url: string): string { return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; } +function readRequestHeader( + request: RequestLike | null, + name: (typeof FORWARDED_REQUEST_HEADERS)[number], +): string | null { + const normalizedName = name.toLowerCase(); + const headerValue = + request?.headers?.[normalizedName] ?? request?.get?.(normalizedName); + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; +} + export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { if (isAbsoluteUrl(req.url)) { return next(req); @@ -26,5 +46,24 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { } const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; - return next(req.clone({ url: absoluteUrl })); + const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce< + Record + >((headers, name) => { + if (req.headers.has(name)) { + return headers; + } + + const value = readRequestHeader(request, name); + if (value) { + headers[name] = value; + } + return headers; + }, {}); + + return next( + req.clone({ + url: absoluteUrl, + setHeaders: forwardedHeaders, + }), + ); }; From d27558a3ee660dd78323131e7053d0d589923994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 12:59:09 +0100 Subject: [PATCH 3/5] fix(front-end): fix no index product 3 hope the last one --- .../shop/product-detail.component.html | 2 +- .../features/shop/product-detail.component.ts | 82 ++++++++++-- .../features/shop/shop-page.component.html | 24 +--- .../app/features/shop/shop-page.component.ts | 123 ++++++++++++++++-- .../app/features/shop/shop-seo-fallback.ts | 72 ++++++++++ 5 files changed, 260 insertions(+), 43 deletions(-) create mode 100644 frontend/src/app/features/shop/shop-seo-fallback.ts diff --git a/frontend/src/app/features/shop/product-detail.component.html b/frontend/src/app/features/shop/product-detail.component.html index 76badd8..9a5981a 100644 --- a/frontend/src/app/features/shop/product-detail.component.html +++ b/frontend/src/app/features/shop/product-detail.component.html @@ -4,7 +4,7 @@ ← {{ "SHOP.BACK" | translate }} - @if (loading()) { + @if (loading() || softFallbackActive()) {
diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 5345066..47f7257 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -35,6 +35,7 @@ import { ShopService, } from './services/shop.service'; import { ShopRouteService } from './services/shop-route.service'; +import { humanizeShopSlug } from './shop-seo-fallback'; interface ShopMaterialOption { key: string; @@ -84,6 +85,7 @@ export class ProductDetailComponent { ); readonly loading = signal(true); + readonly softFallbackActive = signal(false); readonly error = signal(null); readonly product = signal(null); readonly selectedVariantId = signal(null); @@ -233,6 +235,7 @@ export class ProductDetailComponent { .pipe( tap(() => { this.loading.set(true); + this.softFallbackActive.set(false); this.error.set(null); this.addSuccess.set(false); this.modelError.set(false); @@ -245,13 +248,14 @@ export class ProductDetailComponent { this.languageService.clearLocalizedRouteOverrides(); this.error.set('SHOP.NOT_FOUND'); this.setResponseStatus(404); - this.applyFallbackSeo(); + this.applyHardFallbackSeo(); this.loading.set(false); return of(null); } + const productSlug = routeParams.productSlug as string; return this.shopService - .getProductByPublicPath(routeParams.productSlug) + .getProductByPublicPath(productSlug) .pipe( catchError((error) => { this.languageService.clearLocalizedRouteOverrides(); @@ -260,13 +264,23 @@ export class ProductDetailComponent { this.setSelectedImageAssetId(null); this.modelFile.set(null); const isNotFound = error?.status === 404; - this.error.set( - isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', - ); - this.setResponseStatus(isNotFound ? 404 : 503); - if (this.shouldApplyFallbackSeo(error)) { - this.applyFallbackSeo(); + if (isNotFound) { + this.error.set('SHOP.NOT_FOUND'); + this.setResponseStatus(404); + this.applyHardFallbackSeo(); + return of(null); } + + if (this.shouldUseSoftSeoFallback(error)) { + this.error.set(null); + this.softFallbackActive.set(true); + this.setResponseStatus(200); + this.applySoftFallbackSeo(productSlug); + return of(null); + } + + this.error.set('SHOP.LOAD_ERROR'); + this.setResponseStatus(503); return of(null); }), finalize(() => this.loading.set(false)), @@ -280,6 +294,7 @@ export class ProductDetailComponent { } this.product.set(product); + this.softFallbackActive.set(false); this.selectedVariantId.set( product.defaultVariant?.id ?? product.variants[0]?.id ?? null, ); @@ -608,7 +623,7 @@ export class ProductDetailComponent { }); } - private applyFallbackSeo(): void { + private applyHardFallbackSeo(): void { const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); this.seoService.applyResolvedSeo({ @@ -623,12 +638,53 @@ export class ProductDetailComponent { }); } - private shouldApplyFallbackSeo(error: { status?: number } | null): boolean { - if (error?.status === 404) { - return true; + private applySoftFallbackSeo(productSlug: string): void { + const title = this.buildSoftFallbackTitle(productSlug); + const description = this.resolveTranslatedText( + 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION', + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'), + ); + + this.seoService.applyResolvedSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + canonicalPath: this.currentPath(), + alternates: null, + xDefault: null, + }); + } + + private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean { + return !this.isBrowser && error?.status !== 404; + } + + private buildSoftFallbackTitle(productSlug: string): string { + const humanized = humanizeShopSlug(productSlug, { + stripProductIdPrefix: true, + }); + if (humanized) { + return `${humanized} | 3D fab`; } - return !this.isBrowser; + return this.resolveTranslatedText( + 'SEO.ROUTES.SHOP.PRODUCT_TITLE', + `${this.translate.instant('SHOP.TITLE')} | 3D fab`, + ); + } + + private resolveTranslatedText(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return typeof translated === 'string' && translated !== key + ? translated + : fallback; + } + + private currentPath(): string { + const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/'; + return path.startsWith('/') ? path : `/${path}`; } private materialLabelForVariant( diff --git a/frontend/src/app/features/shop/shop-page.component.html b/frontend/src/app/features/shop/shop-page.component.html index c276508..8675519 100644 --- a/frontend/src/app/features/shop/shop-page.component.html +++ b/frontend/src/app/features/shop/shop-page.component.html @@ -1,15 +1,7 @@

{{ "NAV.SHOP" | translate }}

-

- {{ - selectedCategory() - ? selectedCategory()?.description || - ("SHOP.CATEGORY_META" - | translate: { count: selectedCategory()?.productCount || 0 }) - : ("SHOP.SUBTITLE" | translate) - }} -

+

{{ heroSubtitle() }}

@@ -181,17 +173,9 @@

- {{ - selectedCategory() - ? ("SHOP.SELECTED_CATEGORY" | translate) - : ("SHOP.CATALOG_LABEL" | translate) - }} + {{ catalogEyebrow() }}

-

- {{ - selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate) - }} -

+

{{ catalogTitle() }}

{{ products().length }} @@ -199,7 +183,7 @@
- @if (loading()) { + @if (loading() || softFallbackActive()) {
@for (ghost of [1, 2, 3, 4]; track ghost) {
diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 13264f3..8287ddd 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -42,6 +42,7 @@ import { ShopService, } from './services/shop.service'; import { ShopRouteService } from './services/shop-route.service'; +import { humanizeShopSlug } from './shop-seo-fallback'; @Component({ selector: 'app-shop-page', @@ -75,6 +76,8 @@ export class ShopPageComponent { ); readonly loading = signal(true); + readonly softFallbackActive = signal(false); + readonly softFallbackCategoryLabel = signal(null); readonly error = signal(null); readonly categories = signal([]); readonly categoryNodes = signal([]); @@ -96,6 +99,44 @@ export class ShopPageComponent { ), ); readonly cartHasItems = computed(() => this.cartItems().length > 0); + readonly heroSubtitle = computed(() => { + this.languageService.currentLang(); + + const category = this.selectedCategory(); + if (category) { + return ( + category.description || + this.translate.instant('SHOP.CATEGORY_META', { + count: category.productCount || 0, + }) + ); + } + + if (this.softFallbackActive() && this.routeCategorySlug()) { + return this.resolveTranslatedText( + 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION', + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'), + ); + } + + return this.translate.instant('SHOP.SUBTITLE'); + }); + readonly catalogEyebrow = computed(() => { + this.languageService.currentLang(); + + return this.selectedCategory() || this.softFallbackCategoryLabel() + ? this.translate.instant('SHOP.SELECTED_CATEGORY') + : this.translate.instant('SHOP.CATALOG_LABEL'); + }); + readonly catalogTitle = computed(() => { + this.languageService.currentLang(); + + return ( + this.selectedCategory()?.name || + this.softFallbackCategoryLabel() || + this.translate.instant('SHOP.CATALOG_TITLE') + ); + }); constructor() { afterNextRender(() => { @@ -114,6 +155,8 @@ export class ShopPageComponent { .pipe( tap(() => { this.loading.set(true); + this.softFallbackActive.set(false); + this.softFallbackCategoryLabel.set(null); this.error.set(null); }), switchMap(([categorySlug]) => { @@ -128,11 +171,26 @@ export class ShopPageComponent { this.categoryNodes.set([]); this.selectedCategory.set(null); this.products.set([]); - this.error.set(isNotFound ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR'); - this.setResponseStatus(isNotFound ? 404 : 503); - if (this.shouldApplyErrorSeo(error)) { - this.applyErrorSeo(); + if (isNotFound) { + this.error.set('SHOP.NOT_FOUND'); + this.setResponseStatus(404); + this.applyHardErrorSeo(); + return of(null); } + + if (this.shouldUseSoftSeoFallback(error)) { + this.error.set(null); + this.softFallbackActive.set(true); + this.softFallbackCategoryLabel.set( + categorySlug ? humanizeShopSlug(categorySlug) : null, + ); + this.setResponseStatus(200); + this.applySoftFallbackSeo(categorySlug); + return of(null); + } + + this.error.set('SHOP.LOAD_ERROR'); + this.setResponseStatus(503); return of(null); }), finalize(() => this.loading.set(false)), @@ -154,6 +212,8 @@ export class ShopPageComponent { ); this.selectedCategory.set(result.catalog.category ?? null); this.products.set(result.catalog.products); + this.softFallbackActive.set(false); + this.softFallbackCategoryLabel.set(null); this.applySeo(result.catalog.category ?? null); this.restoreCatalogScrollIfNeeded(); }); @@ -369,7 +429,7 @@ export class ShopPageComponent { }); } - private applyErrorSeo(): void { + private applyHardErrorSeo(): void { const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); @@ -385,12 +445,57 @@ export class ShopPageComponent { }); } - private shouldApplyErrorSeo(error: { status?: number } | null): boolean { - if (error?.status === 404) { - return true; + private applySoftFallbackSeo(categorySlug: string | null): void { + if (!categorySlug) { + this.applyDefaultSeo(); + return; } - return !this.isBrowser; + const title = this.buildSoftFallbackCategoryTitle(categorySlug); + const description = this.resolveTranslatedText( + 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION', + this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'), + ); + + this.seoService.applyResolvedSeo({ + title, + description, + robots: 'index, follow', + ogTitle: title, + ogDescription: description, + canonicalPath: this.currentPath(), + alternates: null, + xDefault: null, + }); + } + + private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean { + return !this.isBrowser && error?.status !== 404; + } + + private buildSoftFallbackCategoryTitle(categorySlug: string): string { + const shopTitle = this.translate.instant('SHOP.TITLE'); + const humanized = humanizeShopSlug(categorySlug); + if (humanized) { + return `${humanized} | ${shopTitle} | 3D fab`; + } + + return this.resolveTranslatedText( + 'SEO.ROUTES.SHOP.CATEGORY_TITLE', + `${shopTitle} | 3D fab`, + ); + } + + private resolveTranslatedText(key: string, fallback: string): string { + const translated = this.translate.instant(key); + return typeof translated === 'string' && translated !== key + ? translated + : fallback; + } + + private currentPath(): string { + const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/'; + return path.startsWith('/') ? path : `/${path}`; } private setResponseStatus(status: number): void { diff --git a/frontend/src/app/features/shop/shop-seo-fallback.ts b/frontend/src/app/features/shop/shop-seo-fallback.ts new file mode 100644 index 0000000..cda6389 --- /dev/null +++ b/frontend/src/app/features/shop/shop-seo-fallback.ts @@ -0,0 +1,72 @@ +const PRODUCT_ID_PREFIX_PATTERN = /^[0-9a-f]{8}-(?=[a-z0-9])/i; +const UPPERCASE_TOKENS = new Set([ + '3d', + 'abs', + 'asa', + 'cad', + 'cf', + 'gf', + 'pa', + 'pc', + 'petg', + 'pla', + 'pp', + 'tpu', + 'uv', +]); + +export function humanizeShopSlug( + value: string | null | undefined, + options?: { + stripProductIdPrefix?: boolean; + }, +): string { + const normalized = normalizeShopSlug(value, options?.stripProductIdPrefix); + if (!normalized) { + return ''; + } + + return normalized + .split('-') + .filter(Boolean) + .map(formatSlugToken) + .join(' ') + .trim(); +} + +function normalizeShopSlug( + value: string | null | undefined, + stripProductIdPrefix = false, +): string { + const normalized = String(value ?? '') + .trim() + .replace(/^\/+|\/+$/g, '') + .split('/') + .filter(Boolean) + .at(-1) + ?.toLowerCase(); + + if (!normalized) { + return ''; + } + + return stripProductIdPrefix + ? normalized.replace(PRODUCT_ID_PREFIX_PATTERN, '') + : normalized; +} + +function formatSlugToken(token: string): string { + if (!token) { + return ''; + } + + if (/^\d+$/.test(token)) { + return token; + } + + if (UPPERCASE_TOKENS.has(token)) { + return token.toUpperCase(); + } + + return `${token.charAt(0).toUpperCase()}${token.slice(1)}`; +} From 9611049e01d4e22a15f60a5d716012065b872c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joe=20K=C3=BCng?= Date: Tue, 24 Mar 2026 13:17:25 +0100 Subject: [PATCH 4/5] fix(front-end): new test --- .../server-origin.interceptor.spec.ts | 77 ++++++ .../shop/product-detail.component.spec.ts | 237 ++++++++++++++++++ .../features/shop/shop-page.component.spec.ts | 219 ++++++++++++++++ 3 files changed, 533 insertions(+) create mode 100644 frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts create mode 100644 frontend/src/app/features/shop/product-detail.component.spec.ts create mode 100644 frontend/src/app/features/shop/shop-page.component.spec.ts diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts new file mode 100644 index 0000000..403fc8d --- /dev/null +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { REQUEST } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { serverOriginInterceptor } from './server-origin.interceptor'; + +describe('serverOriginInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + headers: { + host: 'dev.3d-fab.ch', + authorization: 'Basic dGVzdDp0ZXN0', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => { + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('does not overwrite explicit request headers', () => { + http + .get('/api/shop/products', { + headers: { + authorization: 'Bearer explicit-token', + }, + }) + .subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Bearer explicit-token', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + request.flush({}); + }); +}); diff --git a/frontend/src/app/features/shop/product-detail.component.spec.ts b/frontend/src/app/features/shop/product-detail.component.spec.ts new file mode 100644 index 0000000..3e73a9d --- /dev/null +++ b/frontend/src/app/features/shop/product-detail.component.spec.ts @@ -0,0 +1,237 @@ +import { Location } from '@angular/common'; +import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; +import { ShopRouteService } from './services/shop-route.service'; +import { ShopProductDetail, ShopService } from './services/shop.service'; +import { ProductDetailComponent } from './product-detail.component'; + +describe('ProductDetailComponent', () => { + function buildProduct( + overrides: Partial = {}, + ): ShopProductDetail { + return { + id: '91823f84-1111-2222-3333-444444444444', + slug: 'bike-wall-hanger', + name: 'Bike Wall-Hanger', + excerpt: 'Wall mount for bicycles', + description: '

Wall mount for bicycles

', + seoTitle: null, + seoDescription: null, + ogTitle: null, + ogDescription: null, + indexable: true, + isFeatured: false, + sortOrder: 0, + category: { + id: 'category-1', + slug: 'bike-accessories', + name: 'Bike Accessories', + }, + breadcrumbs: [], + priceFromChf: 29.9, + priceToChf: 29.9, + defaultVariant: { + id: 'variant-1', + sku: 'BW-1', + variantLabel: 'PLA', + colorName: 'Black', + colorLabel: 'Black', + colorHex: '#111111', + priceChf: 29.9, + isDefault: true, + }, + variants: [ + { + id: 'variant-1', + sku: 'BW-1', + variantLabel: 'PLA', + colorName: 'Black', + colorLabel: 'Black', + colorHex: '#111111', + priceChf: 29.9, + isDefault: true, + }, + ], + primaryImage: null, + images: [], + model3d: null, + publicPath: '91823f84-bike-wall-hanger', + localizedPaths: { + it: '/it/shop/p/91823f84-supporto-bici-muro', + en: '/en/shop/p/91823f84-bike-wall-hanger', + de: '/de/shop/p/91823f84-bike-wall-hanger', + fr: '/fr/shop/p/91823f84-support-mural-velo', + }, + ...overrides, + }; + } + + function createComponent(routerUrl = '/de/shop/p/91823f84-bike-wall-hanger') { + const responseInit: { status?: number } = {}; + const seoService = jasmine.createSpyObj('SeoService', [ + 'applyResolvedSeo', + 'applyPageSeo', + ]); + const translate = jasmine.createSpyObj( + 'TranslateService', + ['instant'], + ); + translate.instant.and.callFake((key: string) => { + const translations: Record = { + 'SHOP.TITLE': 'Technische Lösungen', + 'SHOP.CATALOG_META_DESCRIPTION': + 'Entdecken Sie technische 3D-Druck-Lösungen.', + 'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab', + 'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION': + 'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.', + }; + return translations[key] ?? key; + }); + + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); + const languageService = { + currentLang, + selectedLang: () => currentLang(), + setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'), + clearLocalizedRouteOverrides: jasmine.createSpy( + 'clearLocalizedRouteOverrides', + ), + }; + + const shopService = { + cartLoaded: signal(false), + cartLoading: signal(false), + getProductByPublicPath: jasmine + .createSpy('getProductByPublicPath') + .and.returnValue(of(buildProduct())), + quantityForVariant: jasmine + .createSpy('quantityForVariant') + .and.returnValue(0), + loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), + resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null), + }; + + const router = { + url: routerUrl, + navigate: jasmine.createSpy('navigate'), + navigateByUrl: jasmine.createSpy('navigateByUrl'), + parseUrl: jasmine.createSpy('parseUrl'), + createUrlTree: jasmine.createSpy('createUrlTree'), + serializeUrl: jasmine.createSpy('serializeUrl'), + } as unknown as Router; + + const activatedRoute = { + paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })), + snapshot: { + paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }), + }, + } as unknown as ActivatedRoute; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ProductDetailComponent], + providers: [ + { provide: SeoService, useValue: seoService }, + { provide: TranslateService, useValue: translate }, + { provide: LanguageService, useValue: languageService }, + { provide: ShopService, useValue: shopService }, + { + provide: ShopRouteService, + useValue: jasmine.createSpyObj('ShopRouteService', [ + 'shopRootCommands', + 'productPathSegment', + 'isCatalogUrl', + ]), + }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { + provide: Location, + useValue: jasmine.createSpyObj('Location', ['back']), + }, + { provide: RESPONSE_INIT, useValue: responseInit }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + const fixture: ComponentFixture = + TestBed.createComponent(ProductDetailComponent); + + return { + component: fixture.componentInstance, + seoService, + responseInit, + }; + } + + it('applies index follow SEO for indexable products', () => { + const { component, seoService } = createComponent(); + + (component as any).applySeo(buildProduct()); + + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Bike Wall-Hanger | 3D fab', + robots: 'index, follow', + canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger', + alternates: buildProduct().localizedPaths, + xDefault: '/it/shop/p/91823f84-supporto-bici-muro', + }), + ); + }); + + it('applies noindex for products explicitly marked as non-indexable', () => { + const { component, seoService } = createComponent(); + + (component as any).applySeo(buildProduct({ indexable: false })); + + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + }), + ); + }); + + it('builds a soft SSR fallback with 200 + index follow', () => { + const { component, seoService, responseInit } = createComponent(); + + expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + (component as any).setResponseStatus(200); + (component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger'); + + expect(responseInit.status).toBe(200); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Bike Wall Hanger | 3D fab', + description: + 'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.', + robots: 'index, follow', + canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger', + alternates: null, + xDefault: null, + }), + ); + }); + + it('keeps hard fallback noindex for missing products', () => { + const { component, seoService, responseInit } = createComponent(); + + expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + (component as any).setResponseStatus(404); + (component as any).applyHardFallbackSeo(); + + expect(responseInit.status).toBe(404); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + alternates: null, + xDefault: null, + }), + ); + }); +}); diff --git a/frontend/src/app/features/shop/shop-page.component.spec.ts b/frontend/src/app/features/shop/shop-page.component.spec.ts new file mode 100644 index 0000000..048234a --- /dev/null +++ b/frontend/src/app/features/shop/shop-page.component.spec.ts @@ -0,0 +1,219 @@ +import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; +import { TranslateService } from '@ngx-translate/core'; +import { of } from 'rxjs'; +import { SeoService } from '../../core/services/seo.service'; +import { LanguageService } from '../../core/services/language.service'; +import { + ShopCategoryDetail, + ShopCategoryTree, + ShopProductCatalogResponse, + ShopService, +} from './services/shop.service'; +import { ShopRouteService } from './services/shop-route.service'; +import { ShopPageComponent } from './shop-page.component'; + +describe('ShopPageComponent', () => { + function buildCategory( + overrides: Partial = {}, + ): ShopCategoryDetail { + return { + id: 'cat-1', + slug: 'compatible-with-garmin', + name: 'Compatible with Garmin', + description: 'Accessories compatible with Garmin devices.', + seoTitle: null, + seoDescription: null, + ogTitle: null, + ogDescription: null, + indexable: true, + sortOrder: 0, + productCount: 3, + breadcrumbs: [], + primaryImage: null, + images: [], + children: [], + ...overrides, + }; + } + + function buildCatalog( + overrides: Partial = {}, + ): ShopProductCatalogResponse { + return { + categorySlug: null, + featuredOnly: null, + category: null, + products: [], + ...overrides, + }; + } + + function createComponent(routerUrl = '/de/shop') { + const responseInit: { status?: number } = {}; + const seoService = jasmine.createSpyObj('SeoService', [ + 'applyResolvedSeo', + 'applyPageSeo', + ]); + const translate = jasmine.createSpyObj( + 'TranslateService', + ['instant'], + ); + translate.instant.and.callFake((key: string, params?: { count?: number }) => { + const translations: Record = { + 'SHOP.TITLE': 'Technische Lösungen', + 'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen', + 'SHOP.CATALOG_TITLE': 'Alle Produkte', + 'SHOP.CATALOG_LABEL': 'Katalog', + 'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie', + 'SHOP.CATALOG_META_DESCRIPTION': + 'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.', + 'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab', + 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION': + 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', + }; + if (key === 'SHOP.CATEGORY_META') { + return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`; + } + return translations[key] ?? key; + }); + + const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); + const languageService = { + currentLang, + selectedLang: () => currentLang(), + }; + + const shopService = { + cart: signal(null), + cartLoading: signal(false), + cartLoaded: signal(false), + cartItemCount: signal(0), + cartSessionId: signal(null), + getCategories: jasmine + .createSpy('getCategories') + .and.returnValue(of([] as ShopCategoryTree[])), + getProductCatalog: jasmine + .createSpy('getProductCatalog') + .and.returnValue(of(buildCatalog())), + flattenCategoryTree: jasmine + .createSpy('flattenCategoryTree') + .and.returnValue([]), + quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0), + loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), + clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)), + removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)), + updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)), + }; + + const router = { + url: routerUrl, + navigate: jasmine.createSpy('navigate'), + } as unknown as Router; + + const activatedRoute = { + paramMap: of(convertToParamMap({})), + snapshot: { + paramMap: convertToParamMap({}), + }, + } as unknown as ActivatedRoute; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ShopPageComponent], + providers: [ + { provide: SeoService, useValue: seoService }, + { provide: TranslateService, useValue: translate }, + { provide: LanguageService, useValue: languageService }, + { provide: ShopService, useValue: shopService }, + { + provide: ShopRouteService, + useValue: jasmine.createSpyObj('ShopRouteService', [ + 'shopRootCommands', + ]), + }, + { provide: Router, useValue: router }, + { provide: ActivatedRoute, useValue: activatedRoute }, + { provide: RESPONSE_INIT, useValue: responseInit }, + { provide: PLATFORM_ID, useValue: 'server' }, + ], + }); + + const fixture: ComponentFixture = + TestBed.createComponent(ShopPageComponent); + + return { + component: fixture.componentInstance, + seoService, + responseInit, + }; + } + + it('keeps index follow on the public shop root', () => { + const { component, seoService } = createComponent(); + + (component as any).applyDefaultSeo(); + + expect(seoService.applyPageSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Technische Lösungen | 3D fab', + robots: 'index, follow', + }), + ); + }); + + it('keeps noindex for categories explicitly marked as non-indexable', () => { + const { component, seoService } = createComponent('/de/shop/compatible-with-garmin'); + + (component as any).applySeo(buildCategory({ indexable: false })); + + expect(seoService.applyPageSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + }), + ); + }); + + it('uses a soft SSR fallback for non-404 category load errors', () => { + const { component, seoService, responseInit } = createComponent( + '/de/shop/compatible-with-garmin', + ); + + expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + (component as any).setResponseStatus(200); + (component as any).applySoftFallbackSeo('compatible-with-garmin'); + + expect(responseInit.status).toBe(200); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + title: 'Compatible With Garmin | Technische Lösungen | 3D fab', + description: + 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', + robots: 'index, follow', + canonicalPath: '/de/shop/compatible-with-garmin', + alternates: null, + xDefault: null, + }), + ); + }); + + it('keeps hard 404 noindex behavior for missing categories', () => { + const { component, seoService, responseInit } = createComponent( + '/de/shop/compatible-with-garmin', + ); + + expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + (component as any).setResponseStatus(404); + (component as any).applyHardErrorSeo(); + + expect(responseInit.status).toBe(404); + expect(seoService.applyResolvedSeo).toHaveBeenCalledWith( + jasmine.objectContaining({ + robots: 'noindex, nofollow', + alternates: null, + xDefault: null, + }), + ); + }); +}); From cb861377303b2e54f202f3bc9ff98fbcf4898bb3 Mon Sep 17 00:00:00 2001 From: printcalc-ci Date: Tue, 24 Mar 2026 12:19:19 +0000 Subject: [PATCH 5/5] style: apply prettier formatting --- .../shop/product-detail.component.spec.ts | 24 +++++-- .../features/shop/product-detail.component.ts | 62 +++++++++---------- .../features/shop/shop-page.component.spec.ts | 62 ++++++++++++------- .../app/features/shop/shop-page.component.ts | 4 +- 4 files changed, 86 insertions(+), 66 deletions(-) diff --git a/frontend/src/app/features/shop/product-detail.component.spec.ts b/frontend/src/app/features/shop/product-detail.component.spec.ts index 3e73a9d..08c0cf9 100644 --- a/frontend/src/app/features/shop/product-detail.component.spec.ts +++ b/frontend/src/app/features/shop/product-detail.component.spec.ts @@ -97,7 +97,9 @@ describe('ProductDetailComponent', () => { const languageService = { currentLang, selectedLang: () => currentLang(), - setLocalizedRouteOverrides: jasmine.createSpy('setLocalizedRouteOverrides'), + setLocalizedRouteOverrides: jasmine.createSpy( + 'setLocalizedRouteOverrides', + ), clearLocalizedRouteOverrides: jasmine.createSpy( 'clearLocalizedRouteOverrides', ), @@ -113,7 +115,9 @@ describe('ProductDetailComponent', () => { .createSpy('quantityForVariant') .and.returnValue(0), loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), - resolveMediaUrl: jasmine.createSpy('resolveMediaUrl').and.returnValue(null), + resolveMediaUrl: jasmine + .createSpy('resolveMediaUrl') + .and.returnValue(null), }; const router = { @@ -126,9 +130,13 @@ describe('ProductDetailComponent', () => { } as unknown as Router; const activatedRoute = { - paramMap: of(convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' })), + paramMap: of( + convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }), + ), snapshot: { - paramMap: convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }), + paramMap: convertToParamMap({ + productSlug: '91823f84-bike-wall-hanger', + }), }, } as unknown as ActivatedRoute; @@ -200,7 +208,9 @@ describe('ProductDetailComponent', () => { it('builds a soft SSR fallback with 200 + index follow', () => { const { component, seoService, responseInit } = createComponent(); - expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + expect( + (component as any).shouldUseSoftSeoFallback({ status: 500 }), + ).toBeTrue(); (component as any).setResponseStatus(200); (component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger'); @@ -221,7 +231,9 @@ describe('ProductDetailComponent', () => { it('keeps hard fallback noindex for missing products', () => { const { component, seoService, responseInit } = createComponent(); - expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + expect( + (component as any).shouldUseSoftSeoFallback({ status: 404 }), + ).toBeFalse(); (component as any).setResponseStatus(404); (component as any).applyHardFallbackSeo(); diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 47f7257..3573be6 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -254,37 +254,35 @@ export class ProductDetailComponent { } const productSlug = routeParams.productSlug as string; - return this.shopService - .getProductByPublicPath(productSlug) - .pipe( - catchError((error) => { - this.languageService.clearLocalizedRouteOverrides(); - this.product.set(null); - this.selectedVariantId.set(null); - this.setSelectedImageAssetId(null); - this.modelFile.set(null); - const isNotFound = error?.status === 404; - if (isNotFound) { - this.error.set('SHOP.NOT_FOUND'); - this.setResponseStatus(404); - this.applyHardFallbackSeo(); - return of(null); - } - - if (this.shouldUseSoftSeoFallback(error)) { - this.error.set(null); - this.softFallbackActive.set(true); - this.setResponseStatus(200); - this.applySoftFallbackSeo(productSlug); - return of(null); - } - - this.error.set('SHOP.LOAD_ERROR'); - this.setResponseStatus(503); + return this.shopService.getProductByPublicPath(productSlug).pipe( + catchError((error) => { + this.languageService.clearLocalizedRouteOverrides(); + this.product.set(null); + this.selectedVariantId.set(null); + this.setSelectedImageAssetId(null); + this.modelFile.set(null); + const isNotFound = error?.status === 404; + if (isNotFound) { + this.error.set('SHOP.NOT_FOUND'); + this.setResponseStatus(404); + this.applyHardFallbackSeo(); return of(null); - }), - finalize(() => this.loading.set(false)), - ); + } + + if (this.shouldUseSoftSeoFallback(error)) { + this.error.set(null); + this.softFallbackActive.set(true); + this.setResponseStatus(200); + this.applySoftFallbackSeo(productSlug); + return of(null); + } + + this.error.set('SHOP.LOAD_ERROR'); + this.setResponseStatus(503); + return of(null); + }), + finalize(() => this.loading.set(false)), + ); }), takeUntilDestroyed(this.destroyRef), ) @@ -904,9 +902,7 @@ export class ProductDetailComponent { return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name)); } - private normalizeRouteParam( - value: string | null | undefined, - ): string | null { + private normalizeRouteParam(value: string | null | undefined): string | null { const normalized = String(value ?? '').trim(); return normalized || null; } diff --git a/frontend/src/app/features/shop/shop-page.component.spec.ts b/frontend/src/app/features/shop/shop-page.component.spec.ts index 048234a..39a8ea5 100644 --- a/frontend/src/app/features/shop/shop-page.component.spec.ts +++ b/frontend/src/app/features/shop/shop-page.component.spec.ts @@ -60,24 +60,26 @@ describe('ShopPageComponent', () => { 'TranslateService', ['instant'], ); - translate.instant.and.callFake((key: string, params?: { count?: number }) => { - const translations: Record = { - 'SHOP.TITLE': 'Technische Lösungen', - 'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen', - 'SHOP.CATALOG_TITLE': 'Alle Produkte', - 'SHOP.CATALOG_LABEL': 'Katalog', - 'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie', - 'SHOP.CATALOG_META_DESCRIPTION': - 'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.', - 'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab', - 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION': - 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', - }; - if (key === 'SHOP.CATEGORY_META') { - return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`; - } - return translations[key] ?? key; - }); + translate.instant.and.callFake( + (key: string, params?: { count?: number }) => { + const translations: Record = { + 'SHOP.TITLE': 'Technische Lösungen', + 'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen', + 'SHOP.CATALOG_TITLE': 'Alle Produkte', + 'SHOP.CATALOG_LABEL': 'Katalog', + 'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie', + 'SHOP.CATALOG_META_DESCRIPTION': + 'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.', + 'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab', + 'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION': + 'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.', + }; + if (key === 'SHOP.CATEGORY_META') { + return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`; + } + return translations[key] ?? key; + }, + ); const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de'); const languageService = { @@ -100,11 +102,17 @@ describe('ShopPageComponent', () => { flattenCategoryTree: jasmine .createSpy('flattenCategoryTree') .and.returnValue([]), - quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0), + quantityForProduct: jasmine + .createSpy('quantityForProduct') + .and.returnValue(0), loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)), clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)), - removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)), - updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)), + removeCartItem: jasmine + .createSpy('removeCartItem') + .and.returnValue(of(null)), + updateCartItem: jasmine + .createSpy('updateCartItem') + .and.returnValue(of(null)), }; const router = { @@ -164,7 +172,9 @@ describe('ShopPageComponent', () => { }); it('keeps noindex for categories explicitly marked as non-indexable', () => { - const { component, seoService } = createComponent('/de/shop/compatible-with-garmin'); + const { component, seoService } = createComponent( + '/de/shop/compatible-with-garmin', + ); (component as any).applySeo(buildCategory({ indexable: false })); @@ -180,7 +190,9 @@ describe('ShopPageComponent', () => { '/de/shop/compatible-with-garmin', ); - expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue(); + expect( + (component as any).shouldUseSoftSeoFallback({ status: 500 }), + ).toBeTrue(); (component as any).setResponseStatus(200); (component as any).applySoftFallbackSeo('compatible-with-garmin'); @@ -203,7 +215,9 @@ describe('ShopPageComponent', () => { '/de/shop/compatible-with-garmin', ); - expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse(); + expect( + (component as any).shouldUseSoftSeoFallback({ status: 404 }), + ).toBeFalse(); (component as any).setResponseStatus(404); (component as any).applyHardErrorSeo(); diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 8287ddd..2890b74 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -528,9 +528,7 @@ export class ShopPageComponent { return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name)); } - private normalizeRouteParam( - value: string | null | undefined, - ): string | null { + private normalizeRouteParam(value: string | null | undefined): string | null { const normalized = String(value ?? '').trim(); return normalized || null; }