27 Commits

Author SHA1 Message Date
28c3abdb4a Merge branch 'main' into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
PR Checks / prettier-autofix (pull_request) Successful in 14s
Build and Deploy / test-frontend (push) Successful in 1m41s
PR Checks / security-sast (pull_request) Successful in 52s
PR Checks / test-backend (pull_request) Successful in 30s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 20s
PR Checks / test-frontend (pull_request) Successful in 1m42s
2026-03-25 11:44:01 +01:00
b30bfc9293 fix(front-end): improvements in load products by uuid truncated
Some checks failed
Build and Deploy / test-backend (push) Successful in 38s
Build and Deploy / test-frontend (push) Successful in 1m37s
Build and Deploy / build-and-push (push) Successful in 1m57s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 13s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / security-sast (pull_request) Successful in 40s
PR Checks / test-frontend (pull_request) Successful in 1m15s
2026-03-25 11:27:43 +01:00
d70423fcc0 fix(front-end): improvements in ssr
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 29s
Build and Deploy / deploy (push) Successful in 21s
2026-03-24 16:20:40 +01:00
1b7c0c48e7 Merge pull request 'dev' (#54) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 27s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #54
2026-03-24 13:29:50 +01:00
printcalc-ci
cb86137730 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 29s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-24 12:19:19 +00:00
c8913af660 Merge remote-tracking branch 'origin/dev' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / test-backend (push) Successful in 29s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / test-frontend (push) Successful in 1m1s
Build and Deploy / deploy (push) Successful in 21s
Build and Deploy / build-and-push (push) Successful in 31s
2026-03-24 13:17:30 +01:00
9611049e01 fix(front-end): new test 2026-03-24 13:17:25 +01:00
bad5947fb5 Merge branch 'main' into dev
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 31s
PR Checks / test-frontend (pull_request) Successful in 1m4s
Build and Deploy / test-backend (push) Has been cancelled
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / build-and-push (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Failing after 10s
2026-03-24 13:17:10 +01:00
d27558a3ee fix(front-end): fix no index product 3 hope the last one
Some checks failed
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 19s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-24 12:59:09 +01:00
81f6f78c49 fix(front-end): fix no index product 2
All checks were successful
Build and Deploy / test-backend (push) Successful in 32s
Build and Deploy / test-frontend (push) Successful in 1m9s
Build and Deploy / build-and-push (push) Successful in 31s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 19:11:26 +01:00
bf593445bd fix(front-end): fix no index product
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 1m16s
Build and Deploy / deploy (push) Successful in 20s
2026-03-23 18:07:07 +01:00
aa032c0140 Merge pull request 'fix(front-end): fix no index in products' (#53) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m6s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 20s
Reviewed-on: #53
2026-03-23 17:36:11 +01:00
printcalc-ci
95e60692c0 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / test-backend (pull_request) Successful in 29s
PR Checks / security-sast (pull_request) Successful in 37s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-23 16:31:33 +00:00
fda2cdbecb Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 15s
PR Checks / security-sast (pull_request) Successful in 31s
PR Checks / test-backend (pull_request) Successful in 28s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / deploy (push) Successful in 23s
PR Checks / test-frontend (pull_request) Successful in 1m5s
2026-03-23 17:29:38 +01:00
a1cc9f18c4 fix(front-end): fix no index in products
Some checks failed
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 1m3s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Failing after 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-23 17:27:18 +01:00
084d35d605 Merge pull request 'fix(front-end): seo improvemnts' (#52) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 28s
Build and Deploy / test-frontend (push) Successful in 59s
Build and Deploy / build-and-push (push) Successful in 30s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #52
2026-03-23 16:21:00 +01:00
printcalc-ci
02aac24a09 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 59s
2026-03-23 15:15:15 +00:00
51c2bf6985 Merge branch 'main' into dev
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 13s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 29s
Build and Deploy / test-backend (push) Successful in 27s
PR Checks / test-frontend (pull_request) Successful in 1m3s
Build and Deploy / test-frontend (push) Successful in 1m0s
Build and Deploy / build-and-push (push) Successful in 28s
Build and Deploy / deploy (push) Successful in 21s
2026-03-23 16:14:18 +01:00
4e99d12be1 fix(front-end): seo improvemnts
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m4s
PR Checks / prettier-autofix (pull_request) Failing after 12s
Build and Deploy / test-frontend (push) Has been cancelled
2026-03-23 16:14:04 +01:00
8b5d8f92e0 Merge pull request 'dev' (#51) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
Build and Deploy / test-frontend (push) Successful in 1m7s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #51
2026-03-22 23:06:02 +01:00
d3c9dd6eb9 Merge branch 'main' into dev
All checks were successful
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m6s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 33s
Build and Deploy / test-backend (push) Successful in 29s
Build and Deploy / test-frontend (push) Successful in 1m8s
Build and Deploy / build-and-push (push) Successful in 32s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 23:03:08 +01:00
254ff36c50 fix(front-end): seo improvemnts
Some checks failed
Build and Deploy / build-and-push (push) Has been cancelled
Build and Deploy / deploy (push) Has been cancelled
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
Build and Deploy / test-frontend (push) Has been cancelled
Build and Deploy / test-backend (push) Has been cancelled
PR Checks / prettier-autofix (pull_request) Successful in 10s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-22 23:02:59 +01:00
b317196217 fix(front-end): redirect
All checks were successful
Build and Deploy / test-backend (push) Successful in 40s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m19s
Build and Deploy / deploy (push) Successful in 22s
2026-03-22 22:41:12 +01:00
cc343ee27c fix(back-end): fix csrm and cors 2026-03-22 21:11:48 +01:00
74d1b16b7c fix(back-end): fix load product 2026-03-22 21:11:33 +01:00
adf6889712 Merge pull request 'dev' (#49) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 39s
Build and Deploy / test-frontend (push) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 17s
Build and Deploy / deploy (push) Successful in 22s
Reviewed-on: #49
2026-03-21 18:57:57 +01:00
653082868a Merge pull request 'feat/brand-logo' (#50) from feat/brand-logo into dev
All checks were successful
Build and Deploy / test-backend (push) Successful in 35s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 27s
PR Checks / prettier-autofix (pull_request) Successful in 10s
Build and Deploy / test-frontend (push) Successful in 1m7s
PR Checks / test-frontend (pull_request) Successful in 1m5s
Build and Deploy / build-and-push (push) Successful in 1m21s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #50
2026-03-20 13:17:03 +01:00
32 changed files with 1906 additions and 206 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,8 @@ 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()); Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductSummaryDto( return new ShopProductSummaryDto(
entry.product().getId(), entry.product().getId(),
@@ -417,7 +479,7 @@ public class PublicShopCatalogService {
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
toProductModelDto(entry), toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), publicPathSegment,
localizedPaths localizedPaths
); );
} }
@@ -429,9 +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);
String normalizedLanguage = normalizeLanguage(language);
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
return new ShopProductDetailDto( return new ShopProductDetailDto(entry.product().getId(),
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),
@@ -458,7 +521,7 @@ public class PublicShopCatalogService {
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
toProductModelDto(entry), toProductModelDto(entry),
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")), publicPathSegment,
localizedPaths localizedPaths
); );
} }
@@ -512,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()) {
@@ -607,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
) { ) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
<xhtml:link rel="alternate" hreflang="en-CH" 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-CH" 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-CH" 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>
@@ -16,7 +16,7 @@
<xhtml:link rel="alternate" hreflang="en-CH" 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-CH" 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-CH" 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>
@@ -26,7 +26,7 @@
<xhtml:link rel="alternate" hreflang="en-CH" 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-CH" 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-CH" 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>
@@ -36,7 +36,7 @@
<xhtml:link rel="alternate" hreflang="en-CH" 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-CH" 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-CH" 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>

