diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java index 265cb8b..ba205a4 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java @@ -2,6 +2,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.UUID; public record ShopProductDetailDto( @@ -25,6 +26,8 @@ public record ShopProductDetailDto( List variants, PublicMediaUsageDto primaryImage, List images, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java index d563a07..2d4e14e 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; +import java.util.Map; import java.util.UUID; public record ShopProductSummaryDto( @@ -15,6 +16,8 @@ public record ShopProductSummaryDto( BigDecimal priceToChf, ShopProductVariantOptionDto defaultVariant, PublicMediaUsageDto primaryImage, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } 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 62636a1..779258a 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -399,6 +399,7 @@ public class PublicShopCatalogService { Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); return new ShopProductSummaryDto( entry.product().getId(), entry.product().getSlug(), @@ -415,7 +416,9 @@ public class PublicShopCatalogService { resolvePriceTo(entry.variants()), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), selectPrimaryMedia(images), - toProductModelDto(entry) + toProductModelDto(entry), + localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), + localizedPaths ); } @@ -426,6 +429,7 @@ public class PublicShopCatalogService { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); return new ShopProductDetailDto( entry.product().getId(), entry.product().getSlug(), @@ -453,7 +457,9 @@ public class PublicShopCatalogService { .toList(), selectPrimaryMedia(images), images, - toProductModelDto(entry) + toProductModelDto(entry), + localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), + localizedPaths ); } @@ -514,6 +520,22 @@ public class PublicShopCatalogService { return raw; } + private String normalizeLanguage(String language) { + String normalized = trimToNull(language); + if (normalized == null) { + return "it"; + } + normalized = normalized.toLowerCase(Locale.ROOT); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + return switch (normalized) { + case "en", "de", "fr" -> normalized; + default -> "it"; + }; + } + private ShopProductModelDto toProductModelDto(ProductEntry entry) { if (entry.modelAsset() == null) { return null; diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java new file mode 100644 index 0000000..cd16503 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java @@ -0,0 +1,66 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.entity.ShopProduct; + +import java.text.Normalizer; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +final class ShopPublicPathSupport { + private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/"; + + private ShopPublicPathSupport() { + } + + static String buildProductPathSegment(ShopProduct product, String language) { + String localizedName = product.getNameForLanguage(language); + String idPrefix = productIdPrefix(product.getId()); + String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product"); + return idPrefix.isBlank() ? tail : idPrefix + "-" + tail; + } + + static Map buildLocalizedProductPaths(ShopProduct product) { + Map localizedPaths = new LinkedHashMap<>(); + for (String language : ShopProduct.SUPPORTED_LANGUAGES) { + localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language)); + } + return localizedPaths; + } + + static String productIdPrefix(UUID productId) { + if (productId == null) { + return ""; + } + String raw = productId.toString().trim().toLowerCase(Locale.ROOT); + int dashIndex = raw.indexOf('-'); + if (dashIndex > 0) { + return raw.substring(0, dashIndex); + } + return raw.length() >= 8 ? raw.substring(0, 8) : raw; + } + + static String slugify(String rawValue) { + String safeValue = rawValue == null ? "" : rawValue; + String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD) + .replaceAll("\\p{M}+", "") + .toLowerCase(Locale.ROOT) + .replaceAll("[^a-z0-9]+", "-") + .replaceAll("^-+|-+$", "") + .replaceAll("-{2,}", "-"); + return normalized; + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java index e3cf38a..54ad68f 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.text.Normalizer; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -19,7 +18,6 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -31,6 +29,12 @@ public class ShopSitemapService { private static final List SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; private static final String DEFAULT_LANGUAGE = "it"; private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final Map HREFLANG_BY_LANGUAGE = Map.of( + "it", "it-CH", + "en", "en-CH", + "de", "de-CH", + "fr", "fr-CH" + ); private final ShopCategoryRepository shopCategoryRepository; private final ShopProductRepository shopProductRepository; @@ -130,7 +134,7 @@ public class ShopSitemapService { Map hrefByLanguage = new LinkedHashMap<>(); for (String language : SUPPORTED_LANGUAGES) { - String publicSegment = localizedProductPathSegment(product, language); + String publicSegment = ShopPublicPathSupport.buildProductPathSegment(product, language); hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment)); } @@ -169,7 +173,7 @@ public class ShopSitemapService { continue; } xml.append(" \n"); @@ -186,48 +190,6 @@ public class ShopSitemapService { xml.append(" \n"); } - private String localizedProductPathSegment(ShopProduct product, String language) { - String localizedName = product.getNameForLanguage(language); - String idPrefix = productIdPrefix(product.getId()); - String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product"); - return idPrefix.isBlank() ? tail : idPrefix + "-" + tail; - } - - private String productIdPrefix(UUID productId) { - if (productId == null) { - return ""; - } - String raw = productId.toString().trim().toLowerCase(Locale.ROOT); - int dashIndex = raw.indexOf('-'); - if (dashIndex > 0) { - return raw.substring(0, dashIndex); - } - return raw.length() >= 8 ? raw.substring(0, 8) : raw; - } - - static String slugify(String rawValue) { - String safeValue = rawValue == null ? "" : rawValue; - String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD) - .replaceAll("\\p{M}+", "") - .toLowerCase(Locale.ROOT) - .replaceAll("[^a-z0-9]+", "-") - .replaceAll("^-+|-+$", "") - .replaceAll("-{2,}", "-"); - return normalized; - } - - private String firstNonBlank(String... values) { - if (values == null) { - return null; - } - for (String value : values) { - if (value != null && !value.isBlank()) { - return value; - } - } - return null; - } - private String pathEncodeSegment(String rawSegment) { String safeSegment = rawSegment == null ? "" : rawSegment; return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); diff --git a/frontend/public/sitemap-static.xml b/frontend/public/sitemap-static.xml index b7020d5..795768d 100644 --- a/frontend/public/sitemap-static.xml +++ b/frontend/public/sitemap-static.xml @@ -2,40 +2,40 @@ https://3d-fab.ch/it - - - - + + + + weekly 1.0 https://3d-fab.ch/en - - - - + + + + weekly 1.0 https://3d-fab.ch/de - - - - + + + + weekly 1.0 https://3d-fab.ch/fr - - - - + + + + weekly 1.0 @@ -43,40 +43,40 @@ https://3d-fab.ch/it/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/en/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/de/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/fr/calculator/basic - - - - + + + + weekly 0.9 @@ -84,40 +84,40 @@ https://3d-fab.ch/it/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/en/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/de/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/calculator/advanced - - - - + + + + weekly 0.8 @@ -125,40 +125,40 @@ https://3d-fab.ch/it/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/en/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/de/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/shop - - - - + + + + weekly 0.8 @@ -166,40 +166,40 @@ https://3d-fab.ch/it/about - - - - + + + + monthly 0.7 https://3d-fab.ch/en/about - - - - + + + + monthly 0.7 https://3d-fab.ch/de/about - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/about - - - - + + + + monthly 0.7 @@ -207,40 +207,40 @@ https://3d-fab.ch/it/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/en/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/de/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/contact - - - - + + + + monthly 0.7 @@ -248,40 +248,40 @@ https://3d-fab.ch/it/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/en/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/de/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/privacy - - - - + + + + yearly 0.4 @@ -289,40 +289,40 @@ https://3d-fab.ch/it/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/en/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/de/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/terms - - - - + + + + yearly 0.4 diff --git a/frontend/src/app/core/services/language.service.spec.ts b/frontend/src/app/core/services/language.service.spec.ts index 43eebc0..d0996b2 100644 --- a/frontend/src/app/core/services/language.service.spec.ts +++ b/frontend/src/app/core/services/language.service.spec.ts @@ -1,5 +1,10 @@ import { Subject } from 'rxjs'; -import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router'; +import { + DefaultUrlSerializer, + NavigationEnd, + Router, + UrlTree, +} from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; import { LanguageService } from './language.service'; import { RequestLike } from '../../../core/request-origin'; @@ -61,7 +66,14 @@ describe('LanguageService', () => { parseUrl: (url: string) => serializer.parse(url), createUrlTree, serializeUrl: (tree: UrlTree) => serializer.serialize(tree), - navigateByUrl: jasmine.createSpy('navigateByUrl'), + navigateByUrl: jasmine + .createSpy('navigateByUrl') + .and.callFake((tree: UrlTree) => { + const nextUrl = serializer.serialize(tree); + router.url = nextUrl; + events$.next(new NavigationEnd(1, nextUrl, nextUrl)); + return Promise.resolve(true); + }), }; return router as unknown as Router; @@ -91,7 +103,28 @@ describe('LanguageService', () => { expect(navOptions.replaceUrl).toBeTrue(); }); - it('uses the preferred browser language when the URL has no language prefix', () => { + it('uses the preferred browser language on the root URL', () => { + const translate = createTranslateMock(); + const router = createRouterMock('/'); + const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const request: RequestLike = { + headers: { + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7', + }, + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const service = new LanguageService(translate, router, request); + + expect(translate.use).toHaveBeenCalledWith('de'); + expect(navigateSpy).toHaveBeenCalledTimes(1); + + const firstCall = navigateSpy.calls.mostRecent(); + const tree = firstCall.args[0] as UrlTree; + expect(router.serializeUrl(tree)).toBe('/de'); + }); + + it('uses the default language for non-root URLs without a language prefix', () => { const translate = createTranslateMock(); const router = createRouterMock('/calculator?session=abc'); const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; @@ -109,7 +142,7 @@ describe('LanguageService', () => { const firstCall = navigateSpy.calls.mostRecent(); const tree = firstCall.args[0] as UrlTree; - expect(router.serializeUrl(tree)).toBe('/de/calculator?session=abc'); + expect(router.serializeUrl(tree)).toBe('/it/calculator?session=abc'); }); it('switches language while preserving path and query params', () => { @@ -142,4 +175,23 @@ describe('LanguageService', () => { '/de/contact?topic=seo#form', ); }); + + it('switches product pages using the resolved localized route overrides', () => { + const translate = createTranslateMock(); + const router = createRouterMock('/it/shop/p/12345678-supporto-cavo'); + const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy; + const service = new LanguageService(translate, router); + + service.setLocalizedRouteOverrides({ + it: '/it/shop/p/12345678-supporto-cavo', + de: '/de/shop/p/12345678-kabelhalter', + }); + navigateSpy.calls.reset(); + + service.switchLang('de'); + + const call = navigateSpy.calls.mostRecent(); + const tree = call.args[0] as UrlTree; + expect(router.serializeUrl(tree)).toBe('/de/shop/p/12345678-kabelhalter'); + }); }); diff --git a/frontend/src/app/core/services/language.service.ts b/frontend/src/app/core/services/language.service.ts index fd27954..1669c59 100644 --- a/frontend/src/app/core/services/language.service.ts +++ b/frontend/src/app/core/services/language.service.ts @@ -13,17 +13,17 @@ import { } from '../i18n/language-resolution'; import { RequestLike } from '../../../core/request-origin'; +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +type LocalizedRouteOverrides = Partial>; + @Injectable({ providedIn: 'root', }) export class LanguageService { - currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it'); - private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [ - 'it', - 'en', - 'de', - 'fr', - ]; + currentLang = signal('it'); + private readonly defaultLang: SupportedLang = 'it'; + private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr']; + private localizedRouteOverrides: LocalizedRouteOverrides | null = null; constructor( private translate: TranslateService, @@ -61,13 +61,18 @@ export class LanguageService { }); } - switchLang(lang: 'it' | 'en' | 'de' | 'fr') { + switchLang(lang: SupportedLang) { if (!this.isSupportedLang(lang)) { return; } - this.applyLanguage(lang); const currentTree = this.router.parseUrl(this.router.url); + const localizedRoute = this.resolveLocalizedRouteOverride(currentTree, lang); + if (localizedRoute) { + this.navigateToLocalizedRoute(currentTree, localizedRoute); + return; + } + const segments = this.getPrimarySegments(currentTree); let targetSegments: string[]; @@ -85,7 +90,7 @@ export class LanguageService { this.navigateIfChanged(currentTree, targetSegments); } - selectedLang(): 'it' | 'en' | 'de' | 'fr' { + selectedLang(): SupportedLang { const activeLang = typeof this.translate.currentLang === 'string' ? this.translate.currentLang.toLowerCase() @@ -118,6 +123,16 @@ export class LanguageService { return `/${[lang, ...segments].join('/')}${suffix}`; } + setLocalizedRouteOverrides( + paths: LocalizedRouteOverrides | null | undefined, + ): void { + this.localizedRouteOverrides = this.normalizeLocalizedRouteOverrides(paths); + } + + clearLocalizedRouteOverrides(): void { + this.localizedRouteOverrides = null; + } + private ensureLanguageInPath(urlTree: UrlTree): void { const segments = this.getPrimarySegments(urlTree); @@ -126,23 +141,26 @@ export class LanguageService { return; } - const queryLang = this.getQueryLang(urlTree); - const activeLang = this.isSupportedLang(queryLang) - ? queryLang - : this.currentLang(); - if (activeLang !== this.currentLang()) { - this.applyLanguage(activeLang); - } - let targetSegments: string[]; - if (segments.length === 0) { - targetSegments = [activeLang]; - } else if (this.looksLikeLangToken(segments[0])) { - targetSegments = [activeLang, ...segments.slice(1)]; - } else { - targetSegments = [activeLang, ...segments]; + const queryLang = this.getQueryLang(urlTree); + const rootLang = this.isSupportedLang(queryLang) + ? queryLang + : this.currentLang(); + if (rootLang !== this.currentLang()) { + this.applyLanguage(rootLang); + } + this.navigateIfChanged(urlTree, [rootLang]); + return; } + if (this.currentLang() !== this.defaultLang) { + this.applyLanguage(this.defaultLang); + } + + const targetSegments = this.looksLikeLangToken(segments[0]) + ? [this.defaultLang, ...segments.slice(1)] + : [this.defaultLang, ...segments]; + this.navigateIfChanged(urlTree, targetSegments); } @@ -172,10 +190,10 @@ export class LanguageService { private isSupportedLang( lang: string | null | undefined, - ): lang is 'it' | 'en' | 'de' | 'fr' { + ): lang is SupportedLang { return ( typeof lang === 'string' && - this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr') + this.supportedLangs.includes(lang as SupportedLang) ); } @@ -185,7 +203,7 @@ export class LanguageService { ); } - private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void { + private applyLanguage(lang: SupportedLang): void { if (this.currentLang() === lang && this.translate.currentLang === lang) { return; } @@ -193,6 +211,86 @@ export class LanguageService { this.currentLang.set(lang); } + private resolveLocalizedRouteOverride( + currentTree: UrlTree, + lang: SupportedLang, + ): string | null { + const overrides = this.localizedRouteOverrides; + if (!overrides) { + return null; + } + + const currentPath = this.getCleanPath(this.router.serializeUrl(currentTree)); + const paths = Object.values(overrides) + .map((path) => this.normalizeLocalizedRoutePath(path)) + .filter((path): path is string => !!path); + if (!paths.includes(currentPath)) { + return null; + } + + return this.normalizeLocalizedRoutePath(overrides[lang]); + } + + private normalizeLocalizedRouteOverrides( + paths: LocalizedRouteOverrides | null | undefined, + ): LocalizedRouteOverrides | null { + if (!paths) { + return null; + } + + const normalized = this.supportedLangs.reduce( + (accumulator, lang) => { + const path = this.normalizeLocalizedRoutePath(paths[lang]); + if (path) { + accumulator[lang] = path; + } + return accumulator; + }, + {}, + ); + + return Object.keys(normalized).length > 0 ? normalized : null; + } + + private normalizeLocalizedRoutePath( + path: string | null | undefined, + ): string | null { + const rawPath = String(path ?? '').trim(); + if (!rawPath) { + return null; + } + const cleanPath = this.getCleanPath(rawPath); + return cleanPath.startsWith('/') ? cleanPath : null; + } + + private navigateToLocalizedRoute( + currentTree: UrlTree, + localizedPath: string, + ): void { + const { lang: _unusedLang, ...queryParams } = currentTree.queryParams; + const targetTree = this.router.createUrlTree( + ['/', ...localizedPath.split('/').filter(Boolean)], + { + queryParams, + fragment: currentTree.fragment ?? undefined, + }, + ); + + if ( + this.router.serializeUrl(targetTree) === + this.router.serializeUrl(currentTree) + ) { + return; + } + + this.router.navigateByUrl(targetTree, { replaceUrl: true }); + } + + private getCleanPath(url: string): string { + const path = (url || '/').split('?')[0].split('#')[0]; + return path || '/'; + } + private navigateIfChanged( currentTree: UrlTree, targetSegments: string[], diff --git a/frontend/src/app/core/services/seo.service.spec.ts b/frontend/src/app/core/services/seo.service.spec.ts index 62b279e..3a8775e 100644 --- a/frontend/src/app/core/services/seo.service.spec.ts +++ b/frontend/src/app/core/services/seo.service.spec.ts @@ -29,6 +29,7 @@ describe('SeoService', () => { data: Record; translations: Record; }): { + service: SeoService; meta: jasmine.SpyObj; title: jasmine.SpyObj; } { @@ -51,7 +52,7 @@ describe('SeoService', () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const service = new SeoService(router, title, meta, translate, document); - return { meta, title }; + return { service, meta, title }; } beforeEach(() => { @@ -137,4 +138,52 @@ describe('SeoService', () => { expect(descriptionCall?.[0].content).toBe('About description'); expect(document.documentElement.lang).toBe('en-CH'); }); + + it('applies canonical and hreflang values resolved from localized paths', () => { + const { service } = createService({ + url: '/it/shop/p/12345678-supporto-cavo-scrivania', + data: {}, + translations: {}, + }); + + service.applyResolvedSeo({ + title: 'Supporto cavo scrivania | 3D fab', + description: 'Accessorio tecnico', + robots: 'index, follow', + ogTitle: 'Supporto cavo scrivania | 3D fab', + ogDescription: 'Accessorio tecnico', + canonicalPath: '/it/shop/p/12345678-supporto-cavo-scrivania', + alternates: { + it: '/it/shop/p/12345678-supporto-cavo-scrivania', + en: '/en/shop/p/12345678-desk-cable-clip', + de: '/de/shop/p/12345678-schreibtisch-kabelhalter', + }, + xDefault: '/it/shop/p/12345678-supporto-cavo-scrivania', + }); + + const canonical = document.head.querySelector( + 'link[rel="canonical"]', + ) as HTMLLinkElement | null; + expect(canonical?.getAttribute('href')).toBe( + `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`, + ); + + const alternates = Array.from( + document.head.querySelectorAll( + 'link[rel="alternate"][data-seo-managed="true"]', + ), + ).map((node) => ({ + hreflang: node.getAttribute('hreflang'), + href: node.getAttribute('href'), + })); + + expect(alternates).toContain({ + hreflang: 'de-CH', + href: `${document.location.origin}/de/shop/p/12345678-schreibtisch-kabelhalter`, + }); + expect(alternates).toContain({ + hreflang: 'x-default', + href: `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`, + }); + }); }); diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index da7e35c..4e2974c 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -17,7 +17,13 @@ export interface PageSeoOverride { ogDescriptionKey?: string | null; } -type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +export interface ResolvedPageSeo extends PageSeoOverride { + canonicalPath: string | null; + alternates?: SeoMap | null; + xDefault?: string | null; +} + +export type SupportedLang = 'it' | 'en' | 'de' | 'fr'; type SeoMap = Partial<Record<SupportedLang, string>>; type SeoTextDataKey = | 'seoTitle' @@ -85,23 +91,10 @@ export class SeoService { applyPageSeo(override: PageSeoOverride): void { const cleanPath = this.getCleanPath(this.router.url); const lang = this.resolveLangFromPath(cleanPath); - const title = - this.resolveOverrideSeoText(override.title, override.titleKey) ?? - this.defaultTitle(lang); - const description = - this.resolveOverrideSeoText( - override.description, - override.descriptionKey, - ) ?? this.defaultDescription(lang); - const robots = this.asString(override.robots) ?? 'index, follow'; - const ogTitle = - this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ?? - title; - const ogDescription = - this.resolveOverrideSeoText( - override.ogDescription, - override.ogDescriptionKey, - ) ?? description; + const { title, description, robots, ogTitle, ogDescription } = + this.resolvePageSeoOverride(override, lang); + const canonicalPath = this.buildLocalizedPath(cleanPath, lang); + const alternates = this.buildAlternatePaths(canonicalPath); this.applySeoValues( title, @@ -110,6 +103,35 @@ export class SeoService { ogTitle, ogDescription, cleanPath, + canonicalPath, + alternates, + alternates.it ?? canonicalPath, + lang, + ); + } + + applyResolvedSeo(override: ResolvedPageSeo): void { + const cleanPath = this.getCleanPath(this.router.url); + const lang = this.resolveLangFromPath(cleanPath); + const { title, description, robots, ogTitle, ogDescription } = + this.resolvePageSeoOverride(override, lang); + const canonicalPath = this.normalizeSeoPath(override.canonicalPath); + const alternates = this.normalizeAlternatePaths(override.alternates); + const xDefault = + this.normalizeSeoPath(override.xDefault) ?? + alternates?.it ?? + canonicalPath; + + this.applySeoValues( + title, + description, + robots, + ogTitle, + ogDescription, + cleanPath, + canonicalPath, + alternates, + xDefault, lang, ); } @@ -128,6 +150,8 @@ export class SeoService { const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title; const ogDescription = this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description; + const canonicalPath = this.buildLocalizedPath(cleanPath, lang); + const alternates = this.buildAlternatePaths(canonicalPath); this.applySeoValues( title, @@ -136,6 +160,9 @@ export class SeoService { ogTitle, ogDescription, cleanPath, + canonicalPath, + alternates, + alternates.it ?? canonicalPath, lang, ); } @@ -147,6 +174,9 @@ export class SeoService { ogTitle: string, ogDescription: string, cleanPath: string, + canonicalPath: string | null, + alternates: SeoMap | null, + xDefaultPath: string | null, lang: SupportedLang, ): void { this.titleService.setTitle(title); @@ -166,12 +196,13 @@ export class SeoService { content: ogDescription, }); - const canonicalPath = this.buildLocalizedPath(cleanPath, lang); - const canonical = `${this.document.location.origin}${canonicalPath}`; - this.metaService.updateTag({ property: 'og:url', content: canonical }); - this.updateCanonicalTag(canonical); + const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath); + this.metaService.updateTag({ property: 'og:url', content: ogUrl }); + this.updateCanonicalTag( + canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null, + ); this.updateOpenGraphLocales(lang); - this.updateLangAndAlternates(canonicalPath, lang); + this.updateLangAndAlternates(alternates, xDefaultPath, lang); } private getMergedRouteData( @@ -197,6 +228,43 @@ export class SeoService { return this.asString(value) ?? this.resolveTranslation(key); } + private resolvePageSeoOverride( + override: PageSeoOverride, + lang: SupportedLang, + ): { + title: string; + description: string; + robots: string; + ogTitle: string; + ogDescription: string; + } { + const title = + this.resolveOverrideSeoText(override.title, override.titleKey) ?? + this.defaultTitle(lang); + const description = + this.resolveOverrideSeoText( + override.description, + override.descriptionKey, + ) ?? this.defaultDescription(lang); + const robots = this.asString(override.robots) ?? 'index, follow'; + const ogTitle = + this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ?? + title; + const ogDescription = + this.resolveOverrideSeoText( + override.ogDescription, + override.ogDescriptionKey, + ) ?? description; + + return { + title, + description, + robots, + ogTitle, + ogDescription, + }; + } + private resolveSeoText( routeData: Record<string, unknown>, key: SeoTextDataKey, @@ -281,10 +349,54 @@ export class SeoService { return `/${[lang, ...segments].join('/')}`; } - private updateCanonicalTag(url: string): void { + private buildAlternatePaths(canonicalPath: string): SeoMap { + const suffixSegments = canonicalPath.split('/').filter(Boolean).slice(1); + const suffix = + suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; + + return this.supportedLangs.reduce<SeoMap>((accumulator, alt) => { + accumulator[alt] = `/${alt}${suffix}`; + return accumulator; + }, {}); + } + + private normalizeAlternatePaths(paths: SeoMap | null | undefined): SeoMap | null { + if (!paths) { + return null; + } + + const normalized = this.supportedLangs.reduce<SeoMap>((accumulator, lang) => { + const path = this.normalizeSeoPath(paths[lang]); + if (path) { + accumulator[lang] = path; + } + return accumulator; + }, {}); + + return Object.keys(normalized).length > 0 ? normalized : null; + } + + private normalizeSeoPath(path: string | null | undefined): string | null { + const rawPath = String(path ?? '').trim(); + if (!rawPath) { + return null; + } + const normalized = this.getCleanPath(rawPath); + return normalized.startsWith('/') ? normalized : null; + } + + private toAbsoluteUrl(path: string): string { + return `${this.document.location.origin}${path}`; + } + + private updateCanonicalTag(url: string | null): void { let link = this.document.head.querySelector( 'link[rel="canonical"]', ) as HTMLLinkElement | null; + if (!url) { + link?.remove(); + return; + } if (!link) { link = this.document.createElement('link'); link.setAttribute('rel', 'canonical'); @@ -314,29 +426,30 @@ export class SeoService { } private updateLangAndAlternates( - localizedPath: string, + alternates: SeoMap | null, + xDefaultPath: string | null, lang: SupportedLang, ): void { - const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1); - const suffix = - suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; - this.document.documentElement.lang = this.seoLocaleByLang[lang]; this.document.head .querySelectorAll('link[rel="alternate"][data-seo-managed="true"]') .forEach((node) => node.remove()); - for (const alt of this.supportedLangs) { - this.appendAlternateLink( - this.seoLocaleByLang[alt], - `${this.document.location.origin}/${alt}${suffix}`, - ); + if (!alternates) { + return; + } + + for (const alt of this.supportedLangs) { + const path = alternates[alt]; + if (!path) { + continue; + } + this.appendAlternateLink(this.seoLocaleByLang[alt], this.toAbsoluteUrl(path)); + } + if (xDefaultPath) { + this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath)); } - this.appendAlternateLink( - 'x-default', - `${this.document.location.origin}/it${suffix}`, - ); } private appendAlternateLink(hreflang: string, href: string): void { diff --git a/frontend/src/app/features/shop/product-detail.component.ts b/frontend/src/app/features/shop/product-detail.component.ts index 0f023c3..e493865 100644 --- a/frontend/src/app/features/shop/product-detail.component.ts +++ b/frontend/src/app/features/shop/product-detail.component.ts @@ -1,5 +1,6 @@ import { CommonModule, Location, isPlatformBrowser } from '@angular/common'; import { + RESPONSE_INIT, afterNextRender, Component, DestroyRef, @@ -66,6 +67,7 @@ export class ProductDetailComponent { private readonly languageService = inject(LanguageService); private readonly shopRouteService = inject(ShopRouteService); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); readonly shopService = inject(ShopService); readonly categorySlug = input<string | undefined>(); @@ -198,6 +200,9 @@ export class ProductDetailComponent { afterNextRender(() => { this.scheduleCartWarmup(); }); + this.destroyRef.onDestroy(() => { + this.languageService.clearLocalizedRouteOverrides(); + }); combineLatest([ toObservable(this.productSlug, { injector: this.injector }), @@ -216,13 +221,17 @@ export class ProductDetailComponent { }), switchMap(([productSlug]) => { if (!productSlug) { + this.languageService.clearLocalizedRouteOverrides(); this.error.set('SHOP.NOT_FOUND'); + this.setResponseStatus(404); + this.applyFallbackSeo(); this.loading.set(false); return of(null); } return this.shopService.getProductByPublicPath(productSlug).pipe( catchError((error) => { + this.languageService.clearLocalizedRouteOverrides(); this.product.set(null); this.selectedVariantId.set(null); this.setSelectedImageAssetId(null); @@ -230,6 +239,9 @@ export class ProductDetailComponent { this.error.set( error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', ); + if (error?.status === 404) { + this.setResponseStatus(404); + } this.applyFallbackSeo(); return of(null); }), @@ -258,6 +270,7 @@ export class ProductDetailComponent { null, ); this.quantity.set(1); + this.languageService.setLocalizedRouteOverrides(product.localizedPaths); this.syncPublicUrl(product); this.applySeo(product); this.modelFile.set(null); @@ -554,25 +567,34 @@ export class ProductDetailComponent { this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); const robots = product.indexable === false ? 'noindex, nofollow' : 'index, follow'; + const lang = this.languageService.selectedLang(); + const canonicalPath = + product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null; - this.seoService.applyPageSeo({ + this.seoService.applyResolvedSeo({ title, description, robots, ogTitle: product.ogTitle || title, ogDescription: product.ogDescription || description, + canonicalPath, + alternates: product.localizedPaths, + xDefault: product.localizedPaths?.it ?? canonicalPath, }); } private applyFallbackSeo(): void { const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); - this.seoService.applyPageSeo({ + this.seoService.applyResolvedSeo({ title, description, - robots: 'index, follow', + robots: 'noindex, nofollow', ogTitle: title, ogDescription: description, + canonicalPath: null, + alternates: null, + xDefault: null, }); } @@ -747,21 +769,20 @@ export class ProductDetailComponent { return; } - const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? ''; - const targetProductSlug = this.shopRouteService.productPathSegment(product); - if (currentProductSlug === targetProductSlug) { + const currentTree = this.router.parseUrl(this.router.url); + const lang = this.languageService.selectedLang(); + const targetPath = + product.localizedPaths?.[lang] ?? + `/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`; + const normalizedTargetPath = + targetPath.startsWith('/') ? targetPath : `/${targetPath}`; + const currentPath = this.router.serializeUrl(currentTree).split(/[?#]/, 1)[0]; + if (currentPath === normalizedTargetPath) { return; } - const currentTree = this.router.parseUrl(this.router.url); const targetTree = this.router.createUrlTree( - [ - '/', - this.languageService.selectedLang(), - 'shop', - 'p', - targetProductSlug, - ], + ['/', ...normalizedTargetPath.split('/').filter(Boolean)], { queryParams: currentTree.queryParams, fragment: currentTree.fragment ?? undefined, @@ -780,4 +801,10 @@ export class ProductDetailComponent { state: history.state, }); } + + private setResponseStatus(status: number): void { + if (this.responseInit) { + this.responseInit.status = status; + } + } } diff --git a/frontend/src/app/features/shop/services/shop-route.service.ts b/frontend/src/app/features/shop/services/shop-route.service.ts index 849bb1f..3bcf5e8 100644 --- a/frontend/src/app/features/shop/services/shop-route.service.ts +++ b/frontend/src/app/features/shop/services/shop-route.service.ts @@ -1,15 +1,14 @@ import { Injectable } from '@angular/core'; import { LanguageService } from '../../../core/services/language.service'; +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; + export interface ShopProductRouteRef { id: string | null | undefined; name: string | null | undefined; slug?: string | null | undefined; -} - -export interface ShopProductLookup { - idPrefix: string | null; - slugHint: string | null; + publicPath?: string | null | undefined; + localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined; } @Injectable({ @@ -26,11 +25,21 @@ export class ShopRouteService { } productCommands(product: ShopProductRouteRef): string[] { + const localizedPath = this.localizedProductPath(product); + if (localizedPath) { + return ['/', ...localizedPath.split('/').filter(Boolean)]; + } + const lang = this.languageService.currentLang(); return ['/', lang, 'shop', 'p', this.productPathSegment(product)]; } productPathSegment(product: ShopProductRouteRef): string { + const publicPath = String(product.publicPath ?? '').trim(); + if (publicPath) { + return publicPath; + } + const idPrefix = this.productIdPrefix(product.id); const tail = this.slugify(product.name) || this.slugify(product.slug) || 'product'; @@ -38,41 +47,6 @@ export class ShopRouteService { return idPrefix ? `${idPrefix}-${tail}` : tail; } - resolveProductLookup( - productPathSegment: string | null | undefined, - ): ShopProductLookup { - const normalized = String(productPathSegment ?? '') - .trim() - .toLowerCase(); - if (!normalized) { - return { - idPrefix: null, - slugHint: null, - }; - } - - const bareUuidMatch = normalized.match(/^([0-9a-f]{8})$/); - if (bareUuidMatch) { - return { - idPrefix: bareUuidMatch[1], - slugHint: null, - }; - } - - const publicSlugMatch = normalized.match(/^([0-9a-f]{8})-(.+)$/); - if (publicSlugMatch) { - return { - idPrefix: publicSlugMatch[1], - slugHint: this.slugify(publicSlugMatch[2]) || null, - }; - } - - return { - idPrefix: null, - slugHint: normalized, - }; - } - isCatalogUrl(url: string | null | undefined): boolean { if (!url) { return false; @@ -92,6 +66,12 @@ export class ShopRouteService { .replace(/-{2,}/g, '-'); } + private localizedProductPath(product: ShopProductRouteRef): string | null { + const lang = this.languageService.currentLang(); + const localizedPath = String(product.localizedPaths?.[lang] ?? '').trim(); + return localizedPath.startsWith('/') ? localizedPath : null; + } + private productIdPrefix(productId: string | null | undefined): string { const normalized = String(productId ?? '') .trim() 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 cc22cf7..b55d3e4 100644 --- a/frontend/src/app/features/shop/services/shop.service.spec.ts +++ b/frontend/src/app/features/shop/services/shop.service.spec.ts @@ -112,6 +112,13 @@ describe('ShopService', () => { 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', + }, }, ], }); @@ -142,6 +149,13 @@ describe('ShopService', () => { primaryImage: null, images: [], 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', + }, }); beforeEach(() => { @@ -235,14 +249,15 @@ describe('ShopService', () => { expect(response?.name).toBe('Supporto cavo scrivania'); }); - it('resolves product detail from uuid prefix even when slug tail does not match', () => { - let response: ShopProductDetail | undefined; + it('rejects product paths whose slug tail does not match the canonical path', () => { + let errorResponse: { status?: number } | undefined; - service - .getProductByPublicPath('12345678-qualunque-nome') - .subscribe((product) => { - response = product; - }); + service.getProductByPublicPath('12345678-qualunque-nome').subscribe({ + next: () => fail('Expected canonical path mismatch to return 404'), + error: (error) => { + errorResponse = error; + }, + }); const catalogRequest = httpMock.expectOne((request) => { return ( @@ -253,24 +268,18 @@ describe('ShopService', () => { }); catalogRequest.flush(buildCatalog()); - const detailRequest = httpMock.expectOne((request) => { - return ( - request.method === 'GET' && - request.url === - 'http://localhost:8000/api/shop/products/desk-cable-clip' && - request.params.get('lang') === 'it' - ); - }); - detailRequest.flush(buildProduct()); - - expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); + httpMock.expectNone('http://localhost:8000/api/shop/products/desk-cable-clip'); + expect(errorResponse?.status).toBe(404); }); - it('resolves product detail from bare uuid prefix without slug tail', () => { - let response: ShopProductDetail | undefined; + it('rejects bare uuid product paths without the localized slug tail', () => { + let errorResponse: { status?: number } | undefined; - service.getProductByPublicPath('12345678').subscribe((product) => { - response = product; + service.getProductByPublicPath('12345678').subscribe({ + next: () => fail('Expected bare uuid path to return 404'), + error: (error) => { + errorResponse = error; + }, }); const catalogRequest = httpMock.expectOne((request) => { @@ -282,16 +291,7 @@ describe('ShopService', () => { }); catalogRequest.flush(buildCatalog()); - const detailRequest = httpMock.expectOne((request) => { - return ( - request.method === 'GET' && - request.url === - 'http://localhost:8000/api/shop/products/desk-cable-clip' && - request.params.get('lang') === 'it' - ); - }); - detailRequest.flush(buildProduct()); - - expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab'); + httpMock.expectNone('http://localhost:8000/api/shop/products/desk-cable-clip'); + 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 c30e823..58fc6f5 100644 --- a/frontend/src/app/features/shop/services/shop.service.ts +++ b/frontend/src/app/features/shop/services/shop.service.ts @@ -7,7 +7,9 @@ import { PublicMediaVariantDto, } from '../../../core/services/public-media.service'; import { LanguageService } from '../../../core/services/language.service'; -import { ShopRouteService } from './shop-route.service'; + +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +type LocalizedPathMap = Partial<Record<SupportedLang, string>>; export interface ShopCategoryRef { id: string; @@ -84,6 +86,8 @@ export interface ShopProductSummary { defaultVariant: ShopProductVariantOption | null; primaryImage: PublicMediaUsageDto | null; model3d: ShopProductModel | null; + publicPath: string; + localizedPaths: LocalizedPathMap; } export interface ShopProductDetail { @@ -108,6 +112,8 @@ export interface ShopProductDetail { primaryImage: PublicMediaUsageDto | null; images: PublicMediaUsageDto[]; model3d: ShopProductModel | null; + publicPath: string; + localizedPaths: LocalizedPathMap; } export interface ShopProductCatalogResponse { @@ -185,7 +191,6 @@ export interface ShopCategoryNavNode { export class ShopService { private readonly http = inject(HttpClient); private readonly languageService = inject(LanguageService); - private readonly shopRouteService = inject(ShopRouteService); private readonly apiUrl = `${environment.apiUrl}/api/shop`; readonly cart = signal<ShopCartResponse | null>(null); @@ -278,16 +283,17 @@ export class ShopService { getProductByPublicPath( productPathSegment: string, ): Observable<ShopProductDetail> { - const lookup = - this.shopRouteService.resolveProductLookup(productPathSegment); - if (!lookup.idPrefix && lookup.slugHint) { - return this.getProduct(lookup.slugHint); + const normalizedPath = this.normalizePublicPath(productPathSegment); + if (!normalizedPath) { + return throwError(() => ({ + status: 404, + })); } return this.getProductCatalog().pipe( map((catalog) => catalog.products.find((product) => - product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''), + this.normalizePublicPath(product.publicPath) === normalizedPath, ), ), switchMap((product) => { @@ -301,6 +307,12 @@ export class ShopService { ); } + private normalizePublicPath(value: string | null | undefined): string { + return String(value ?? '') + .trim() + .toLowerCase(); + } + loadCart(): Observable<ShopCartResponse> { this.cartLoading.set(true); return this.http diff --git a/frontend/src/app/features/shop/shop-page.component.ts b/frontend/src/app/features/shop/shop-page.component.ts index 5fac9b2..404611c 100644 --- a/frontend/src/app/features/shop/shop-page.component.ts +++ b/frontend/src/app/features/shop/shop-page.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { + RESPONSE_INIT, afterNextRender, Component, DestroyRef, @@ -60,6 +61,7 @@ export class ShopPageComponent { private readonly router = inject(Router); private readonly translate = inject(TranslateService); private readonly seoService = inject(SeoService); + private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); readonly languageService = inject(LanguageService); private readonly shopRouteService = inject(ShopRouteService); readonly shopService = inject(ShopService); @@ -118,7 +120,10 @@ export class ShopPageComponent { this.error.set( error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', ); - this.applyDefaultSeo(); + if (error?.status === 404) { + this.setResponseStatus(404); + } + this.applyErrorSeo(); return of(null); }), finalize(() => this.loading.set(false)), @@ -355,6 +360,28 @@ export class ShopPageComponent { }); } + private applyErrorSeo(): void { + const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`; + const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'); + + this.seoService.applyResolvedSeo({ + title, + description, + robots: 'noindex, nofollow', + ogTitle: title, + ogDescription: description, + canonicalPath: null, + alternates: null, + xDefault: null, + }); + } + + private setResponseStatus(status: number): void { + if (this.responseInit) { + this.responseInit.status = status; + } + } + private restoreCatalogScrollIfNeeded(): void { if (typeof window === 'undefined') { return; diff --git a/frontend/src/server-routing.spec.ts b/frontend/src/server-routing.spec.ts new file mode 100644 index 0000000..1baffb1 --- /dev/null +++ b/frontend/src/server-routing.spec.ts @@ -0,0 +1,47 @@ +import { resolvePublicRedirectTarget } from './server-routing'; + +describe('server routing redirects', () => { + it('does not force a fixed-language redirect for the root path', () => { + expect(resolvePublicRedirectTarget('/')).toBeNull(); + }); + + it('redirects unprefixed public pages to the default language', () => { + expect(resolvePublicRedirectTarget('/about')).toBe('/it/about'); + expect(resolvePublicRedirectTarget('/about/')).toBe('/it/about'); + }); + + it('redirects calculator paths directly to the canonical basic route', () => { + expect(resolvePublicRedirectTarget('/calculator')).toBe( + '/it/calculator/basic', + ); + expect(resolvePublicRedirectTarget('/it/calculator')).toBe( + '/it/calculator/basic', + ); + }); + + it('redirects legacy shop product aliases to the canonical product route', () => { + expect(resolvePublicRedirectTarget('/shop/accessories/desk-cable-clip')).toBe( + '/it/shop/p/desk-cable-clip', + ); + expect( + resolvePublicRedirectTarget('/de/shop/zubehor/schreibtisch-kabelhalter'), + ).toBe('/de/shop/p/schreibtisch-kabelhalter'); + }); + + it('drops unsupported language-like prefixes instead of nesting them', () => { + expect(resolvePublicRedirectTarget('/es/about')).toBe('/it/about'); + expect(resolvePublicRedirectTarget('/de-CH/about')).toBe('/it/about'); + }); + + it('normalizes supported language prefixes and trailing slashes', () => { + expect(resolvePublicRedirectTarget('/DE/about')).toBe('/de/about'); + expect(resolvePublicRedirectTarget('/it/about/')).toBe('/it/about'); + expect(resolvePublicRedirectTarget('/fr')).toBeNull(); + }); + + it('does not redirect static files and sitemap resources', () => { + expect(resolvePublicRedirectTarget('/assets/logo.svg')).toBeNull(); + expect(resolvePublicRedirectTarget('/robots.txt')).toBeNull(); + expect(resolvePublicRedirectTarget('/sitemap.xml')).toBeNull(); + }); +}); diff --git a/frontend/src/server-routing.ts b/frontend/src/server-routing.ts new file mode 100644 index 0000000..59c6601 --- /dev/null +++ b/frontend/src/server-routing.ts @@ -0,0 +1,103 @@ +const SUPPORTED_LANG_LIST = ['it', 'en', 'de', 'fr'] as const; + +export const SUPPORTED_LANGS = new Set<string>(SUPPORTED_LANG_LIST); +export const DEFAULT_LANG = 'it'; + +export function resolvePublicRedirectTarget(pathname: string): string | null { + const normalizedPath = normalizePathname(pathname); + if (shouldBypassRedirect(normalizedPath)) { + return null; + } + + const trimmedPath = + normalizedPath === '/' ? '/' : normalizedPath.replace(/\/+$/, ''); + const segments = splitSegments(trimmedPath); + if (segments.length === 0) { + return null; + } + + const firstSegment = segments[0].toLowerCase(); + if (SUPPORTED_LANGS.has(firstSegment)) { + const canonicalSegments = [firstSegment, ...segments.slice(1)]; + const canonicalPath = `/${canonicalSegments.join('/')}`; + const directRedirect = resolveCanonicalRedirect(canonicalSegments); + if (directRedirect) { + return directRedirect; + } + return canonicalPath === normalizedPath ? null : canonicalPath; + } + + const effectiveSegments = looksLikeLangToken(firstSegment) + ? segments.slice(1) + : segments; + if (effectiveSegments.length === 0) { + return `/${DEFAULT_LANG}`; + } + + const directRedirect = resolveCanonicalRedirect([ + DEFAULT_LANG, + ...effectiveSegments, + ]); + if (directRedirect) { + return directRedirect; + } + + return `/${[DEFAULT_LANG, ...effectiveSegments].join('/')}`; +} + +function resolveCanonicalRedirect(segments: string[]): string | null { + const [lang, section, thirdSegment, fourthSegment] = segments; + + if (section?.toLowerCase() === 'calculator' && segments.length === 2) { + return `/${lang}/calculator/basic`; + } + + if ( + section?.toLowerCase() === 'shop' && + segments.length === 4 && + thirdSegment?.toLowerCase() !== 'p' && + fourthSegment + ) { + return `/${lang}/shop/p/${fourthSegment}`; + } + + return null; +} + +function normalizePathname(pathname: string): string { + const rawValue = String(pathname || '/').trim(); + if (!rawValue) { + return '/'; + } + + return rawValue.startsWith('/') ? rawValue : `/${rawValue}`; +} + +function shouldBypassRedirect(pathname: string): boolean { + if ( + pathname.startsWith('/api/') || + pathname.startsWith('/assets/') || + pathname.startsWith('/media/') + ) { + return true; + } + + if ( + pathname === '/robots.txt' || + pathname === '/sitemap.xml' || + pathname === '/sitemap-static.xml' || + pathname === '/favicon.ico' + ) { + return true; + } + + return /\.[^/]+$/.test(pathname); +} + +function splitSegments(pathname: string): string[] { + return pathname.split('/').filter(Boolean); +} + +function looksLikeLangToken(segment: string | null | undefined): boolean { + return typeof segment === 'string' && /^[a-z]{2}(?:-[a-z]{2})?$/i.test(segment); +} diff --git a/frontend/src/server.ts b/frontend/src/server.ts index 04df896..c99614e 100644 --- a/frontend/src/server.ts +++ b/frontend/src/server.ts @@ -5,6 +5,11 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import bootstrap from './main.server'; import { resolveRequestOrigin } from './core/request-origin'; +import { + parseAcceptLanguage, + resolveInitialLanguage, +} from './app/core/i18n/language-resolution'; +import { resolvePublicRedirectTarget } from './server-routing'; const serverDistFolder = dirname(fileURLToPath(import.meta.url)); const browserDistFolder = resolve(serverDistFolder, '../browser'); @@ -36,6 +41,28 @@ app.get( }), ); +app.get('/', (req, res) => { + const acceptLanguage = req.get('accept-language'); + const preferredLanguages = parseAcceptLanguage(acceptLanguage); + const lang = resolveInitialLanguage({ + preferredLanguages, + }); + + res.setHeader('Vary', 'Accept-Language'); + res.setHeader('Cache-Control', 'private, no-store'); + res.redirect(302, `/${lang}${querySuffix(req.originalUrl)}`); +}); + +app.get('**', (req, res, next) => { + const targetPath = resolvePublicRedirectTarget(req.path); + if (!targetPath) { + next(); + return; + } + + res.redirect(308, `${targetPath}${querySuffix(req.originalUrl)}`); +}); + /** * Handle all other requests by rendering the Angular application. */ @@ -67,3 +94,8 @@ if (isMainModule(import.meta.url)) { } export default app; + +function querySuffix(url: string): string { + const queryIndex = String(url ?? '').indexOf('?'); + return queryIndex >= 0 ? String(url).slice(queryIndex) : ''; +}