Compare commits
39 Commits
7e8c89ce45
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2e249e94 | |||
| 132f0f3646 | |||
| 3cbcec5f53 | |||
|
|
8835175fb3 | ||
| 28c3abdb4a | |||
| b30bfc9293 | |||
| d70423fcc0 | |||
| 1b7c0c48e7 | |||
|
|
cb86137730 | ||
| c8913af660 | |||
| 9611049e01 | |||
| bad5947fb5 | |||
| d27558a3ee | |||
| 81f6f78c49 | |||
| bf593445bd | |||
| aa032c0140 | |||
|
|
95e60692c0 | ||
| fda2cdbecb | |||
| a1cc9f18c4 | |||
| 084d35d605 | |||
|
|
02aac24a09 | ||
| 51c2bf6985 | |||
| 4e99d12be1 | |||
| 8b5d8f92e0 | |||
| d3c9dd6eb9 | |||
| 254ff36c50 | |||
| b317196217 | |||
| cc343ee27c | |||
| 74d1b16b7c | |||
| adf6889712 | |||
| 653082868a | |||
| 997e770256 | |||
| fb1a6456e6 | |||
| 43cd80600e | |||
|
|
23e1abdbbb | ||
| e575021f53 | |||
|
|
41f36ed18a | ||
| e04189bbfe | |||
| 4ba408859d |
@@ -0,0 +1,88 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AllowedOriginService {
|
||||||
|
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
|
||||||
|
public AllowedOriginService(
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
|
||||||
|
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
|
||||||
|
) {
|
||||||
|
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
|
||||||
|
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
|
||||||
|
|
||||||
|
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
|
||||||
|
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredOrigins.isEmpty()) {
|
||||||
|
throw new IllegalStateException("At least one allowed origin must be configured.");
|
||||||
|
}
|
||||||
|
this.allowedOrigins = List.copyOf(configuredOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAllowedOrigins() {
|
||||||
|
return allowedOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed(String rawOriginOrUrl) {
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
if (normalizedOrigin == null) {
|
||||||
|
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
|
||||||
|
}
|
||||||
|
configuredOrigins.add(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRequestOrigin(String rawOriginOrUrl) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(rawOriginOrUrl.trim());
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (scheme == null || host == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||||
|
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||||
|
int port = uri.getPort();
|
||||||
|
if (isDefaultPort(normalizedScheme, port) || port < 0) {
|
||||||
|
return normalizedScheme + "://" + normalizedHost;
|
||||||
|
}
|
||||||
|
return normalizedScheme + "://" + normalizedHost + ":" + port;
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDefaultPort(String scheme, int port) {
|
||||||
|
return ("http".equals(scheme) && port == 80)
|
||||||
|
|| ("https".equals(scheme) && port == 443);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig {
|
||||||
|
|
||||||
@Override
|
@Bean
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||||
registry.addMapping("/**")
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
.allowedOrigins(
|
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||||
"http://localhost",
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
"http://localhost:4200",
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
"http://localhost:80",
|
configuration.setAllowCredentials(true);
|
||||||
"http://127.0.0.1",
|
configuration.setMaxAge(3600L);
|
||||||
"https://dev.3d-fab.ch",
|
|
||||||
"https://int.3d-fab.ch",
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
"https://3d-fab.ch"
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
)
|
return source;
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity http,
|
HttpSecurity http,
|
||||||
|
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
|||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||||
}))
|
}))
|
||||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-path/{publicPath}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-id-prefix/{idPrefix}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByIdPrefix(@PathVariable String idPrefix,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/products/{slug}/model")
|
@GetMapping("/products/{slug}/model")
|
||||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.printcalculator.dto;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductDetailDto(
|
public record ShopProductDetailDto(
|
||||||
@@ -25,6 +26,8 @@ public record ShopProductDetailDto(
|
|||||||
List<ShopProductVariantOptionDto> variants,
|
List<ShopProductVariantOptionDto> variants,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
List<PublicMediaUsageDto> images,
|
List<PublicMediaUsageDto> images,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductSummaryDto(
|
public record ShopProductSummaryDto(
|
||||||
@@ -15,6 +16,8 @@ public record ShopProductSummaryDto(
|
|||||||
BigDecimal priceToChf,
|
BigDecimal priceToChf,
|
||||||
ShopProductVariantOptionDto defaultVariant,
|
ShopProductVariantOptionDto defaultVariant,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.printcalculator.security;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
|
||||||
|
|
||||||
|
private final AllowedOriginService allowedOriginService;
|
||||||
|
|
||||||
|
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
|
||||||
|
this.allowedOriginService = allowedOriginService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String path = resolvePath(request);
|
||||||
|
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
|
||||||
|
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String origin = request.getHeader(HttpHeaders.ORIGIN);
|
||||||
|
String referer = request.getHeader(HttpHeaders.REFERER);
|
||||||
|
|
||||||
|
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolvePath(HttpServletRequest request) {
|
||||||
|
String path = request.getRequestURI();
|
||||||
|
String contextPath = request.getContextPath();
|
||||||
|
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||||
|
return path.substring(contextPath.length());
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,9 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String buildLogoUrl() {
|
private String buildLogoUrl() {
|
||||||
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
|
||||||
|
? "http://localhost:4200"
|
||||||
|
: frontendBaseUrl;
|
||||||
|
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,24 +126,62 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ShopProductDetailDto getProduct(String slug, String language) {
|
public ShopProductDetailDto getProduct(String slug, String language) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(language);
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesBySlug().get(slug),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ProductEntry entry = productContext.entriesBySlug().get(slug);
|
public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
|
||||||
if (entry == null) {
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
|
||||||
|
if (normalizedPublicPath == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
ShopCategory category = entry.product().getCategory();
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
}
|
productContext.entriesByPublicPath().get(normalizedPublicPath),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
return toProductDetailDto(
|
return toProductDetailDto(
|
||||||
entry,
|
entry,
|
||||||
productContext.productMediaBySlug(),
|
productContext.productMediaBySlug(),
|
||||||
productContext.variantColorHexByMaterialAndColor(),
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
language
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopProductDetailDto getProductByIdPrefix(String idPrefix, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedIdPrefix = normalizeProductIdPrefix(idPrefix);
|
||||||
|
if (normalizedIdPrefix == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesByIdPrefix().get(normalizedIdPrefix),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +235,7 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
||||||
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
||||||
@@ -207,8 +246,29 @@ public class PublicShopCatalogService {
|
|||||||
|
|
||||||
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
||||||
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizeProductIdPrefix(ShopPublicPathSupport.productIdPrefix(entry.product().getId())),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
|
||||||
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
|
return new PublicProductContext(
|
||||||
|
entries,
|
||||||
|
entriesBySlug,
|
||||||
|
entriesByPublicPath,
|
||||||
|
entriesByIdPrefix,
|
||||||
|
productMediaBySlug,
|
||||||
|
variantColorHexByMaterialAndColor
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> buildFilamentVariantColorHexMap() {
|
private Map<String, String> buildFilamentVariantColorHexMap() {
|
||||||
@@ -399,6 +459,9 @@ public class PublicShopCatalogService {
|
|||||||
Map<String, String> variantColorHexByMaterialAndColor,
|
Map<String, String> variantColorHexByMaterialAndColor,
|
||||||
String language) {
|
String language) {
|
||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
return new ShopProductSummaryDto(
|
return new ShopProductSummaryDto(
|
||||||
entry.product().getId(),
|
entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
@@ -415,7 +478,9 @@ public class PublicShopCatalogService {
|
|||||||
resolvePriceTo(entry.variants()),
|
resolvePriceTo(entry.variants()),
|
||||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +491,10 @@ public class PublicShopCatalogService {
|
|||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||||
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||||
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
||||||
return new ShopProductDetailDto(
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
entry.product().getId(),
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
|
return new ShopProductDetailDto(entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
entry.product().getNameForLanguage(language),
|
entry.product().getNameForLanguage(language),
|
||||||
entry.product().getExcerptForLanguage(language),
|
entry.product().getExcerptForLanguage(language),
|
||||||
@@ -453,7 +520,9 @@ public class PublicShopCatalogService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
images,
|
images,
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +575,36 @@ public class PublicShopCatalogService {
|
|||||||
return raw.toLowerCase(Locale.ROOT);
|
return raw.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
|
||||||
|
if (entry == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShopCategory category = entry.product().getCategory();
|
||||||
|
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePublicPathSegment(String publicPathSegment) {
|
||||||
|
String normalized = trimToNull(publicPathSegment);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeProductIdPrefix(String idPrefix) {
|
||||||
|
String normalized = trimToNull(idPrefix);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.matches("^[0-9a-f]{8}$") ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String trimToNull(String value) {
|
private String trimToNull(String value) {
|
||||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||||
if (raw.isEmpty()) {
|
if (raw.isEmpty()) {
|
||||||
@@ -514,6 +613,22 @@ public class PublicShopCatalogService {
|
|||||||
return raw;
|
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) {
|
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
|
||||||
if (entry.modelAsset() == null) {
|
if (entry.modelAsset() == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -585,6 +700,8 @@ public class PublicShopCatalogService {
|
|||||||
private record PublicProductContext(
|
private record PublicProductContext(
|
||||||
List<ProductEntry> entries,
|
List<ProductEntry> entries,
|
||||||
Map<String, ProductEntry> entriesBySlug,
|
Map<String, ProductEntry> entriesBySlug,
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath,
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix,
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.Normalizer;
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -19,7 +18,6 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -31,6 +29,12 @@ public class ShopSitemapService {
|
|||||||
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
|
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
|
||||||
private static final String DEFAULT_LANGUAGE = "it";
|
private static final String DEFAULT_LANGUAGE = "it";
|
||||||
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
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 ShopCategoryRepository shopCategoryRepository;
|
||||||
private final ShopProductRepository shopProductRepository;
|
private final ShopProductRepository shopProductRepository;
|
||||||
@@ -130,7 +134,7 @@ public class ShopSitemapService {
|
|||||||
|
|
||||||
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
|
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
|
||||||
for (String language : SUPPORTED_LANGUAGES) {
|
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));
|
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ public class ShopSitemapService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
|
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
|
||||||
.append(language)
|
.append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
|
||||||
.append("\" href=\"")
|
.append("\" href=\"")
|
||||||
.append(xmlEscape(href))
|
.append(xmlEscape(href))
|
||||||
.append("\" />\n");
|
.append("\" />\n");
|
||||||
@@ -186,48 +190,6 @@ public class ShopSitemapService {
|
|||||||
xml.append(" </url>\n");
|
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) {
|
private String pathEncodeSegment(String rawSegment) {
|
||||||
String safeSegment = rawSegment == null ? "" : rawSegment;
|
String safeSegment = rawSegment == null ? "" : rawSegment;
|
||||||
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
|
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
|
|||||||
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
||||||
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||||
|
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
|
||||||
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||||
openai.translation.api-key=${OPENAI_API_KEY:}
|
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||||
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.controller.admin.AdminAuthController;
|
import com.printcalculator.controller.admin.AdminAuthController;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(controllers = AdminAuthController.class)
|
@WebMvcTest(controllers = AdminAuthController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class
|
AdminLoginThrottleService.class
|
||||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminAuthSecurityTest {
|
class AdminAuthSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.1");
|
req.setRemoteAddr("10.0.0.1");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.2");
|
req.setRemoteAddr("10.0.0.2");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isTooManyRequests())
|
.andExpect(status().isTooManyRequests())
|
||||||
.andExpect(jsonPath("$.authenticated").value(false));
|
.andExpect(jsonPath("$.authenticated").value(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.30");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
|
||||||
|
mockMvc.perform(options("/api/admin/auth/login")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/auth/me"))
|
mockMvc.perform(get("/api/admin/auth/me"))
|
||||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.4");
|
req.setRemoteAddr("10.0.0.4");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminOrderControllerSecurityTest {
|
class AdminOrderControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -36,7 +39,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -49,6 +55,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminShopProductControllerSecurityTest {
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -61,11 +69,22 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
@Test
|
@Test
|
||||||
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_withAdminCookieAndMissingOrigin_shouldReturn403() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||||
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
@@ -82,6 +101,7 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
|
|
||||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
.cookie(loginAndExtractCookie())
|
.cookie(loginAndExtractCookie())
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("""
|
.content("""
|
||||||
{
|
{
|
||||||
@@ -107,6 +127,7 @@ class AdminShopProductControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.printcalculator.service.shop;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.ShopProductCatalogResponseDto;
|
||||||
|
import com.printcalculator.dto.ShopProductDetailDto;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.entity.ShopProduct;
|
||||||
|
import com.printcalculator.entity.ShopProductVariant;
|
||||||
|
import com.printcalculator.repository.FilamentVariantRepository;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductModelAssetRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductVariantRepository;
|
||||||
|
import com.printcalculator.service.media.PublicMediaQueryService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PublicShopCatalogServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductRepository shopProductRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductVariantRepository shopProductVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductModelAssetRepository shopProductModelAssetRepository;
|
||||||
|
@Mock
|
||||||
|
private FilamentVariantRepository filamentVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private PublicMediaQueryService publicMediaQueryService;
|
||||||
|
@Mock
|
||||||
|
private ShopStorageService shopStorageService;
|
||||||
|
|
||||||
|
private PublicShopCatalogService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new PublicShopCatalogService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
shopProductRepository,
|
||||||
|
shopProductVariantRepository,
|
||||||
|
shopProductModelAssetRepository,
|
||||||
|
filamentVariantRepository,
|
||||||
|
publicMediaQueryService,
|
||||||
|
shopStorageService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductCatalog_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
|
||||||
|
|
||||||
|
assertEquals(1, response.products().size());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProduct_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldResolveLocalizedSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByIdPrefix_shouldResolveLocalizedProduct() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByIdPrefix("12345678", "de");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/de/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(404, exception.getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
||||||
|
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
||||||
|
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
||||||
|
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
|
||||||
|
.thenReturn(List.of(variant));
|
||||||
|
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
|
||||||
|
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
|
||||||
|
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopCategory buildCategory() {
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
|
||||||
|
category.setSlug("accessori");
|
||||||
|
category.setName("Accessori");
|
||||||
|
category.setNameIt("Accessori");
|
||||||
|
category.setNameEn("Accessories");
|
||||||
|
category.setIsActive(true);
|
||||||
|
category.setSortOrder(0);
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProduct buildProduct(ShopCategory category) {
|
||||||
|
ShopProduct product = new ShopProduct();
|
||||||
|
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
|
||||||
|
product.setCategory(category);
|
||||||
|
product.setSlug("bike-wall-hanger");
|
||||||
|
product.setName("Bike Wall-Hanger");
|
||||||
|
product.setNameIt("Supporto bici");
|
||||||
|
product.setNameEn("Bike Wall-Hanger");
|
||||||
|
product.setIsActive(true);
|
||||||
|
product.setIsFeatured(true);
|
||||||
|
product.setSortOrder(0);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProductVariant buildVariant(ShopProduct product) {
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||||
|
variant.setProduct(product);
|
||||||
|
variant.setVariantLabel("PLA");
|
||||||
|
variant.setColorName("Grigio");
|
||||||
|
variant.setInternalMaterialCode("PLA");
|
||||||
|
variant.setPriceChf(new BigDecimal("29.90"));
|
||||||
|
variant.setIsActive(true);
|
||||||
|
variant.setIsDefault(true);
|
||||||
|
variant.setSortOrder(0);
|
||||||
|
return variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
|
|||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\""));
|
assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/accessori\""));
|
||||||
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
|
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
|
||||||
|
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
||||||
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
||||||
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
|
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
|
||||||
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
|
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
|
||||||
assertFalse(xml.contains("33333333-draft"));
|
assertFalse(xml.contains("33333333-draft"));
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ services:
|
|||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
environment:
|
||||||
|
- SSR_INTERNAL_API_ORIGIN=http://backend:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -2,81 +2,81 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it</loc>
|
<loc>https://3d-fab.ch/it</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en</loc>
|
<loc>https://3d-fab.ch/en</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de</loc>
|
<loc>https://3d-fab.ch/de</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr</loc>
|
<loc>https://3d-fab.ch/fr</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/calculator/basic</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/calculator/basic</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
@@ -84,40 +84,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="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" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
@@ -125,40 +125,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/shop</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/shop</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/shop</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/shop</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
@@ -166,40 +166,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/about</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/about</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/about</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/about</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
@@ -207,40 +207,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/contact</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/contact</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/contact</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/contact</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
@@ -248,40 +248,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/privacy</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/privacy</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/privacy</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/privacy</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
@@ -289,40 +289,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/terms</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/terms</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/terms</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/terms</loc>
|
<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="it-CH" 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="en-CH" 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="de-CH" 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="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
@if (siteIntroState() !== 'hidden') {
|
@if (siteIntroState() !== "hidden") {
|
||||||
<div
|
<div
|
||||||
class="site-intro"
|
class="site-intro"
|
||||||
[class.site-intro--closing]="siteIntroState() === 'closing'"
|
[class.site-intro--closing]="siteIntroState() === 'closing'"
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export function parseAcceptLanguage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const qualityParam = params.find((param) => param.startsWith('q='));
|
const qualityParam = params.find((param) => param.startsWith('q='));
|
||||||
const quality = qualityParam ? Number.parseFloat(qualityParam.slice(2)) : 1;
|
const quality = qualityParam
|
||||||
|
? Number.parseFloat(qualityParam.slice(2))
|
||||||
|
: 1;
|
||||||
return {
|
return {
|
||||||
tag: rawTag,
|
tag: rawTag,
|
||||||
quality: Number.isFinite(quality) ? quality : 0,
|
quality: Number.isFinite(quality) ? quality : 0,
|
||||||
@@ -70,7 +72,9 @@ export function parseAcceptLanguage(
|
|||||||
index: number;
|
index: number;
|
||||||
} => entry !== null && entry.quality > 0,
|
} => entry !== null && entry.quality > 0,
|
||||||
)
|
)
|
||||||
.sort((left, right) => right.quality - left.quality || left.index - right.index)
|
.sort(
|
||||||
|
(left, right) => right.quality - left.quality || left.index - right.index,
|
||||||
|
)
|
||||||
.map((entry) => entry.tag);
|
.map((entry) => entry.tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,9 +106,7 @@ function resolveExplicitLanguageFromUrl(
|
|||||||
const normalizedUrl = String(url ?? '/');
|
const normalizedUrl = String(url ?? '/');
|
||||||
const [pathAndQuery] = normalizedUrl.split('#', 1);
|
const [pathAndQuery] = normalizedUrl.split('#', 1);
|
||||||
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
|
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
|
||||||
const firstSegment = rawPath
|
const firstSegment = rawPath.split('/').filter(Boolean)[0];
|
||||||
.split('/')
|
|
||||||
.filter(Boolean)[0];
|
|
||||||
const pathLanguage = normalizeSupportedLanguage(firstSegment);
|
const pathLanguage = normalizeSupportedLanguage(firstSegment);
|
||||||
if (pathLanguage) {
|
if (pathLanguage) {
|
||||||
return pathLanguage;
|
return pathLanguage;
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
|||||||
const FALLBACK_LANG: SupportedLang = 'it';
|
const FALLBACK_LANG: SupportedLang = 'it';
|
||||||
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
|
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
|
||||||
|
|
||||||
const translationLoaders: Record<SupportedLang, () => Promise<TranslationObject>> = {
|
const translationLoaders: Record<
|
||||||
|
SupportedLang,
|
||||||
|
() => Promise<TranslationObject>
|
||||||
|
> = {
|
||||||
it: () =>
|
it: () =>
|
||||||
import('../../../assets/i18n/it.json').then(
|
import('../../../assets/i18n/it.json').then(
|
||||||
(module) => module.default as TranslationObject,
|
(module) => module.default as TranslationObject,
|
||||||
@@ -51,8 +54,9 @@ export class StaticTranslateLoader implements TranslateLoader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
|
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
|
||||||
const transferStateKey =
|
const transferStateKey = makeStateKey<TranslationObject>(
|
||||||
makeStateKey<TranslationObject>(`i18n:${lang.toLowerCase()}`);
|
`i18n:${lang.toLowerCase()}`,
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
isPlatformBrowser(this.platformId) &&
|
isPlatformBrowser(this.platformId) &&
|
||||||
this.transferState.hasKey(transferStateKey)
|
this.transferState.hasKey(transferStateKey)
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import { REQUEST } from '@angular/core';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||||
|
|
||||||
|
type TestGlobal = typeof globalThis & {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('serverOriginInterceptor', () => {
|
||||||
|
let http: HttpClient;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
const testGlobal = globalThis as TestGlobal;
|
||||||
|
const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
authorization: 'Basic dGVzdDp0ZXN0',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
if (originalInternalApiOrigin) {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = originalInternalApiOrigin;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => {
|
||||||
|
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite explicit request headers', () => {
|
||||||
|
http
|
||||||
|
.get('/api/shop/products', {
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer explicit-token',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Bearer explicit-token',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the internal SSR API origin for public shop discovery calls', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bypasses the public origin even when the proxy strips authorization on shop SSR requests', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps transactional shop API calls on the public origin', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
http.get('/api/shop/cart').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne('https://dev.3d-fab.ch/api/shop/cart');
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-shop pages on the public origin even for public shop APIs', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/checkout?session=abc',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,27 @@ import {
|
|||||||
resolveRequestOrigin,
|
resolveRequestOrigin,
|
||||||
} from '../../../core/request-origin';
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type ServerRequestLike = RequestLike & {
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORWARDED_REQUEST_HEADERS = [
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'accept-language',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SHOP_DISCOVERY_API_PATTERNS = [
|
||||||
|
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
|
||||||
|
/^\/api\/shop\/products$/i,
|
||||||
|
/^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i,
|
||||||
|
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
|
||||||
|
/^\/api\/shop\/products\/[^/?#]+$/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SHOP_PAGE_PATH_PATTERN = /^\/(?:it|en|de|fr)\/shop(?:\/.*)?$/i;
|
||||||
|
|
||||||
function isAbsoluteUrl(url: string): boolean {
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||||
}
|
}
|
||||||
@@ -14,17 +35,135 @@ function normalizeRelativePath(url: string): string {
|
|||||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripQueryAndHash(url: string): string {
|
||||||
|
return String(url ?? '').split(/[?#]/, 1)[0] || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(origin: string): string {
|
||||||
|
return origin.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestHeader(
|
||||||
|
request: RequestLike | null,
|
||||||
|
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||||
|
): string | null {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const headerValue =
|
||||||
|
request?.headers?.[normalizedName] ?? request?.get?.(normalizedName);
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestPath(request: ServerRequestLike | null): string | null {
|
||||||
|
const rawPath =
|
||||||
|
(typeof request?.originalUrl === 'string' && request.originalUrl) ||
|
||||||
|
(typeof request?.url === 'string' && request.url) ||
|
||||||
|
null;
|
||||||
|
if (!rawPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsoluteUrl(rawPath)) {
|
||||||
|
try {
|
||||||
|
return stripQueryAndHash(new URL(rawPath).pathname || '/');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicShopPageRequest(request: ServerRequestLike | null): boolean {
|
||||||
|
const requestPath = readRequestPath(request);
|
||||||
|
return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicShopDiscoveryApi(url: string): boolean {
|
||||||
|
const normalizedPath = stripQueryAndHash(normalizeRelativePath(url));
|
||||||
|
return SHOP_DISCOVERY_API_PATTERNS.some((pattern) =>
|
||||||
|
pattern.test(normalizedPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInternalApiOrigin(): string | null {
|
||||||
|
const globalObject = globalThis as {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const explicitOverride =
|
||||||
|
typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string'
|
||||||
|
? globalObject.__SSR_INTERNAL_API_ORIGIN__
|
||||||
|
: null;
|
||||||
|
const env = (
|
||||||
|
globalObject as {
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).process?.env;
|
||||||
|
const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN'];
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawValue.trim();
|
||||||
|
return normalized ? normalizeOrigin(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiOrigin(
|
||||||
|
request: ServerRequestLike | null,
|
||||||
|
relativeUrl: string,
|
||||||
|
): string | null {
|
||||||
|
const internalOrigin = readInternalApiOrigin();
|
||||||
|
if (
|
||||||
|
internalOrigin &&
|
||||||
|
isPublicShopPageRequest(request) &&
|
||||||
|
isPublicShopDiscoveryApi(relativeUrl)
|
||||||
|
) {
|
||||||
|
return internalOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveRequestOrigin(request);
|
||||||
|
}
|
||||||
|
|
||||||
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (isAbsoluteUrl(req.url)) {
|
if (isAbsoluteUrl(req.url)) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
const request = inject(REQUEST, {
|
||||||
const origin = resolveRequestOrigin(request);
|
optional: true,
|
||||||
|
}) as ServerRequestLike | null;
|
||||||
|
const origin = resolveApiOrigin(request, req.url);
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
|
||||||
return next(req.clone({ url: absoluteUrl }));
|
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||||
|
Record<string, string>
|
||||||
|
>((headers, name) => {
|
||||||
|
if (req.headers.has(name)) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = readRequestHeader(request, name);
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return next(
|
||||||
|
req.clone({
|
||||||
|
url: absoluteUrl,
|
||||||
|
setHeaders: forwardedHeaders,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,10 +22,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
<!-- Social Placeholders -->
|
<div class="social-link-row">
|
||||||
<div class="social-icon"></div>
|
<span class="social-name">Joe Küng:</span>
|
||||||
<div class="social-icon"></div>
|
<a
|
||||||
<div class="social-icon"></div>
|
class="social-icon-link"
|
||||||
|
href="https://www.linkedin.com/in/joe-k%C3%BCng-31831828b/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Joe Küng LinkedIn"
|
||||||
|
>
|
||||||
|
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="social-link-row">
|
||||||
|
<span class="social-name">Matteo Caletti:</span>
|
||||||
|
<a
|
||||||
|
class="social-icon-link"
|
||||||
|
href="https://www.linkedin.com/in/matteo-caletti-94291a3b6/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Matteo Caletti LinkedIn"
|
||||||
|
>
|
||||||
|
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -66,11 +66,69 @@
|
|||||||
|
|
||||||
.social {
|
.social {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.social-icon {
|
|
||||||
width: 24px;
|
.social-link-row {
|
||||||
height: 24px;
|
display: flex;
|
||||||
background-color: var(--color-neutral-800);
|
align-items: center;
|
||||||
border-radius: 50%;
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-name {
|
||||||
|
color: var(--color-neutral-200);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icon-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-neutral-50);
|
||||||
|
color: #0a66c2;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #0a66c2;
|
||||||
|
color: var(--color-neutral-50);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-secondary-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icon-linkedin {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.social {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link-row {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,5 +10,4 @@ import { FooterComponent } from './footer.component';
|
|||||||
templateUrl: './layout.component.html',
|
templateUrl: './layout.component.html',
|
||||||
styleUrl: './layout.component.scss',
|
styleUrl: './layout.component.scss',
|
||||||
})
|
})
|
||||||
export class LayoutComponent {
|
export class LayoutComponent {}
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navbar',
|
selector: 'app-navbar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule, NgOptimizedImage],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
TranslateModule,
|
||||||
|
NgOptimizedImage,
|
||||||
|
],
|
||||||
templateUrl: './navbar.component.html',
|
templateUrl: './navbar.component.html',
|
||||||
styleUrls: ['./navbar.component.scss'],
|
styleUrls: ['./navbar.component.scss'],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { Subject } from 'rxjs';
|
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 { TranslateService } from '@ngx-translate/core';
|
||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
import { RequestLike } from '../../../core/request-origin';
|
import { RequestLike } from '../../../core/request-origin';
|
||||||
@@ -61,7 +66,14 @@ describe('LanguageService', () => {
|
|||||||
parseUrl: (url: string) => serializer.parse(url),
|
parseUrl: (url: string) => serializer.parse(url),
|
||||||
createUrlTree,
|
createUrlTree,
|
||||||
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
|
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;
|
return router as unknown as Router;
|
||||||
@@ -91,7 +103,28 @@ describe('LanguageService', () => {
|
|||||||
expect(navOptions.replaceUrl).toBeTrue();
|
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 translate = createTranslateMock();
|
||||||
const router = createRouterMock('/calculator?session=abc');
|
const router = createRouterMock('/calculator?session=abc');
|
||||||
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
@@ -109,7 +142,7 @@ describe('LanguageService', () => {
|
|||||||
|
|
||||||
const firstCall = navigateSpy.calls.mostRecent();
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
const tree = firstCall.args[0] as UrlTree;
|
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', () => {
|
it('switches language while preserving path and query params', () => {
|
||||||
@@ -142,4 +175,23 @@ describe('LanguageService', () => {
|
|||||||
'/de/contact?topic=seo#form',
|
'/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';
|
} from '../i18n/language-resolution';
|
||||||
import { RequestLike } from '../../../core/request-origin';
|
import { RequestLike } from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class LanguageService {
|
export class LanguageService {
|
||||||
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
currentLang = signal<SupportedLang>('it');
|
||||||
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
|
private readonly defaultLang: SupportedLang = 'it';
|
||||||
'it',
|
private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
|
||||||
'en',
|
private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
|
||||||
'de',
|
|
||||||
'fr',
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
@@ -61,13 +61,21 @@ export class LanguageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
switchLang(lang: 'it' | 'en' | 'de' | 'fr') {
|
switchLang(lang: SupportedLang) {
|
||||||
if (!this.isSupportedLang(lang)) {
|
if (!this.isSupportedLang(lang)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.applyLanguage(lang);
|
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
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);
|
const segments = this.getPrimarySegments(currentTree);
|
||||||
|
|
||||||
let targetSegments: string[];
|
let targetSegments: string[];
|
||||||
@@ -85,7 +93,7 @@ export class LanguageService {
|
|||||||
this.navigateIfChanged(currentTree, targetSegments);
|
this.navigateIfChanged(currentTree, targetSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
|
selectedLang(): SupportedLang {
|
||||||
const activeLang =
|
const activeLang =
|
||||||
typeof this.translate.currentLang === 'string'
|
typeof this.translate.currentLang === 'string'
|
||||||
? this.translate.currentLang.toLowerCase()
|
? this.translate.currentLang.toLowerCase()
|
||||||
@@ -118,6 +126,16 @@ export class LanguageService {
|
|||||||
return `/${[lang, ...segments].join('/')}${suffix}`;
|
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 {
|
private ensureLanguageInPath(urlTree: UrlTree): void {
|
||||||
const segments = this.getPrimarySegments(urlTree);
|
const segments = this.getPrimarySegments(urlTree);
|
||||||
|
|
||||||
@@ -126,23 +144,26 @@ export class LanguageService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (segments.length === 0) {
|
||||||
const queryLang = this.getQueryLang(urlTree);
|
const queryLang = this.getQueryLang(urlTree);
|
||||||
const activeLang = this.isSupportedLang(queryLang)
|
const rootLang = this.isSupportedLang(queryLang)
|
||||||
? queryLang
|
? queryLang
|
||||||
: this.currentLang();
|
: this.currentLang();
|
||||||
if (activeLang !== this.currentLang()) {
|
if (rootLang !== this.currentLang()) {
|
||||||
this.applyLanguage(activeLang);
|
this.applyLanguage(rootLang);
|
||||||
|
}
|
||||||
|
this.navigateIfChanged(urlTree, [rootLang]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
let targetSegments: string[];
|
|
||||||
|
|
||||||
if (segments.length === 0) {
|
if (this.currentLang() !== this.defaultLang) {
|
||||||
targetSegments = [activeLang];
|
this.applyLanguage(this.defaultLang);
|
||||||
} else if (this.looksLikeLangToken(segments[0])) {
|
|
||||||
targetSegments = [activeLang, ...segments.slice(1)];
|
|
||||||
} else {
|
|
||||||
targetSegments = [activeLang, ...segments];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetSegments = this.looksLikeLangToken(segments[0])
|
||||||
|
? [this.defaultLang, ...segments.slice(1)]
|
||||||
|
: [this.defaultLang, ...segments];
|
||||||
|
|
||||||
this.navigateIfChanged(urlTree, targetSegments);
|
this.navigateIfChanged(urlTree, targetSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,10 +193,10 @@ export class LanguageService {
|
|||||||
|
|
||||||
private isSupportedLang(
|
private isSupportedLang(
|
||||||
lang: string | null | undefined,
|
lang: string | null | undefined,
|
||||||
): lang is 'it' | 'en' | 'de' | 'fr' {
|
): lang is SupportedLang {
|
||||||
return (
|
return (
|
||||||
typeof lang === 'string' &&
|
typeof lang === 'string' &&
|
||||||
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
|
this.supportedLangs.includes(lang as SupportedLang)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +206,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) {
|
if (this.currentLang() === lang && this.translate.currentLang === lang) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -193,6 +214,88 @@ export class LanguageService {
|
|||||||
this.currentLang.set(lang);
|
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(
|
private navigateIfChanged(
|
||||||
currentTree: UrlTree,
|
currentTree: UrlTree,
|
||||||
targetSegments: string[],
|
targetSegments: string[],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('SeoService', () => {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
translations: Record<string, string>;
|
translations: Record<string, string>;
|
||||||
}): {
|
}): {
|
||||||
|
service: SeoService;
|
||||||
meta: jasmine.SpyObj<Meta>;
|
meta: jasmine.SpyObj<Meta>;
|
||||||
title: jasmine.SpyObj<Title>;
|
title: jasmine.SpyObj<Title>;
|
||||||
} {
|
} {
|
||||||
@@ -51,7 +52,7 @@ describe('SeoService', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new SeoService(router, title, meta, translate, document);
|
const service = new SeoService(router, title, meta, translate, document);
|
||||||
|
|
||||||
return { meta, title };
|
return { service, meta, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -116,6 +117,34 @@ describe('SeoService', () => {
|
|||||||
expect(ogLocaleCall?.[0].content).toBe('it_CH');
|
expect(ogLocaleCall?.[0].content).toBe('it_CH');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the locale-adaptive root as x-default for home pages', () => {
|
||||||
|
createService({
|
||||||
|
url: '/de',
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
|
||||||
|
},
|
||||||
|
translations: {
|
||||||
|
'SEO.ROUTES.HOME.TITLE': '3D-Druck in Zürich | 3D fab',
|
||||||
|
'SEO.ROUTES.HOME.DESCRIPTION': '3D-Druckservice in Zürich',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'x-default',
|
||||||
|
href: `${document.location.origin}/`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves translated route metadata for the active language', () => {
|
it('resolves translated route metadata for the active language', () => {
|
||||||
const { meta, title } = createService({
|
const { meta, title } = createService({
|
||||||
url: '/en/about',
|
url: '/en/about',
|
||||||
@@ -137,4 +166,52 @@ describe('SeoService', () => {
|
|||||||
expect(descriptionCall?.[0].content).toBe('About description');
|
expect(descriptionCall?.[0].content).toBe('About description');
|
||||||
expect(document.documentElement.lang).toBe('en-CH');
|
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;
|
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 SeoMap = Partial<Record<SupportedLang, string>>;
|
||||||
type SeoTextDataKey =
|
type SeoTextDataKey =
|
||||||
| 'seoTitle'
|
| 'seoTitle'
|
||||||
@@ -85,23 +91,10 @@ export class SeoService {
|
|||||||
applyPageSeo(override: PageSeoOverride): void {
|
applyPageSeo(override: PageSeoOverride): void {
|
||||||
const cleanPath = this.getCleanPath(this.router.url);
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
const lang = this.resolveLangFromPath(cleanPath);
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
const title =
|
const { title, description, robots, ogTitle, ogDescription } =
|
||||||
this.resolveOverrideSeoText(override.title, override.titleKey) ??
|
this.resolvePageSeoOverride(override, lang);
|
||||||
this.defaultTitle(lang);
|
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||||
const description =
|
const alternates = this.buildAlternatePaths(canonicalPath);
|
||||||
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;
|
|
||||||
|
|
||||||
this.applySeoValues(
|
this.applySeoValues(
|
||||||
title,
|
title,
|
||||||
@@ -110,6 +103,34 @@ export class SeoService {
|
|||||||
ogTitle,
|
ogTitle,
|
||||||
ogDescription,
|
ogDescription,
|
||||||
cleanPath,
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates),
|
||||||
|
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) ??
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates);
|
||||||
|
|
||||||
|
this.applySeoValues(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots,
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
xDefault,
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,6 +149,8 @@ export class SeoService {
|
|||||||
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
||||||
const ogDescription =
|
const ogDescription =
|
||||||
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||||
|
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||||
|
const alternates = this.buildAlternatePaths(canonicalPath);
|
||||||
|
|
||||||
this.applySeoValues(
|
this.applySeoValues(
|
||||||
title,
|
title,
|
||||||
@@ -136,6 +159,9 @@ export class SeoService {
|
|||||||
ogTitle,
|
ogTitle,
|
||||||
ogDescription,
|
ogDescription,
|
||||||
cleanPath,
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates),
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -147,6 +173,9 @@ export class SeoService {
|
|||||||
ogTitle: string,
|
ogTitle: string,
|
||||||
ogDescription: string,
|
ogDescription: string,
|
||||||
cleanPath: string,
|
cleanPath: string,
|
||||||
|
canonicalPath: string | null,
|
||||||
|
alternates: SeoMap | null,
|
||||||
|
xDefaultPath: string | null,
|
||||||
lang: SupportedLang,
|
lang: SupportedLang,
|
||||||
): void {
|
): void {
|
||||||
this.titleService.setTitle(title);
|
this.titleService.setTitle(title);
|
||||||
@@ -166,12 +195,13 @@ export class SeoService {
|
|||||||
content: ogDescription,
|
content: ogDescription,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
|
||||||
const canonical = `${this.document.location.origin}${canonicalPath}`;
|
this.metaService.updateTag({ property: 'og:url', content: ogUrl });
|
||||||
this.metaService.updateTag({ property: 'og:url', content: canonical });
|
this.updateCanonicalTag(
|
||||||
this.updateCanonicalTag(canonical);
|
canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
|
||||||
|
);
|
||||||
this.updateOpenGraphLocales(lang);
|
this.updateOpenGraphLocales(lang);
|
||||||
this.updateLangAndAlternates(canonicalPath, lang);
|
this.updateLangAndAlternates(alternates, xDefaultPath, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMergedRouteData(
|
private getMergedRouteData(
|
||||||
@@ -197,6 +227,43 @@ export class SeoService {
|
|||||||
return this.asString(value) ?? this.resolveTranslation(key);
|
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(
|
private resolveSeoText(
|
||||||
routeData: Record<string, unknown>,
|
routeData: Record<string, unknown>,
|
||||||
key: SeoTextDataKey,
|
key: SeoTextDataKey,
|
||||||
@@ -281,10 +348,78 @@ export class SeoService {
|
|||||||
return `/${[lang, ...segments].join('/')}`;
|
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 buildXDefaultPath(
|
||||||
|
canonicalPath: string | null,
|
||||||
|
alternates: SeoMap | null,
|
||||||
|
): string | null {
|
||||||
|
if (canonicalPath && this.isLocalizedHomePath(canonicalPath)) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return alternates?.it ?? canonicalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLocalizedHomePath(path: string): boolean {
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
return (
|
||||||
|
segments.length === 1 &&
|
||||||
|
this.supportedLangSet.has(segments[0] as SupportedLang)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
let link = this.document.head.querySelector(
|
||||||
'link[rel="canonical"]',
|
'link[rel="canonical"]',
|
||||||
) as HTMLLinkElement | null;
|
) as HTMLLinkElement | null;
|
||||||
|
if (!url) {
|
||||||
|
link?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = this.document.createElement('link');
|
link = this.document.createElement('link');
|
||||||
link.setAttribute('rel', 'canonical');
|
link.setAttribute('rel', 'canonical');
|
||||||
@@ -314,30 +449,34 @@ export class SeoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateLangAndAlternates(
|
private updateLangAndAlternates(
|
||||||
localizedPath: string,
|
alternates: SeoMap | null,
|
||||||
|
xDefaultPath: string | null,
|
||||||
lang: SupportedLang,
|
lang: SupportedLang,
|
||||||
): void {
|
): 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.documentElement.lang = this.seoLocaleByLang[lang];
|
||||||
|
|
||||||
this.document.head
|
this.document.head
|
||||||
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
||||||
.forEach((node) => node.remove());
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
|
if (!alternates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const alt of this.supportedLangs) {
|
for (const alt of this.supportedLangs) {
|
||||||
this.appendAlternateLink(
|
const path = alternates[alt];
|
||||||
this.seoLocaleByLang[alt],
|
if (!path) {
|
||||||
`${this.document.location.origin}/${alt}${suffix}`,
|
continue;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.appendAlternateLink(
|
this.appendAlternateLink(
|
||||||
'x-default',
|
this.seoLocaleByLang[alt],
|
||||||
`${this.document.location.origin}/it${suffix}`,
|
this.toAbsoluteUrl(path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (xDefaultPath) {
|
||||||
|
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private appendAlternateLink(hreflang: string, href: string): void {
|
private appendAlternateLink(hreflang: string, href: string): void {
|
||||||
const link = this.document.createElement('link');
|
const link = this.document.createElement('link');
|
||||||
|
|||||||
@@ -18,10 +18,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="animation-stage" [attr.data-variant]="variant()">
|
||||||
class="animation-stage"
|
|
||||||
[attr.data-variant]="variant()"
|
|
||||||
>
|
|
||||||
<app-brand-animation-logo
|
<app-brand-animation-logo
|
||||||
[variant]="variant()"
|
[variant]="variant()"
|
||||||
[decorative]="false"
|
[decorative]="false"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
← {{ "SHOP.BACK" | translate }}
|
← {{ "SHOP.BACK" | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() || softFallbackActive()) {
|
||||||
<div class="detail-grid skeleton-grid">
|
<div class="detail-grid skeleton-grid">
|
||||||
<div class="skeleton-block"></div>
|
<div class="skeleton-block"></div>
|
||||||
<div class="skeleton-block"></div>
|
<div class="skeleton-block"></div>
|
||||||
|
|||||||
272
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
272
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { ShopProductDetail, ShopService } from './services/shop.service';
|
||||||
|
import { ProductDetailComponent } from './product-detail.component';
|
||||||
|
|
||||||
|
describe('ProductDetailComponent', () => {
|
||||||
|
function buildProduct(
|
||||||
|
overrides: Partial<ShopProductDetail> = {},
|
||||||
|
): ShopProductDetail {
|
||||||
|
return {
|
||||||
|
id: '91823f84-1111-2222-3333-444444444444',
|
||||||
|
slug: 'bike-wall-hanger',
|
||||||
|
name: 'Bike Wall-Hanger',
|
||||||
|
excerpt: 'Wall mount for bicycles',
|
||||||
|
description: '<p>Wall mount for bicycles</p>',
|
||||||
|
seoTitle: null,
|
||||||
|
seoDescription: null,
|
||||||
|
ogTitle: null,
|
||||||
|
ogDescription: null,
|
||||||
|
indexable: true,
|
||||||
|
isFeatured: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
category: {
|
||||||
|
id: 'category-1',
|
||||||
|
slug: 'bike-accessories',
|
||||||
|
name: 'Bike Accessories',
|
||||||
|
},
|
||||||
|
breadcrumbs: [],
|
||||||
|
priceFromChf: 29.9,
|
||||||
|
priceToChf: 29.9,
|
||||||
|
defaultVariant: {
|
||||||
|
id: 'variant-1',
|
||||||
|
sku: 'BW-1',
|
||||||
|
variantLabel: 'PLA',
|
||||||
|
colorName: 'Black',
|
||||||
|
colorLabel: 'Black',
|
||||||
|
colorHex: '#111111',
|
||||||
|
priceChf: 29.9,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'variant-1',
|
||||||
|
sku: 'BW-1',
|
||||||
|
variantLabel: 'PLA',
|
||||||
|
colorName: 'Black',
|
||||||
|
colorLabel: 'Black',
|
||||||
|
colorHex: '#111111',
|
||||||
|
priceChf: 29.9,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primaryImage: null,
|
||||||
|
images: [],
|
||||||
|
model3d: null,
|
||||||
|
publicPath: '91823f84-bike-wall-hanger',
|
||||||
|
localizedPaths: {
|
||||||
|
it: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||||
|
en: '/en/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
de: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
fr: '/fr/shop/p/91823f84-support-mural-velo',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComponent(
|
||||||
|
routerUrl = '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
options?: {
|
||||||
|
currentLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
selectedLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const responseInit: { status?: number } = {};
|
||||||
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
|
'applyResolvedSeo',
|
||||||
|
'applyPageSeo',
|
||||||
|
]);
|
||||||
|
const translate = jasmine.createSpyObj<TranslateService>(
|
||||||
|
'TranslateService',
|
||||||
|
['instant'],
|
||||||
|
);
|
||||||
|
translate.instant.and.callFake((key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'SHOP.TITLE': 'Technische Lösungen',
|
||||||
|
'SHOP.CATALOG_META_DESCRIPTION':
|
||||||
|
'Entdecken Sie technische 3D-Druck-Lösungen.',
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab',
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION':
|
||||||
|
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
|
||||||
|
options?.currentLang ?? 'de',
|
||||||
|
);
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: () => options?.selectedLang ?? currentLang(),
|
||||||
|
setLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
|
'setLocalizedRouteOverrides',
|
||||||
|
),
|
||||||
|
clearLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
|
'clearLocalizedRouteOverrides',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopService = {
|
||||||
|
cartLoaded: signal(false),
|
||||||
|
cartLoading: signal(false),
|
||||||
|
getProductByPublicPath: jasmine
|
||||||
|
.createSpy('getProductByPublicPath')
|
||||||
|
.and.returnValue(of(buildProduct())),
|
||||||
|
quantityForVariant: jasmine
|
||||||
|
.createSpy('quantityForVariant')
|
||||||
|
.and.returnValue(0),
|
||||||
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
|
resolveMediaUrl: jasmine
|
||||||
|
.createSpy('resolveMediaUrl')
|
||||||
|
.and.returnValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
url: routerUrl,
|
||||||
|
navigate: jasmine.createSpy('navigate'),
|
||||||
|
navigateByUrl: jasmine.createSpy('navigateByUrl'),
|
||||||
|
parseUrl: jasmine.createSpy('parseUrl'),
|
||||||
|
createUrlTree: jasmine.createSpy('createUrlTree'),
|
||||||
|
serializeUrl: jasmine.createSpy('serializeUrl'),
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
const activatedRoute = {
|
||||||
|
paramMap: of(
|
||||||
|
convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
|
||||||
|
),
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({
|
||||||
|
productSlug: '91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ProductDetailComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: SeoService, useValue: seoService },
|
||||||
|
{ provide: TranslateService, useValue: translate },
|
||||||
|
{ provide: LanguageService, useValue: languageService },
|
||||||
|
{ provide: ShopService, useValue: shopService },
|
||||||
|
{
|
||||||
|
provide: ShopRouteService,
|
||||||
|
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||||
|
'shopRootCommands',
|
||||||
|
'productPathSegment',
|
||||||
|
'isCatalogUrl',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||||
|
{
|
||||||
|
provide: Location,
|
||||||
|
useValue: jasmine.createSpyObj<Location>('Location', ['back']),
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture: ComponentFixture<ProductDetailComponent> =
|
||||||
|
TestBed.createComponent(ProductDetailComponent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: fixture.componentInstance,
|
||||||
|
seoService,
|
||||||
|
responseInit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('applies index follow SEO for indexable products', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct());
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Bike Wall-Hanger | 3D fab',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
alternates: buildProduct().localizedPaths,
|
||||||
|
xDefault: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the route language for canonical SEO even if the selected translation language lags', () => {
|
||||||
|
const { component, seoService } = createComponent(undefined, {
|
||||||
|
currentLang: 'de',
|
||||||
|
selectedLang: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct());
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct({ indexable: false }));
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a soft SSR fallback with 200 + index follow', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
|
(component as any).setResponseStatus(200);
|
||||||
|
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(200);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Bike Wall Hanger | 3D fab',
|
||||||
|
description:
|
||||||
|
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hard fallback noindex for missing products', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
|
(component as any).setResponseStatus(404);
|
||||||
|
(component as any).applyHardFallbackSeo();
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
RESPONSE_INIT,
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
@@ -7,16 +8,24 @@ import {
|
|||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
finalize,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import { findColorHex, getColorHex } from '../../core/constants/colors.const';
|
import { findColorHex } from '../../core/constants/colors.const';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
@@ -26,6 +35,7 @@ import {
|
|||||||
ShopService,
|
ShopService,
|
||||||
} from './services/shop.service';
|
} from './services/shop.service';
|
||||||
import { ShopRouteService } from './services/shop-route.service';
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||||
|
|
||||||
interface ShopMaterialOption {
|
interface ShopMaterialOption {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -60,18 +70,22 @@ export class ProductDetailComponent {
|
|||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
private readonly location = inject(Location);
|
private readonly location = inject(Location);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
private readonly shopRouteService = inject(ShopRouteService);
|
||||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
readonly productSlug = input<string | undefined>();
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
|
readonly softFallbackActive = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly product = signal<ShopProductDetail | null>(null);
|
readonly product = signal<ShopProductDetail | null>(null);
|
||||||
readonly selectedVariantId = signal<string | null>(null);
|
readonly selectedVariantId = signal<string | null>(null);
|
||||||
@@ -198,39 +212,73 @@ export class ProductDetailComponent {
|
|||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
this.scheduleCartWarmup();
|
this.scheduleCartWarmup();
|
||||||
});
|
});
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
|
});
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.productSlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => ({
|
||||||
|
categorySlug: this.normalizeRouteParam(params.get('categorySlug')),
|
||||||
|
productSlug: this.normalizeRouteParam(params.get('productSlug')),
|
||||||
|
})),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(previous, current) =>
|
||||||
|
previous.categorySlug === current.categorySlug &&
|
||||||
|
previous.productSlug === current.productSlug,
|
||||||
|
),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.addSuccess.set(false);
|
this.addSuccess.set(false);
|
||||||
this.modelError.set(false);
|
this.modelError.set(false);
|
||||||
this.colorPopupOpen.set(false);
|
this.colorPopupOpen.set(false);
|
||||||
this.modelModalOpen.set(false);
|
this.modelModalOpen.set(false);
|
||||||
}),
|
}),
|
||||||
switchMap(([productSlug]) => {
|
switchMap(([routeParams]) => {
|
||||||
if (!productSlug) {
|
this.routeCategorySlug.set(routeParams.categorySlug);
|
||||||
|
if (!routeParams.productSlug) {
|
||||||
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.error.set('SHOP.NOT_FOUND');
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
|
this.setResponseStatus(404);
|
||||||
|
this.applyHardFallbackSeo();
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const productSlug = routeParams.productSlug as string;
|
||||||
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.selectedVariantId.set(null);
|
this.selectedVariantId.set(null);
|
||||||
this.setSelectedImageAssetId(null);
|
this.setSelectedImageAssetId(null);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
this.error.set(
|
const isNotFound = error?.status === 404;
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
if (isNotFound) {
|
||||||
);
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
this.applyFallbackSeo();
|
this.setResponseStatus(404);
|
||||||
|
this.applyHardFallbackSeo();
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldUseSoftSeoFallback(error)) {
|
||||||
|
this.error.set(null);
|
||||||
|
this.softFallbackActive.set(true);
|
||||||
|
this.setResponseStatus(200);
|
||||||
|
this.applySoftFallbackSeo(productSlug);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('SHOP.LOAD_ERROR');
|
||||||
|
this.setResponseStatus(503);
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
finalize(() => this.loading.set(false)),
|
finalize(() => this.loading.set(false)),
|
||||||
@@ -244,6 +292,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product.set(product);
|
this.product.set(product);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
this.selectedVariantId.set(
|
this.selectedVariantId.set(
|
||||||
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
||||||
);
|
);
|
||||||
@@ -258,6 +307,7 @@ export class ProductDetailComponent {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
this.quantity.set(1);
|
this.quantity.set(1);
|
||||||
|
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
|
||||||
this.syncPublicUrl(product);
|
this.syncPublicUrl(product);
|
||||||
this.applySeo(product);
|
this.applySeo(product);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
@@ -479,7 +529,8 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
productLinkRoot(): string[] {
|
productLinkRoot(): string[] {
|
||||||
const categorySlug = this.product()?.category.slug || this.categorySlug();
|
const categorySlug =
|
||||||
|
this.product()?.category.slug || this.routeCategorySlug();
|
||||||
return this.shopRouteService.shopRootCommands(categorySlug);
|
return this.shopRouteService.shopRootCommands(categorySlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,28 +605,86 @@ export class ProductDetailComponent {
|
|||||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
const robots =
|
const robots =
|
||||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||||
|
const lang = this.languageService.currentLang();
|
||||||
|
const canonicalPath =
|
||||||
|
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
||||||
|
|
||||||
this.seoService.applyPageSeo({
|
this.seoService.applyResolvedSeo({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
robots,
|
robots,
|
||||||
ogTitle: product.ogTitle || title,
|
ogTitle: product.ogTitle || title,
|
||||||
ogDescription: product.ogDescription || description,
|
ogDescription: product.ogDescription || description,
|
||||||
|
canonicalPath,
|
||||||
|
alternates: product.localizedPaths,
|
||||||
|
xDefault: product.localizedPaths?.it ?? canonicalPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFallbackSeo(): void {
|
private applyHardFallbackSeo(): void {
|
||||||
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
||||||
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
this.seoService.applyPageSeo({
|
this.seoService.applyResolvedSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
ogTitle: title,
|
||||||
|
ogDescription: description,
|
||||||
|
canonicalPath: null,
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySoftFallbackSeo(productSlug: string): void {
|
||||||
|
const title = this.buildSoftFallbackTitle(productSlug);
|
||||||
|
const description = this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||||
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.seoService.applyResolvedSeo({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
robots: 'index, follow',
|
robots: 'index, follow',
|
||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
|
canonicalPath: this.currentPath(),
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
|
||||||
|
return !this.isBrowser && error?.status !== 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSoftFallbackTitle(productSlug: string): string {
|
||||||
|
const humanized = humanizeShopSlug(productSlug, {
|
||||||
|
stripProductIdPrefix: true,
|
||||||
|
});
|
||||||
|
if (humanized) {
|
||||||
|
return `${humanized} | 3D fab`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||||
|
`${this.translate.instant('SHOP.TITLE')} | 3D fab`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTranslatedText(key: string, fallback: string): string {
|
||||||
|
const translated = this.translate.instant(key);
|
||||||
|
return typeof translated === 'string' && translated !== key
|
||||||
|
? translated
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPath(): string {
|
||||||
|
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
private materialLabelForVariant(
|
private materialLabelForVariant(
|
||||||
variant: ShopProductVariantOption | null,
|
variant: ShopProductVariantOption | null,
|
||||||
): string {
|
): string {
|
||||||
@@ -747,21 +856,23 @@ export class ProductDetailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
|
const currentTree = this.router.parseUrl(this.router.url);
|
||||||
const targetProductSlug = this.shopRouteService.productPathSegment(product);
|
const lang = this.languageService.currentLang();
|
||||||
if (currentProductSlug === targetProductSlug) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
|
||||||
const targetTree = this.router.createUrlTree(
|
const targetTree = this.router.createUrlTree(
|
||||||
[
|
['/', ...normalizedTargetPath.split('/').filter(Boolean)],
|
||||||
'/',
|
|
||||||
this.languageService.selectedLang(),
|
|
||||||
'shop',
|
|
||||||
'p',
|
|
||||||
targetProductSlug,
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
queryParams: currentTree.queryParams,
|
queryParams: currentTree.queryParams,
|
||||||
fragment: currentTree.fragment ?? undefined,
|
fragment: currentTree.fragment ?? undefined,
|
||||||
@@ -780,4 +891,19 @@ export class ProductDetailComponent {
|
|||||||
state: history.state,
|
state: history.state,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setResponseStatus(status: number): void {
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRouteParam(name: string): string | null {
|
||||||
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteParam(value: string | null | undefined): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { LanguageService } from '../../../core/services/language.service';
|
import { LanguageService } from '../../../core/services/language.service';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
export interface ShopProductRouteRef {
|
export interface ShopProductRouteRef {
|
||||||
id: string | null | undefined;
|
id: string | null | undefined;
|
||||||
name: string | null | undefined;
|
name: string | null | undefined;
|
||||||
slug?: string | null | undefined;
|
slug?: string | null | undefined;
|
||||||
}
|
publicPath?: string | null | undefined;
|
||||||
|
localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined;
|
||||||
export interface ShopProductLookup {
|
|
||||||
idPrefix: string | null;
|
|
||||||
slugHint: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -26,11 +25,21 @@ export class ShopRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
productCommands(product: ShopProductRouteRef): string[] {
|
productCommands(product: ShopProductRouteRef): string[] {
|
||||||
|
const localizedPath = this.localizedProductPath(product);
|
||||||
|
if (localizedPath) {
|
||||||
|
return ['/', ...localizedPath.split('/').filter(Boolean)];
|
||||||
|
}
|
||||||
|
|
||||||
const lang = this.languageService.currentLang();
|
const lang = this.languageService.currentLang();
|
||||||
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
|
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
|
||||||
}
|
}
|
||||||
|
|
||||||
productPathSegment(product: ShopProductRouteRef): string {
|
productPathSegment(product: ShopProductRouteRef): string {
|
||||||
|
const publicPath = String(product.publicPath ?? '').trim();
|
||||||
|
if (publicPath) {
|
||||||
|
return publicPath;
|
||||||
|
}
|
||||||
|
|
||||||
const idPrefix = this.productIdPrefix(product.id);
|
const idPrefix = this.productIdPrefix(product.id);
|
||||||
const tail =
|
const tail =
|
||||||
this.slugify(product.name) || this.slugify(product.slug) || 'product';
|
this.slugify(product.name) || this.slugify(product.slug) || 'product';
|
||||||
@@ -38,41 +47,6 @@ export class ShopRouteService {
|
|||||||
return idPrefix ? `${idPrefix}-${tail}` : tail;
|
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 {
|
isCatalogUrl(url: string | null | undefined): boolean {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return false;
|
return false;
|
||||||
@@ -92,6 +66,12 @@ export class ShopRouteService {
|
|||||||
.replace(/-{2,}/g, '-');
|
.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 {
|
private productIdPrefix(productId: string | null | undefined): string {
|
||||||
const normalized = String(productId ?? '')
|
const normalized = String(productId ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
@@ -5,7 +6,6 @@ import {
|
|||||||
} from '@angular/common/http/testing';
|
} from '@angular/common/http/testing';
|
||||||
import {
|
import {
|
||||||
ShopCartResponse,
|
ShopCartResponse,
|
||||||
ShopProductCatalogResponse,
|
|
||||||
ShopProductDetail,
|
ShopProductDetail,
|
||||||
ShopService,
|
ShopService,
|
||||||
} from './shop.service';
|
} from './shop.service';
|
||||||
@@ -14,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
|
|||||||
describe('ShopService', () => {
|
describe('ShopService', () => {
|
||||||
let service: ShopService;
|
let service: ShopService;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
|
||||||
|
};
|
||||||
|
|
||||||
const buildCart = (): ShopCartResponse => ({
|
const buildCart = (): ShopCartResponse => ({
|
||||||
session: {
|
session: {
|
||||||
@@ -90,32 +95,6 @@ describe('ShopService', () => {
|
|||||||
grandTotalChf: 36.8,
|
grandTotalChf: 36.8,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildCatalog = (): ShopProductCatalogResponse => ({
|
|
||||||
categorySlug: null,
|
|
||||||
featuredOnly: false,
|
|
||||||
category: null,
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
|
||||||
slug: 'desk-cable-clip',
|
|
||||||
name: 'Supporto cavo scrivania',
|
|
||||||
excerpt: 'Accessorio tecnico',
|
|
||||||
isFeatured: true,
|
|
||||||
sortOrder: 0,
|
|
||||||
category: {
|
|
||||||
id: 'category-1',
|
|
||||||
slug: 'accessori',
|
|
||||||
name: 'Accessori',
|
|
||||||
},
|
|
||||||
priceFromChf: 9.9,
|
|
||||||
priceToChf: 12.5,
|
|
||||||
defaultVariant: null,
|
|
||||||
primaryImage: null,
|
|
||||||
model3d: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildProduct = (): ShopProductDetail => ({
|
const buildProduct = (): ShopProductDetail => ({
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
||||||
slug: 'desk-cable-clip',
|
slug: 'desk-cable-clip',
|
||||||
@@ -142,6 +121,13 @@ describe('ShopService', () => {
|
|||||||
primaryImage: null,
|
primaryImage: null,
|
||||||
images: [],
|
images: [],
|
||||||
model3d: 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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -151,13 +137,14 @@ describe('ShopService', () => {
|
|||||||
ShopService,
|
ShopService,
|
||||||
{
|
{
|
||||||
provide: LanguageService,
|
provide: LanguageService,
|
||||||
useValue: {
|
useValue: languageService,
|
||||||
selectedLang: () => 'it',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
currentLang.set('it');
|
||||||
|
languageService.selectedLang.and.returnValue('it');
|
||||||
|
|
||||||
service = TestBed.inject(ShopService);
|
service = TestBed.inject(ShopService);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
@@ -212,85 +199,89 @@ describe('ShopService', () => {
|
|||||||
response = product;
|
response = product;
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detailRequest.flush(buildProduct());
|
request.flush(buildProduct());
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves product detail from uuid prefix even when slug tail does not match', () => {
|
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
|
||||||
let response: ShopProductDetail | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
||||||
|
next: (product) => {
|
||||||
|
response = product;
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected stale slug tails to resolve from the uuid prefix'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((request) => {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
|
request.params.get('lang') === 'it'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.flush(buildProduct());
|
||||||
|
|
||||||
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
|
||||||
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
service.getProductByPublicPath('12345678').subscribe({
|
||||||
|
next: (product) => {
|
||||||
|
response = product;
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected bare uuid path to resolve from the uuid prefix'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((request) => {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
|
request.params.get('lang') === 'it'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.flush(buildProduct());
|
||||||
|
|
||||||
|
expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the route language for public shop lookups when translate.currentLang lags behind', () => {
|
||||||
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
currentLang.set('de');
|
||||||
|
languageService.selectedLang.and.returnValue('en');
|
||||||
|
|
||||||
service
|
service
|
||||||
.getProductByPublicPath('12345678-qualunque-nome')
|
.getProductByPublicPath('12345678-schreibtisch-kabelhalter')
|
||||||
.subscribe((product) => {
|
.subscribe((product) => {
|
||||||
response = product;
|
response = product;
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'de'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detailRequest.flush(buildProduct());
|
request.flush(buildProduct());
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves product detail from bare uuid prefix without slug tail', () => {
|
|
||||||
let response: ShopProductDetail | undefined;
|
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678').subscribe((product) => {
|
|
||||||
response = product;
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
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');
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { map, Observable, switchMap, tap, throwError } from 'rxjs';
|
import { map, Observable, tap, throwError } from 'rxjs';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
PublicMediaUsageDto,
|
PublicMediaUsageDto,
|
||||||
PublicMediaVariantDto,
|
PublicMediaVariantDto,
|
||||||
} from '../../../core/services/public-media.service';
|
} from '../../../core/services/public-media.service';
|
||||||
import { LanguageService } from '../../../core/services/language.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 {
|
export interface ShopCategoryRef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,6 +86,8 @@ export interface ShopProductSummary {
|
|||||||
defaultVariant: ShopProductVariantOption | null;
|
defaultVariant: ShopProductVariantOption | null;
|
||||||
primaryImage: PublicMediaUsageDto | null;
|
primaryImage: PublicMediaUsageDto | null;
|
||||||
model3d: ShopProductModel | null;
|
model3d: ShopProductModel | null;
|
||||||
|
publicPath: string;
|
||||||
|
localizedPaths: LocalizedPathMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopProductDetail {
|
export interface ShopProductDetail {
|
||||||
@@ -108,6 +112,8 @@ export interface ShopProductDetail {
|
|||||||
primaryImage: PublicMediaUsageDto | null;
|
primaryImage: PublicMediaUsageDto | null;
|
||||||
images: PublicMediaUsageDto[];
|
images: PublicMediaUsageDto[];
|
||||||
model3d: ShopProductModel | null;
|
model3d: ShopProductModel | null;
|
||||||
|
publicPath: string;
|
||||||
|
localizedPaths: LocalizedPathMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopProductCatalogResponse {
|
export interface ShopProductCatalogResponse {
|
||||||
@@ -185,7 +191,6 @@ export interface ShopCategoryNavNode {
|
|||||||
export class ShopService {
|
export class ShopService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
|
||||||
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
|
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
|
||||||
|
|
||||||
readonly cart = signal<ShopCartResponse | null>(null);
|
readonly cart = signal<ShopCartResponse | null>(null);
|
||||||
@@ -278,27 +283,32 @@ export class ShopService {
|
|||||||
getProductByPublicPath(
|
getProductByPublicPath(
|
||||||
productPathSegment: string,
|
productPathSegment: string,
|
||||||
): Observable<ShopProductDetail> {
|
): Observable<ShopProductDetail> {
|
||||||
const lookup =
|
const normalizedPath = this.normalizePublicPath(productPathSegment);
|
||||||
this.shopRouteService.resolveProductLookup(productPathSegment);
|
if (!normalizedPath) {
|
||||||
if (!lookup.idPrefix && lookup.slugHint) {
|
|
||||||
return this.getProduct(lookup.slugHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.getProductCatalog().pipe(
|
|
||||||
map((catalog) =>
|
|
||||||
catalog.products.find((product) =>
|
|
||||||
product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
switchMap((product) => {
|
|
||||||
if (!product) {
|
|
||||||
return throwError(() => ({
|
return throwError(() => ({
|
||||||
status: 404,
|
status: 404,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
return this.getProduct(product.slug);
|
|
||||||
}),
|
const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
|
||||||
);
|
const endpoint = productIdPrefix
|
||||||
|
? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}`
|
||||||
|
: `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`;
|
||||||
|
|
||||||
|
return this.http.get<ShopProductDetail>(endpoint, {
|
||||||
|
params: this.buildLangParams(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizePublicPath(value: string | null | undefined): string {
|
||||||
|
return String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractProductIdPrefix(value: string): string | null {
|
||||||
|
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCart(): Observable<ShopCartResponse> {
|
loadCart(): Observable<ShopCartResponse> {
|
||||||
@@ -452,7 +462,10 @@ export class ShopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildLangParams(): HttpParams {
|
private buildLangParams(): HttpParams {
|
||||||
return new HttpParams().set('lang', this.languageService.selectedLang());
|
// Public shop URLs are localized. During direct loads the translation
|
||||||
|
// service can still momentarily reflect the browser language, while the
|
||||||
|
// route language has already been resolved from the URL.
|
||||||
|
return new HttpParams().set('lang', this.languageService.currentLang());
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCart(cart: ShopCartResponse): void {
|
private setCart(cart: ShopCartResponse): void {
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
<section class="shop-page">
|
<section class="shop-page">
|
||||||
<div class="container ui-simple-hero shop-hero">
|
<div class="container ui-simple-hero shop-hero">
|
||||||
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
|
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
|
||||||
<p class="ui-simple-hero__subtitle">
|
<p class="ui-simple-hero__subtitle">{{ heroSubtitle() }}</p>
|
||||||
{{
|
|
||||||
selectedCategory()
|
|
||||||
? selectedCategory()?.description ||
|
|
||||||
("SHOP.CATEGORY_META"
|
|
||||||
| translate: { count: selectedCategory()?.productCount || 0 })
|
|
||||||
: ("SHOP.SUBTITLE" | translate)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container shop-layout">
|
<div class="container shop-layout">
|
||||||
@@ -181,17 +173,9 @@
|
|||||||
<div class="section-head catalog-head">
|
<div class="section-head catalog-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui-eyebrow ui-eyebrow--compact">
|
<p class="ui-eyebrow ui-eyebrow--compact">
|
||||||
{{
|
{{ catalogEyebrow() }}
|
||||||
selectedCategory()
|
|
||||||
? ("SHOP.SELECTED_CATEGORY" | translate)
|
|
||||||
: ("SHOP.CATALOG_LABEL" | translate)
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">{{ catalogTitle() }}</h2>
|
||||||
{{
|
|
||||||
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="catalog-counter">
|
<span class="catalog-counter">
|
||||||
{{ products().length }}
|
{{ products().length }}
|
||||||
@@ -199,7 +183,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() || softFallbackActive()) {
|
||||||
<div class="product-grid skeleton-grid">
|
<div class="product-grid skeleton-grid">
|
||||||
@for (ghost of [1, 2, 3, 4]; track ghost) {
|
@for (ghost of [1, 2, 3, 4]; track ghost) {
|
||||||
<div class="skeleton-card"></div>
|
<div class="skeleton-card"></div>
|
||||||
|
|||||||
233
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
233
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
|
import {
|
||||||
|
ShopCategoryDetail,
|
||||||
|
ShopCategoryTree,
|
||||||
|
ShopProductCatalogResponse,
|
||||||
|
ShopService,
|
||||||
|
} from './services/shop.service';
|
||||||
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { ShopPageComponent } from './shop-page.component';
|
||||||
|
|
||||||
|
describe('ShopPageComponent', () => {
|
||||||
|
function buildCategory(
|
||||||
|
overrides: Partial<ShopCategoryDetail> = {},
|
||||||
|
): ShopCategoryDetail {
|
||||||
|
return {
|
||||||
|
id: 'cat-1',
|
||||||
|
slug: 'compatible-with-garmin',
|
||||||
|
name: 'Compatible with Garmin',
|
||||||
|
description: 'Accessories compatible with Garmin devices.',
|
||||||
|
seoTitle: null,
|
||||||
|
seoDescription: null,
|
||||||
|
ogTitle: null,
|
||||||
|
ogDescription: null,
|
||||||
|
indexable: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
productCount: 3,
|
||||||
|
breadcrumbs: [],
|
||||||
|
primaryImage: null,
|
||||||
|
images: [],
|
||||||
|
children: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCatalog(
|
||||||
|
overrides: Partial<ShopProductCatalogResponse> = {},
|
||||||
|
): ShopProductCatalogResponse {
|
||||||
|
return {
|
||||||
|
categorySlug: null,
|
||||||
|
featuredOnly: null,
|
||||||
|
category: null,
|
||||||
|
products: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComponent(routerUrl = '/de/shop') {
|
||||||
|
const responseInit: { status?: number } = {};
|
||||||
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
|
'applyResolvedSeo',
|
||||||
|
'applyPageSeo',
|
||||||
|
]);
|
||||||
|
const translate = jasmine.createSpyObj<TranslateService>(
|
||||||
|
'TranslateService',
|
||||||
|
['instant'],
|
||||||
|
);
|
||||||
|
translate.instant.and.callFake(
|
||||||
|
(key: string, params?: { count?: number }) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'SHOP.TITLE': 'Technische Lösungen',
|
||||||
|
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
|
||||||
|
'SHOP.CATALOG_TITLE': 'Alle Produkte',
|
||||||
|
'SHOP.CATALOG_LABEL': 'Katalog',
|
||||||
|
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
|
||||||
|
'SHOP.CATALOG_META_DESCRIPTION':
|
||||||
|
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
|
||||||
|
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||||
|
};
|
||||||
|
if (key === 'SHOP.CATEGORY_META') {
|
||||||
|
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
|
||||||
|
}
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: () => currentLang(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopService = {
|
||||||
|
cart: signal(null),
|
||||||
|
cartLoading: signal(false),
|
||||||
|
cartLoaded: signal(false),
|
||||||
|
cartItemCount: signal(0),
|
||||||
|
cartSessionId: signal<string | null>(null),
|
||||||
|
getCategories: jasmine
|
||||||
|
.createSpy('getCategories')
|
||||||
|
.and.returnValue(of([] as ShopCategoryTree[])),
|
||||||
|
getProductCatalog: jasmine
|
||||||
|
.createSpy('getProductCatalog')
|
||||||
|
.and.returnValue(of(buildCatalog())),
|
||||||
|
flattenCategoryTree: jasmine
|
||||||
|
.createSpy('flattenCategoryTree')
|
||||||
|
.and.returnValue([]),
|
||||||
|
quantityForProduct: jasmine
|
||||||
|
.createSpy('quantityForProduct')
|
||||||
|
.and.returnValue(0),
|
||||||
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
|
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
|
||||||
|
removeCartItem: jasmine
|
||||||
|
.createSpy('removeCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
|
updateCartItem: jasmine
|
||||||
|
.createSpy('updateCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
url: routerUrl,
|
||||||
|
navigate: jasmine.createSpy('navigate'),
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
const activatedRoute = {
|
||||||
|
paramMap: of(convertToParamMap({})),
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({}),
|
||||||
|
},
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ShopPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: SeoService, useValue: seoService },
|
||||||
|
{ provide: TranslateService, useValue: translate },
|
||||||
|
{ provide: LanguageService, useValue: languageService },
|
||||||
|
{ provide: ShopService, useValue: shopService },
|
||||||
|
{
|
||||||
|
provide: ShopRouteService,
|
||||||
|
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||||
|
'shopRootCommands',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture: ComponentFixture<ShopPageComponent> =
|
||||||
|
TestBed.createComponent(ShopPageComponent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: fixture.componentInstance,
|
||||||
|
seoService,
|
||||||
|
responseInit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps index follow on the public shop root', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applyDefaultSeo();
|
||||||
|
|
||||||
|
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Technische Lösungen | 3D fab',
|
||||||
|
robots: 'index, follow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
||||||
|
const { component, seoService } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
(component as any).applySeo(buildCategory({ indexable: false }));
|
||||||
|
|
||||||
|
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a soft SSR fallback for non-404 category load errors', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
|
(component as any).setResponseStatus(200);
|
||||||
|
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(200);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Compatible With Garmin | Technische Lösungen | 3D fab',
|
||||||
|
description:
|
||||||
|
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/compatible-with-garmin',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hard 404 noindex behavior for missing categories', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
|
(component as any).setResponseStatus(404);
|
||||||
|
(component as any).applyHardErrorSeo();
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,22 +1,25 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
PLATFORM_ID,
|
||||||
|
RESPONSE_INIT,
|
||||||
afterNextRender,
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injector,
|
Injector,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
finalize,
|
finalize,
|
||||||
forkJoin,
|
forkJoin,
|
||||||
|
map,
|
||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
@@ -39,6 +42,7 @@ import {
|
|||||||
ShopService,
|
ShopService,
|
||||||
} from './services/shop.service';
|
} from './services/shop.service';
|
||||||
import { ShopRouteService } from './services/shop-route.service';
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shop-page',
|
selector: 'app-shop-page',
|
||||||
@@ -57,16 +61,23 @@ import { ShopRouteService } from './services/shop-route.service';
|
|||||||
export class ShopPageComponent {
|
export class ShopPageComponent {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
readonly languageService = inject(LanguageService);
|
readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
private readonly shopRouteService = inject(ShopRouteService);
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
|
readonly softFallbackActive = signal(false);
|
||||||
|
readonly softFallbackCategoryLabel = signal<string | null>(null);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly categories = signal<ShopCategoryTree[]>([]);
|
readonly categories = signal<ShopCategoryTree[]>([]);
|
||||||
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
||||||
@@ -80,7 +91,7 @@ export class ShopPageComponent {
|
|||||||
readonly cartLoading = this.shopService.cartLoading;
|
readonly cartLoading = this.shopService.cartLoading;
|
||||||
readonly cartItemCount = this.shopService.cartItemCount;
|
readonly cartItemCount = this.shopService.cartItemCount;
|
||||||
readonly currentCategorySlug = computed(
|
readonly currentCategorySlug = computed(
|
||||||
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
|
() => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
);
|
);
|
||||||
readonly cartItems = computed(() =>
|
readonly cartItems = computed(() =>
|
||||||
(this.cart()?.items ?? []).filter(
|
(this.cart()?.items ?? []).filter(
|
||||||
@@ -88,6 +99,44 @@ export class ShopPageComponent {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
readonly cartHasItems = computed(() => this.cartItems().length > 0);
|
readonly cartHasItems = computed(() => this.cartItems().length > 0);
|
||||||
|
readonly heroSubtitle = computed(() => {
|
||||||
|
this.languageService.currentLang();
|
||||||
|
|
||||||
|
const category = this.selectedCategory();
|
||||||
|
if (category) {
|
||||||
|
return (
|
||||||
|
category.description ||
|
||||||
|
this.translate.instant('SHOP.CATEGORY_META', {
|
||||||
|
count: category.productCount || 0,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.softFallbackActive() && this.routeCategorySlug()) {
|
||||||
|
return this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
|
||||||
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.translate.instant('SHOP.SUBTITLE');
|
||||||
|
});
|
||||||
|
readonly catalogEyebrow = computed(() => {
|
||||||
|
this.languageService.currentLang();
|
||||||
|
|
||||||
|
return this.selectedCategory() || this.softFallbackCategoryLabel()
|
||||||
|
? this.translate.instant('SHOP.SELECTED_CATEGORY')
|
||||||
|
: this.translate.instant('SHOP.CATALOG_LABEL');
|
||||||
|
});
|
||||||
|
readonly catalogTitle = computed(() => {
|
||||||
|
this.languageService.currentLang();
|
||||||
|
|
||||||
|
return (
|
||||||
|
this.selectedCategory()?.name ||
|
||||||
|
this.softFallbackCategoryLabel() ||
|
||||||
|
this.translate.instant('SHOP.CATALOG_TITLE')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
afterNextRender(() => {
|
afterNextRender(() => {
|
||||||
@@ -95,35 +144,58 @@ export class ShopPageComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.categorySlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
|
this.softFallbackCategoryLabel.set(null);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
}),
|
}),
|
||||||
switchMap(([categorySlug]) =>
|
switchMap(([categorySlug]) => {
|
||||||
forkJoin({
|
this.routeCategorySlug.set(categorySlug);
|
||||||
|
return forkJoin({
|
||||||
categories: this.shopService.getCategories(),
|
categories: this.shopService.getCategories(),
|
||||||
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
|
const isNotFound = error?.status === 404;
|
||||||
this.categories.set([]);
|
this.categories.set([]);
|
||||||
this.categoryNodes.set([]);
|
this.categoryNodes.set([]);
|
||||||
this.selectedCategory.set(null);
|
this.selectedCategory.set(null);
|
||||||
this.products.set([]);
|
this.products.set([]);
|
||||||
this.error.set(
|
if (isNotFound) {
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
|
this.setResponseStatus(404);
|
||||||
|
this.applyHardErrorSeo();
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldUseSoftSeoFallback(error)) {
|
||||||
|
this.error.set(null);
|
||||||
|
this.softFallbackActive.set(true);
|
||||||
|
this.softFallbackCategoryLabel.set(
|
||||||
|
categorySlug ? humanizeShopSlug(categorySlug) : null,
|
||||||
);
|
);
|
||||||
this.applyDefaultSeo();
|
this.setResponseStatus(200);
|
||||||
|
this.applySoftFallbackSeo(categorySlug);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('SHOP.LOAD_ERROR');
|
||||||
|
this.setResponseStatus(503);
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
finalize(() => this.loading.set(false)),
|
finalize(() => this.loading.set(false)),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -135,11 +207,13 @@ export class ShopPageComponent {
|
|||||||
this.categoryNodes.set(
|
this.categoryNodes.set(
|
||||||
this.shopService.flattenCategoryTree(
|
this.shopService.flattenCategoryTree(
|
||||||
result.categories,
|
result.categories,
|
||||||
result.catalog.category?.slug ?? this.categorySlug() ?? null,
|
result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectedCategory.set(result.catalog.category ?? null);
|
this.selectedCategory.set(result.catalog.category ?? null);
|
||||||
this.products.set(result.catalog.products);
|
this.products.set(result.catalog.products);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
|
this.softFallbackCategoryLabel.set(null);
|
||||||
this.applySeo(result.catalog.category ?? null);
|
this.applySeo(result.catalog.category ?? null);
|
||||||
this.restoreCatalogScrollIfNeeded();
|
this.restoreCatalogScrollIfNeeded();
|
||||||
});
|
});
|
||||||
@@ -355,6 +429,81 @@ export class ShopPageComponent {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyHardErrorSeo(): 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 applySoftFallbackSeo(categorySlug: string | null): void {
|
||||||
|
if (!categorySlug) {
|
||||||
|
this.applyDefaultSeo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = this.buildSoftFallbackCategoryTitle(categorySlug);
|
||||||
|
const description = this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
|
||||||
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.seoService.applyResolvedSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots: 'index, follow',
|
||||||
|
ogTitle: title,
|
||||||
|
ogDescription: description,
|
||||||
|
canonicalPath: this.currentPath(),
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
|
||||||
|
return !this.isBrowser && error?.status !== 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSoftFallbackCategoryTitle(categorySlug: string): string {
|
||||||
|
const shopTitle = this.translate.instant('SHOP.TITLE');
|
||||||
|
const humanized = humanizeShopSlug(categorySlug);
|
||||||
|
if (humanized) {
|
||||||
|
return `${humanized} | ${shopTitle} | 3D fab`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_TITLE',
|
||||||
|
`${shopTitle} | 3D fab`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTranslatedText(key: string, fallback: string): string {
|
||||||
|
const translated = this.translate.instant(key);
|
||||||
|
return typeof translated === 'string' && translated !== key
|
||||||
|
? translated
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPath(): string {
|
||||||
|
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setResponseStatus(status: number): void {
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private restoreCatalogScrollIfNeeded(): void {
|
private restoreCatalogScrollIfNeeded(): void {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return;
|
return;
|
||||||
@@ -374,4 +523,13 @@ export class ShopPageComponent {
|
|||||||
window.setTimeout(restore, 60);
|
window.setTimeout(restore, 60);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readRouteParam(name: string): string | null {
|
||||||
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteParam(value: string | null | undefined): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal file
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
const PRODUCT_ID_PREFIX_PATTERN = /^[0-9a-f]{8}-(?=[a-z0-9])/i;
|
||||||
|
const UPPERCASE_TOKENS = new Set([
|
||||||
|
'3d',
|
||||||
|
'abs',
|
||||||
|
'asa',
|
||||||
|
'cad',
|
||||||
|
'cf',
|
||||||
|
'gf',
|
||||||
|
'pa',
|
||||||
|
'pc',
|
||||||
|
'petg',
|
||||||
|
'pla',
|
||||||
|
'pp',
|
||||||
|
'tpu',
|
||||||
|
'uv',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function humanizeShopSlug(
|
||||||
|
value: string | null | undefined,
|
||||||
|
options?: {
|
||||||
|
stripProductIdPrefix?: boolean;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const normalized = normalizeShopSlug(value, options?.stripProductIdPrefix);
|
||||||
|
if (!normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
.split('-')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(formatSlugToken)
|
||||||
|
.join(' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShopSlug(
|
||||||
|
value: string | null | undefined,
|
||||||
|
stripProductIdPrefix = false,
|
||||||
|
): string {
|
||||||
|
const normalized = String(value ?? '')
|
||||||
|
.trim()
|
||||||
|
.replace(/^\/+|\/+$/g, '')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.at(-1)
|
||||||
|
?.toLowerCase();
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripProductIdPrefix
|
||||||
|
? normalized.replace(PRODUCT_ID_PREFIX_PATTERN, '')
|
||||||
|
: normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSlugToken(token: string): string {
|
||||||
|
if (!token) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (/^\d+$/.test(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPERCASE_TOKENS.has(token)) {
|
||||||
|
return token.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${token.charAt(0).toUpperCase()}${token.slice(1)}`;
|
||||||
|
}
|
||||||
@@ -29,14 +29,14 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-animation[data-variant='site-intro'] .brand-animation__letter {
|
.brand-animation[data-variant="site-intro"] .brand-animation__letter {
|
||||||
animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s) linear 1 forwards;
|
animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s)
|
||||||
|
linear 1 forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-animation[data-variant='calculator-loader'] .brand-animation__letter {
|
.brand-animation[data-variant="calculator-loader"] .brand-animation__letter {
|
||||||
animation: calculator-loader-loop
|
animation: calculator-loader-loop
|
||||||
var(--brand-animation-loader-loop-duration, 2.65s)
|
var(--brand-animation-loader-loop-duration, 2.65s) infinite;
|
||||||
infinite;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes site-intro-preview {
|
@keyframes site-intro-preview {
|
||||||
@@ -75,8 +75,7 @@
|
|||||||
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scaleX(var(--loader-group-scale-x))
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
scaleY(var(--loader-group-scale-y));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
12% {
|
12% {
|
||||||
@@ -87,8 +86,7 @@
|
|||||||
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scaleX(var(--loader-group-scale-x))
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
scaleY(var(--loader-group-scale-y));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
12% {
|
12% {
|
||||||
@@ -118,8 +116,7 @@
|
|||||||
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scaleX(var(--loader-group-scale-x))
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
scaleY(var(--loader-group-scale-y));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
88% {
|
88% {
|
||||||
@@ -131,12 +128,11 @@
|
|||||||
transform: translate(-50%, -50%)
|
transform: translate(-50%, -50%)
|
||||||
translateX(
|
translateX(
|
||||||
calc(
|
calc(
|
||||||
(var(--bee-anchor-x) + var(--loader-exit-shift)) *
|
(var(--bee-anchor-x) + var(--loader-exit-shift)) * var(--word-scale) *
|
||||||
var(--word-scale) * var(--word-spacing-factor)
|
var(--word-spacing-factor)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scaleX(0.98)
|
scaleX(0.98) scaleY(1.02);
|
||||||
scaleY(1.02);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
94.01%,
|
94.01%,
|
||||||
@@ -148,8 +144,7 @@
|
|||||||
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
scaleX(var(--loader-group-scale-x))
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
scaleY(var(--loader-group-scale-y));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ export class BrandAnimationLogoComponent {
|
|||||||
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
|
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
|
||||||
LETTERS.map((letter) => ({
|
LETTERS.map((letter) => ({
|
||||||
key: letter.key,
|
key: letter.key,
|
||||||
src:
|
src: this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
|
||||||
this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
|
|
||||||
wordX: letter.wordX,
|
wordX: letter.wordX,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -613,11 +613,11 @@
|
|||||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
||||||
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
||||||
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Mit Sitz in der Schweiz",
|
||||||
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zürich",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||||
"BTN_CALCULATE": "Angebot berechnen",
|
"BTN_CALCULATE": "Angebot berechnen",
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||||
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
||||||
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Basés en Suisse",
|
||||||
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
|
|||||||
@@ -84,11 +84,11 @@
|
|||||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||||
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||||
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Con sede in Svizzera",
|
||||||
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zurigo",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||||
"BTN_CALCULATE": "Calcola Preventivo",
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
|
|||||||
14
frontend/src/assets/images/SVG/linkedin-svgrepo-com.svg
Normal file
14
frontend/src/assets/images/SVG/linkedin-svgrepo-com.svg
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
viewBox="0 0 382 382" xml:space="preserve">
|
||||||
|
<path style="fill:#0077B7;" d="M347.445,0H34.555C15.471,0,0,15.471,0,34.555v312.889C0,366.529,15.471,382,34.555,382h312.889
|
||||||
|
C366.529,382,382,366.529,382,347.444V34.555C382,15.471,366.529,0,347.445,0z M118.207,329.844c0,5.554-4.502,10.056-10.056,10.056
|
||||||
|
H65.345c-5.554,0-10.056-4.502-10.056-10.056V150.403c0-5.554,4.502-10.056,10.056-10.056h42.806
|
||||||
|
c5.554,0,10.056,4.502,10.056,10.056V329.844z M86.748,123.432c-22.459,0-40.666-18.207-40.666-40.666S64.289,42.1,86.748,42.1
|
||||||
|
s40.666,18.207,40.666,40.666S109.208,123.432,86.748,123.432z M341.91,330.654c0,5.106-4.14,9.246-9.246,9.246H286.73
|
||||||
|
c-5.106,0-9.246-4.14-9.246-9.246v-84.168c0-12.556,3.683-55.021-32.813-55.021c-28.309,0-34.051,29.066-35.204,42.11v97.079
|
||||||
|
c0,5.106-4.139,9.246-9.246,9.246h-44.426c-5.106,0-9.246-4.14-9.246-9.246V149.593c0-5.106,4.14-9.246,9.246-9.246h44.426
|
||||||
|
c5.106,0,9.246,4.14,9.246,9.246v15.655c10.497-15.753,26.097-27.912,59.312-27.912c73.552,0,73.131,68.716,73.131,106.472
|
||||||
|
L341.91,330.654L341.91,330.654z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="it">
|
<html lang="it-CH">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>3D fab | Stampa 3D su misura</title>
|
<title>3D fab | Stampa 3D su misura</title>
|
||||||
|
|||||||
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 handle the root path because it is resolved separately', () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
frontend/src/server-routing.ts
Normal file
105
frontend/src/server-routing.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
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 { fileURLToPath } from 'node:url';
|
||||||
import bootstrap from './main.server';
|
import bootstrap from './main.server';
|
||||||
import { resolveRequestOrigin } from './core/request-origin';
|
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 serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||||
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||||
@@ -36,6 +41,35 @@ app.get(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
const userAgent = req.get('user-agent');
|
||||||
|
const preferredLanguages = parseAcceptLanguage(req.get('accept-language'));
|
||||||
|
const lang = resolveInitialLanguage({
|
||||||
|
preferredLanguages,
|
||||||
|
});
|
||||||
|
const stableRedirect = shouldUseStableRootRedirect(
|
||||||
|
userAgent,
|
||||||
|
preferredLanguages,
|
||||||
|
);
|
||||||
|
|
||||||
|
res.setHeader('Vary', 'Accept-Language, User-Agent');
|
||||||
|
res.setHeader('Cache-Control', 'private, no-store');
|
||||||
|
res.redirect(
|
||||||
|
stableRedirect ? 308 : 302,
|
||||||
|
`/${stableRedirect ? 'it' : 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.
|
* Handle all other requests by rendering the Angular application.
|
||||||
*/
|
*/
|
||||||
@@ -67,3 +101,26 @@ if (isMainModule(import.meta.url)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
||||||
|
function querySuffix(url: string): string {
|
||||||
|
const queryIndex = String(url ?? '').indexOf('?');
|
||||||
|
return queryIndex >= 0 ? String(url).slice(queryIndex) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseStableRootRedirect(
|
||||||
|
userAgent: string | undefined,
|
||||||
|
preferredLanguages: readonly string[],
|
||||||
|
): boolean {
|
||||||
|
return preferredLanguages.length === 0 || isLikelyCrawler(userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyCrawler(userAgent: string | undefined): boolean {
|
||||||
|
const normalized = String(userAgent ?? '').toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return /(bot|crawler|spider|slurp|bingpreview|google-read-aloud)/.test(
|
||||||
|
normalized,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user