View File

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

View File

@@ -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,133 @@ 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, { optional: true }) as ServerRequestLike | null;
const origin = resolveRequestOrigin(request); 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,
}),
);
}; };

View File

@@ -117,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',

View File

@@ -105,7 +105,7 @@ export class SeoService {
cleanPath, cleanPath,
canonicalPath, canonicalPath,
alternates, alternates,
alternates.it ?? canonicalPath, this.buildXDefaultPath(canonicalPath, alternates),
lang, lang,
); );
} }
@@ -119,8 +119,7 @@ export class SeoService {
const alternates = this.normalizeAlternatePaths(override.alternates); const alternates = this.normalizeAlternatePaths(override.alternates);
const xDefault = const xDefault =
this.normalizeSeoPath(override.xDefault) ?? this.normalizeSeoPath(override.xDefault) ??
alternates?.it ?? this.buildXDefaultPath(canonicalPath, alternates);
canonicalPath;
this.applySeoValues( this.applySeoValues(
title, title,
@@ -162,7 +161,7 @@ export class SeoService {
cleanPath, cleanPath,
canonicalPath, canonicalPath,
alternates, alternates,
alternates.it ?? canonicalPath, this.buildXDefaultPath(canonicalPath, alternates),
lang, lang,
); );
} }
@@ -360,6 +359,25 @@ export class SeoService {
}, {}); }, {});
} }
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( private normalizeAlternatePaths(
paths: SeoMap | null | undefined, paths: SeoMap | null | undefined,
): SeoMap | null { ): SeoMap | null {

View File

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

View 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,
}),
);
});
});

