feat/brand-logo #50
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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<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[],
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
frontend/src/server-routing.spec.ts
Normal file
47
frontend/src/server-routing.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
103
frontend/src/server-routing.ts
Normal file
103
frontend/src/server-routing.ts
Normal 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);
|
||||
}
|
||||
@@ -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) : '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user