feat(front-end): new logo edited
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 22s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Failing after 34s
PR Checks / test-frontend (pull_request) Successful in 1m1s

This commit is contained in:
2026-03-20 10:36:50 +01:00
parent 7e8c89ce45
commit e575021f53
18 changed files with 939 additions and 343 deletions

View File

@@ -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<ShopProductVariantOptionDto> variants,
PublicMediaUsageDto primaryImage,
List<PublicMediaUsageDto> images,
ShopProductModelDto model3d
ShopProductModelDto model3d,
String publicPath,
Map<String, String> localizedPaths
) {
}

View File

@@ -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<String, String> localizedPaths
) {
}

View File

@@ -399,6 +399,7 @@ public class PublicShopCatalogService {
Map<String, String> variantColorHexByMaterialAndColor,
String language) {
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
Map<String, String> 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<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
Map<String, String> 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;

View File

@@ -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<String, String> buildLocalizedProductPaths(ShopProduct product) {
Map<String, String> 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;
}
}

View File

@@ -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<String> 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<String, String> 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<String, String> 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(" <xhtml:link rel=\"alternate\" hreflang=\"")
.append(language)
.append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
.append("\" href=\"")
.append(xmlEscape(href))
.append("\" />\n");
@@ -186,48 +190,6 @@ public class ShopSitemapService {
xml.append(" </url>\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");

View File

@@ -2,40 +2,40 @@
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://3d-fab.ch/it</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/en</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/de</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
<changefreq>weekly</changefreq>
<priority>1.0</priority>
@@ -43,40 +43,40 @@
<url>
<loc>https://3d-fab.ch/it/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
<changefreq>weekly</changefreq>
<priority>0.9</priority>
@@ -84,40 +84,40 @@
<url>
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -125,40 +125,40 @@
<url>
<loc>https://3d-fab.ch/it/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/shop</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
<changefreq>weekly</changefreq>
<priority>0.8</priority>
@@ -166,40 +166,40 @@
<url>
<loc>https://3d-fab.ch/it/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/about</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -207,40 +207,40 @@
<url>
<loc>https://3d-fab.ch/it/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/contact</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
<changefreq>monthly</changefreq>
<priority>0.7</priority>
@@ -248,40 +248,40 @@
<url>
<loc>https://3d-fab.ch/it/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/privacy</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
@@ -289,40 +289,40 @@
<url>
<loc>https://3d-fab.ch/it/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/en/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/de/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://3d-fab.ch/fr/terms</loc>
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
<changefreq>yearly</changefreq>
<priority>0.4</priority>

View File

@@ -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');
});
});

View File

@@ -13,17 +13,17 @@ import {
} from '../i18n/language-resolution';
import { RequestLike } from '../../../core/request-origin';
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
@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<SupportedLang>('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;
}
if (segments.length === 0) {
const queryLang = this.getQueryLang(urlTree);
const activeLang = this.isSupportedLang(queryLang)
const rootLang = this.isSupportedLang(queryLang)
? queryLang
: this.currentLang();
if (activeLang !== this.currentLang()) {
this.applyLanguage(activeLang);
if (rootLang !== this.currentLang()) {
this.applyLanguage(rootLang);
}
this.navigateIfChanged(urlTree, [rootLang]);
return;
}
let targetSegments: string[];
if (segments.length === 0) {
targetSegments = [activeLang];
} else if (this.looksLikeLangToken(segments[0])) {
targetSegments = [activeLang, ...segments.slice(1)];
} else {
targetSegments = [activeLang, ...segments];
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<LocalizedRouteOverrides>(
(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[],

View File

@@ -29,6 +29,7 @@ describe('SeoService', () => {
data: Record<string, unknown>;
translations: Record<string, string>;
}): {
service: SeoService;
meta: jasmine.SpyObj<Meta>;
title: jasmine.SpyObj<Title>;
} {
@@ -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`,
});
});
});

View File

@@ -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 {

View File

@@ -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;
}
}
}

View File

@@ -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()

View File

@@ -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,13 +249,14 @@ 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) => {
@@ -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);
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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);
}

View File

@@ -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) : '';
}