View File

@@ -8,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';
@@ -27,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;
@@ -61,6 +70,7 @@ 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);
@@ -70,10 +80,12 @@ export class ProductDetailComponent {
private readonly responseInit = inject(RESPONSE_INIT, { optional: true }); 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);
@@ -205,30 +217,43 @@ export class ProductDetailComponent {
}); });
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.languageService.clearLocalizedRouteOverrides();
this.error.set('SHOP.NOT_FOUND'); this.error.set('SHOP.NOT_FOUND');
this.setResponseStatus(404); this.setResponseStatus(404);
this.applyFallbackSeo(); 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.languageService.clearLocalizedRouteOverrides();
@@ -236,13 +261,24 @@ export class ProductDetailComponent {
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');
if (error?.status === 404) {
this.setResponseStatus(404); this.setResponseStatus(404);
this.applyHardFallbackSeo();
return of(null);
} }
this.applyFallbackSeo();
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)),
@@ -256,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,
); );
@@ -492,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);
} }
@@ -567,7 +605,7 @@ 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.selectedLang(); const lang = this.languageService.currentLang();
const canonicalPath = const canonicalPath =
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null; product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
@@ -583,7 +621,7 @@ export class ProductDetailComponent {
}); });
} }
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.applyResolvedSeo({ this.seoService.applyResolvedSeo({
@@ -598,6 +636,55 @@ export class ProductDetailComponent {
}); });
} }
private applySoftFallbackSeo(productSlug: string): void {
const title = this.buildSoftFallbackTitle(productSlug);
const description = this.resolveTranslatedText(
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
);
this.seoService.applyResolvedSeo({
title,
description,
robots: 'index, follow',
ogTitle: title,
ogDescription: description,
canonicalPath: this.currentPath(),
alternates: null,
xDefault: null,
});
}
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
return !this.isBrowser && error?.status !== 404;
}
private buildSoftFallbackTitle(productSlug: string): string {
const humanized = humanizeShopSlug(productSlug, {
stripProductIdPrefix: true,
});
if (humanized) {
return `${humanized} | 3D fab`;
}
return this.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 {
@@ -770,7 +857,7 @@ export class ProductDetailComponent {
} }
const currentTree = this.router.parseUrl(this.router.url); const currentTree = this.router.parseUrl(this.router.url);
const lang = this.languageService.selectedLang(); const lang = this.languageService.currentLang();
const targetPath = const targetPath =
product.localizedPaths?.[lang] ?? product.localizedPaths?.[lang] ??
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`; `/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
@@ -810,4 +897,13 @@ export class ProductDetailComponent {
this.responseInit.status = status; 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;
}
} }

View File

@@ -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,39 +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,
publicPath: '12345678-supporto-cavo-scrivania',
localizedPaths: {
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
en: '/en/shop/p/12345678-desk-cable-clip',
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
fr: '/fr/shop/p/12345678-support-cable-bureau',
},
},
],
});
const buildProduct = (): ShopProductDetail => ({ const buildProduct = (): ShopProductDetail => ({
id: '12345678-abcd-4abc-9abc-1234567890ab', id: '12345678-abcd-4abc-9abc-1234567890ab',
slug: 'desk-cable-clip', slug: 'desk-cable-clip',
@@ -165,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);
}); });
@@ -226,76 +199,88 @@ 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('rejects product paths whose slug tail does not match the canonical path', () => { it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
let errorResponse: { status?: number } | undefined; let response: ShopProductDetail | undefined;
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({ service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
next: () => fail('Expected canonical path mismatch to return 404'), next: (product) => {
error: (error) => { response = product;
errorResponse = error;
}, },
error: () => fail('Expected stale slug tails to resolve from the uuid prefix'),
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = httpMock.expectOne((request) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' && request.url ===
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
request.params.get('lang') === 'it' request.params.get('lang') === 'it'
); );
}); });
catalogRequest.flush(buildCatalog()); request.flush(buildProduct());
httpMock.expectNone( expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
'http://localhost:8000/api/shop/products/desk-cable-clip',
);
expect(errorResponse?.status).toBe(404);
}); });
it('rejects bare uuid product paths without the localized slug tail', () => { it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
let errorResponse: { status?: number } | undefined; let response: ShopProductDetail | undefined;
service.getProductByPublicPath('12345678').subscribe({ service.getProductByPublicPath('12345678').subscribe({
next: () => fail('Expected bare uuid path to return 404'), next: (product) => {
error: (error) => { response = product;
errorResponse = error;
}, },
error: () => fail('Expected bare uuid path to resolve from the uuid prefix'),
}); });
const catalogRequest = httpMock.expectOne((request) => { const request = httpMock.expectOne((request) => {
return ( return (
request.method === 'GET' && request.method === 'GET' &&
request.url === 'http://localhost:8000/api/shop/products' && request.url ===
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
request.params.get('lang') === 'it' request.params.get('lang') === 'it'
); );
}); });
catalogRequest.flush(buildCatalog()); request.flush(buildProduct());
httpMock.expectNone( expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania');
'http://localhost:8000/api/shop/products/desk-cable-clip', });
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
.getProductByPublicPath('12345678-schreibtisch-kabelhalter')
.subscribe((product) => {
response = product;
});
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') === 'de'
); );
expect(errorResponse?.status).toBe(404); });
request.flush(buildProduct());
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
}); });
}); });

View File

@@ -1,6 +1,6 @@
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,
@@ -290,21 +290,16 @@ export class ShopService {
})); }));
} }
return this.getProductCatalog().pipe( const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
map((catalog) => const endpoint = productIdPrefix
catalog.products.find( ? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}`
(product) => : `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`;
this.normalizePublicPath(product.publicPath) === normalizedPath,
), return this.http.get<ShopProductDetail>(
), endpoint,
switchMap((product) => { {
if (!product) { params: this.buildLangParams(),
return throwError(() => ({ },
status: 404,
}));
}
return this.getProduct(product.slug);
}),
); );
} }
@@ -314,6 +309,11 @@ export class ShopService {
.toLowerCase(); .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> {
this.cartLoading.set(true); this.cartLoading.set(true);
return this.http return this.http
@@ -465,7 +465,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 {

View File

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

View 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,
}),
);
});
});

View File

@@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule, isPlatformBrowser } from '@angular/common';
import { import {
PLATFORM_ID,
RESPONSE_INIT, RESPONSE_INIT,
afterNextRender, afterNextRender,
Component, Component,
@@ -7,17 +8,18 @@ import {
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,
@@ -40,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',
@@ -58,17 +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 }); 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[]>([]);
@@ -82,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(
@@ -90,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(() => {
@@ -97,38 +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');
);
if (error?.status === 404) {
this.setResponseStatus(404); this.setResponseStatus(404);
this.applyHardErrorSeo();
return of(null);
} }
this.applyErrorSeo();
if (this.shouldUseSoftSeoFallback(error)) {
this.error.set(null);
this.softFallbackActive.set(true);
this.softFallbackCategoryLabel.set(
categorySlug ? humanizeShopSlug(categorySlug) : null,
);
this.setResponseStatus(200);
this.applySoftFallbackSeo(categorySlug);
return of(null);
}
this.error.set('SHOP.LOAD_ERROR');
this.setResponseStatus(503);
return of(null); return of(null);
}), }),
finalize(() => this.loading.set(false)), finalize(() => this.loading.set(false)),
), );
), }),
takeUntilDestroyed(this.destroyRef), takeUntilDestroyed(this.destroyRef),
) )
.subscribe((result) => { .subscribe((result) => {
@@ -140,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();
}); });
@@ -360,7 +429,7 @@ export class ShopPageComponent {
}); });
} }
private applyErrorSeo(): void { private applyHardErrorSeo(): 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');
@@ -376,6 +445,59 @@ export class ShopPageComponent {
}); });
} }
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 { private setResponseStatus(status: number): void {
if (this.responseInit) { if (this.responseInit) {
this.responseInit.status = status; this.responseInit.status = status;
@@ -401,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;
}
} }

View 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)}`;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { resolvePublicRedirectTarget } from './server-routing'; import { resolvePublicRedirectTarget } from './server-routing';
describe('server routing redirects', () => { describe('server routing redirects', () => {
it('does not force a fixed-language redirect for the root path', () => { it('does not handle the root path because it is resolved separately', () => {
expect(resolvePublicRedirectTarget('/')).toBeNull(); expect(resolvePublicRedirectTarget('/')).toBeNull();
}); });

View File

@@ -42,15 +42,22 @@ app.get(
); );
app.get('/', (req, res) => { app.get('/', (req, res) => {
const acceptLanguage = req.get('accept-language'); const userAgent = req.get('user-agent');
const preferredLanguages = parseAcceptLanguage(acceptLanguage); const preferredLanguages = parseAcceptLanguage(req.get('accept-language'));
const lang = resolveInitialLanguage({ const lang = resolveInitialLanguage({
preferredLanguages, preferredLanguages,
}); });
const stableRedirect = shouldUseStableRootRedirect(
userAgent,
preferredLanguages,
);
res.setHeader('Vary', 'Accept-Language'); res.setHeader('Vary', 'Accept-Language, User-Agent');
res.setHeader('Cache-Control', 'private, no-store'); res.setHeader('Cache-Control', 'private, no-store');
res.redirect(302, `/${lang}${querySuffix(req.originalUrl)}`); res.redirect(
stableRedirect ? 308 : 302,
`/${stableRedirect ? 'it' : lang}${querySuffix(req.originalUrl)}`,
);
}); });
app.get('**', (req, res, next) => { app.get('**', (req, res, next) => {
@@ -99,3 +106,21 @@ function querySuffix(url: string): string {
const queryIndex = String(url ?? '').indexOf('?'); const queryIndex = String(url ?? '').indexOf('?');
return queryIndex >= 0 ? String(url).slice(queryIndex) : ''; 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,
);
}