37 Commits

Author SHA1 Message Date
printcalc-ci
5117726432 style: apply prettier formatting 2026-03-27 10:28:42 +00:00
3ac23173bf feat(front-end): calculator improvements in explanation and UX
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 23s
PR Checks / security-sast (pull_request) Successful in 38s
PR Checks / test-backend (pull_request) Successful in 33s
PR Checks / test-frontend (pull_request) Successful in 1m8s
2026-03-27 11:28:01 +01:00
132f0f3646 feat(front-end): linkedin logo
All checks were successful
Build and Deploy / test-backend (push) Successful in 37s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 36s
Build and Deploy / deploy (push) Successful in 22s
PR Checks / prettier-autofix (pull_request) Successful in 8s
PR Checks / security-sast (pull_request) Successful in 35s
PR Checks / test-backend (pull_request) Successful in 30s
PR Checks / test-frontend (pull_request) Successful in 1m6s
2026-03-25 19:18:38 +01:00
printcalc-ci
8835175fb3 style: apply prettier formatting
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 12s
PR Checks / security-sast (pull_request) Successful in 52s
PR Checks / test-frontend (pull_request) Successful in 1m17s
PR Checks / test-backend (pull_request) Successful in 27s
2026-03-25 10:46:11 +00:00
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
997e770256 Merge remote-tracking branch 'origin/feat/brand-logo' into feat/brand-logo
All checks were successful
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 30s
PR Checks / test-backend (pull_request) Successful in 28s
PR Checks / test-frontend (pull_request) Successful in 1m3s
2026-03-20 11:45:15 +01:00
fb1a6456e6 fix(back-end) base url fix 2026-03-20 11:45:10 +01:00
43cd80600e Merge branch 'dev' into feat/brand-logo
Some checks failed
PR Checks / prettier-autofix (pull_request) Successful in 11s
PR Checks / security-sast (pull_request) Successful in 32s
PR Checks / test-backend (pull_request) Failing after 27s
PR Checks / test-frontend (pull_request) Successful in 1m2s
2026-03-20 11:28:10 +01:00
printcalc-ci
41f36ed18a style: apply prettier formatting 2026-03-17 08:03:30 +00:00
e04189bbfe 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 1m3s
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 1m6s
Build and Deploy / build-and-push (push) Successful in 1m46s
Build and Deploy / deploy (push) Successful in 22s
2026-03-17 09:01:34 +01:00
4ba408859d Merge pull request 'dev' (#48) from dev into main
All checks were successful
Build and Deploy / test-backend (push) Successful in 34s
Build and Deploy / test-frontend (push) Successful in 1m4s
Build and Deploy / build-and-push (push) Successful in 16s
Build and Deploy / deploy (push) Successful in 19s
Reviewed-on: #48
2026-03-14 19:18:15 +01:00
45 changed files with 3007 additions and 247 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,6 +126,9 @@ public class CustomQuoteRequestNotificationService {
} }
private String buildLogoUrl() { private String buildLogoUrl() {
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg"; String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
? "http://localhost:4200"
: frontendBaseUrl;
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
} }
} }

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

@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\"")); assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/accessori\""));
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza")); assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>")); assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\"")); assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\"")); assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>")); assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
assertFalse(xml.contains("33333333-draft")); assertFalse(xml.contains("33333333-draft"));

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,135 @@ function normalizeRelativePath(url: string): string {
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
} }
function stripQueryAndHash(url: string): string {
return String(url ?? '').split(/[?#]/, 1)[0] || '/';
}
function normalizeOrigin(origin: string): string {
return origin.replace(/\/+$/, '');
}
function readRequestHeader(
request: RequestLike | null,
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
): string | null {
const normalizedName = name.toLowerCase();
const headerValue =
request?.headers?.[normalizedName] ?? request?.get?.(normalizedName);
if (Array.isArray(headerValue)) {
return headerValue[0] ?? null;
}
return typeof headerValue === 'string' ? headerValue : null;
}
function readRequestPath(request: ServerRequestLike | null): string | null {
const rawPath =
(typeof request?.originalUrl === 'string' && request.originalUrl) ||
(typeof request?.url === 'string' && request.url) ||
null;
if (!rawPath) {
return null;
}
if (isAbsoluteUrl(rawPath)) {
try {
return stripQueryAndHash(new URL(rawPath).pathname || '/');
} catch {
return null;
}
}
return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`);
}
function isPublicShopPageRequest(request: ServerRequestLike | null): boolean {
const requestPath = readRequestPath(request);
return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath);
}
function isPublicShopDiscoveryApi(url: string): boolean {
const normalizedPath = stripQueryAndHash(normalizeRelativePath(url));
return SHOP_DISCOVERY_API_PATTERNS.some((pattern) =>
pattern.test(normalizedPath),
);
}
function readInternalApiOrigin(): string | null {
const globalObject = globalThis as {
__SSR_INTERNAL_API_ORIGIN__?: string;
process?: {
env?: Record<string, string | undefined>;
};
};
const explicitOverride =
typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string'
? globalObject.__SSR_INTERNAL_API_ORIGIN__
: null;
const env = (
globalObject as {
process?: {
env?: Record<string, string | undefined>;
};
}
).process?.env;
const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN'];
if (typeof rawValue !== 'string') {
return null;
}
const normalized = rawValue.trim();
return normalized ? normalizeOrigin(normalized) : null;
}
function resolveApiOrigin(
request: ServerRequestLike | null,
relativeUrl: string,
): string | null {
const internalOrigin = readInternalApiOrigin();
if (
internalOrigin &&
isPublicShopPageRequest(request) &&
isPublicShopDiscoveryApi(relativeUrl)
) {
return internalOrigin;
}
return resolveRequestOrigin(request);
}
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
if (isAbsoluteUrl(req.url)) { if (isAbsoluteUrl(req.url)) {
return next(req); return next(req);
} }
const request = inject(REQUEST, { optional: true }) as RequestLike | null; const request = inject(REQUEST, {
const origin = resolveRequestOrigin(request); optional: true,
}) as ServerRequestLike | null;
const origin = resolveApiOrigin(request, req.url);
if (!origin) { if (!origin) {
return next(req); return next(req);
} }
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
return next(req.clone({ url: absoluteUrl })); const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
Record<string, string>
>((headers, name) => {
if (req.headers.has(name)) {
return headers;
}
const value = readRequestHeader(request, name);
if (value) {
headers[name] = value;
}
return headers;
}, {});
return next(
req.clone({
url: absoluteUrl,
setHeaders: forwardedHeaders,
}),
);
}; };

View File

@@ -22,10 +22,30 @@
</div> </div>
<div class="col social"> <div class="col social">
<!-- Social Placeholders --> <div class="social-link-row">
<div class="social-icon"></div> <span class="social-name">Joe Küng:</span>
<div class="social-icon"></div> <a
<div class="social-icon"></div> class="social-icon-link"
href="https://www.linkedin.com/in/joe-k%C3%BCng-31831828b/"
target="_blank"
rel="noopener noreferrer"
aria-label="Joe Küng LinkedIn"
>
<span class="social-icon-linkedin" aria-hidden="true"></span>
</a>
</div>
<div class="social-link-row">
<span class="social-name">Matteo Caletti:</span>
<a
class="social-icon-link"
href="https://www.linkedin.com/in/matteo-caletti-94291a3b6/"
target="_blank"
rel="noopener noreferrer"
aria-label="Matteo Caletti LinkedIn"
>
<span class="social-icon-linkedin" aria-hidden="true"></span>
</a>
</div>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -66,11 +66,69 @@
.social { .social {
display: flex; display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--space-3); gap: var(--space-3);
} }
.social-icon {
width: 24px; .social-link-row {
height: 24px; display: flex;
background-color: var(--color-neutral-800); align-items: center;
border-radius: 50%; gap: var(--space-3);
}
.social-name {
color: var(--color-neutral-200);
font-size: 0.875rem;
}
.social-icon-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background-color: var(--color-neutral-50);
color: #0a66c2;
transition:
transform 0.2s ease,
background-color 0.2s ease,
color 0.2s ease;
&:hover {
background-color: #0a66c2;
color: var(--color-neutral-50);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-secondary-500);
outline-offset: 2px;
}
}
.social-icon-linkedin {
display: block;
width: 1rem;
height: 1rem;
background-color: currentColor;
mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
@media (max-width: 768px) {
.social {
align-items: center;
}
.social-link-row {
justify-content: center;
}
} }

View File

@@ -30,7 +30,7 @@
>{{ "NAV.HOME" | translate }}</a >{{ "NAV.HOME" | translate }}</a
> >
<a <a
[routerLink]="langService.localizedPath('/calculator/basic')" [routerLink]="langService.localizedPath('/calculator')"
routerLinkActive="active" routerLinkActive="active"
[routerLinkActiveOptions]="{ exact: false }" [routerLinkActiveOptions]="{ exact: false }"
(click)="closeMenu()" (click)="closeMenu()"

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

@@ -1,6 +1,10 @@
<div class="container ui-simple-hero"> <div class="container ui-simple-hero">
<h1 class="ui-simple-hero__title">{{ "CALC.TITLE" | translate }}</h1> <h1 class="ui-simple-hero__title">
<p class="ui-simple-hero__subtitle">{{ "CALC.SUBTITLE" | translate }}</p> {{ modeContentKey("TITLE") | translate }}
</h1>
<p class="ui-simple-hero__subtitle">
{{ modeContentKey("SUBTITLE") | translate }}
</p>
@if (error()) { @if (error()) {
<app-alert type="error">{{ errorKey() | translate }}</app-alert> <app-alert type="error">{{ errorKey() | translate }}</app-alert>
@@ -99,4 +103,84 @@
} }
</div> </div>
</div> </div>
<div class="container calculator-guides">
<section class="calculator-guide">
<div class="model-source-section">
<div class="model-source-intro">
<p class="guide-kicker">
{{ "CALC.MODEL_SOURCES.KICKER" | translate }}
</p>
<h2>{{ "CALC.MODEL_SOURCES.TITLE" | translate }}</h2>
<p>{{ "CALC.MODEL_SOURCES.TEXT" | translate }}</p>
</div>
<div class="model-source-links">
<div class="model-source-group">
<p class="model-source-group__label">
{{ "CALC.MODEL_SOURCES.FAVORITES_TITLE" | translate }}
</p>
@for (source of favoriteModelSources; track source.id) {
<a
class="model-source-link model-source-link--favorite"
[class.model-source-link--printables]="
source.id === 'PRINTABLES'
"
[class.model-source-link--makerworld]="
source.id === 'MAKERWORLD'
"
[href]="source.url"
target="_blank"
rel="noopener noreferrer"
>
<span class="model-source-link__name">{{ source.label }}</span>
<span class="model-source-link__description">{{
modelSourceDescriptionKey(source.id) | translate
}}</span>
</a>
}
</div>
<div class="model-source-group">
<p class="model-source-group__label">
{{ "CALC.MODEL_SOURCES.OTHERS_TITLE" | translate }}
</p>
<div class="model-source-compact-list">
@for (source of otherModelSources; track source.id) {
<a
class="model-source-link model-source-link--compact"
[href]="source.url"
target="_blank"
rel="noopener noreferrer"
>
<span class="model-source-link__name">{{
source.label
}}</span>
</a>
}
</div>
</div>
</div>
</div>
</section>
<section class="calculator-guide">
<app-card class="guide-card">
<div class="guide-header guide-header--compact">
<p class="guide-kicker">{{ "CALC.FAQ.KICKER" | translate }}</p>
<h2>{{ "CALC.FAQ.TITLE" | translate }}</h2>
<p>{{ "CALC.FAQ.SUBTITLE" | translate }}</p>
</div>
<div class="faq-list">
@for (faqId of faqIds; track faqId) {
<details class="faq-item">
<summary>{{ faqKey(faqId, "Q") | translate }}</summary>
<p>{{ faqKey(faqId, "A") | translate }}</p>
</details>
}
</div>
</app-card>
</section>
</div>
} }

View File

@@ -53,8 +53,8 @@
padding: 8px 16px; padding: 8px 16px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.92rem;
font-weight: 500; font-weight: 600;
color: var(--color-text-muted); color: var(--color-text-muted);
transition: all 0.2s ease; transition: all 0.2s ease;
user-select: none; user-select: none;
@@ -134,3 +134,257 @@
--brand-animation-scale-mobile: 0.84; --brand-animation-scale-mobile: 0.84;
--brand-animation-loader-loop-duration: 2.65s; --brand-animation-loader-loop-duration: 2.65s;
} }
.calculator-guides {
display: flex;
flex-direction: column;
gap: var(--space-6);
margin-top: var(--space-8);
padding-bottom: var(--space-10);
}
.calculator-guide {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.guide-header {
max-width: 48rem;
h2 {
margin: 0 0 var(--space-3);
font-size: clamp(1.4rem, 2vw, 1.9rem);
line-height: 1.2;
}
p {
margin: 0;
color: var(--color-text-muted);
line-height: 1.65;
}
}
.guide-header--compact {
margin-bottom: var(--space-4);
}
.guide-kicker {
margin: 0 0 var(--space-2);
color: var(--color-text-muted);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.guide-card {
h3 {
margin: 0 0 var(--space-3);
font-size: 1.05rem;
}
p {
margin: 0;
color: var(--color-text-muted);
line-height: 1.65;
}
}
.model-source-section {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-6);
padding: var(--space-2) 0;
@media (min-width: 960px) {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: start;
}
}
.model-source-intro {
max-width: 34rem;
h2 {
margin: 0 0 var(--space-3);
font-size: clamp(1.35rem, 2vw, 1.8rem);
line-height: 1.2;
}
p {
margin: 0;
color: var(--color-text-muted);
line-height: 1.7;
}
}
.model-source-links {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.model-source-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.model-source-group__label {
margin: 0 0 var(--space-1);
color: var(--color-text);
font-size: 0.85rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.model-source-link {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--color-border);
color: inherit;
text-decoration: none;
transition:
color 0.2s ease,
border-color 0.2s ease,
transform 0.2s ease;
&:hover {
color: var(--color-text);
border-color: rgba(250, 207, 10, 0.7);
transform: translateX(2px);
}
}
.model-source-link--favorite {
--favorite-accent: var(--color-brand);
padding: var(--space-3) var(--space-4);
border: 1px solid
color-mix(in srgb, var(--favorite-accent) 38%, var(--color-border));
border-radius: var(--radius-md);
background: color-mix(
in srgb,
var(--favorite-accent) 12%,
var(--color-bg-card)
);
box-shadow: inset 0 0 0 1px
color-mix(in srgb, var(--favorite-accent) 16%, #fff);
&:hover {
color: var(--color-text);
background: color-mix(
in srgb,
var(--favorite-accent) 18%,
var(--color-bg-card)
);
border-color: color-mix(
in srgb,
var(--favorite-accent) 70%,
var(--color-border)
);
transform: translateX(2px);
}
}
.model-source-link--printables {
--favorite-accent: #f1872a;
}
.model-source-link--makerworld {
--favorite-accent: #00b140;
}
.model-source-link__name {
position: relative;
width: fit-content;
font-size: 1rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
}
.model-source-link--favorite .model-source-link__name::after {
content: " ->";
position: static;
font-weight: 600;
color: var(--favorite-accent);
}
.model-source-link--favorite .model-source-link__name {
text-decoration: underline;
text-decoration-thickness: 2px;
text-decoration-color: var(--favorite-accent);
text-underline-offset: 0.2em;
}
.model-source-link__description {
color: var(--color-text-muted);
line-height: 1.55;
}
.model-source-compact-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2) var(--space-4);
}
.model-source-link--compact {
display: inline-flex;
align-items: center;
padding: 0;
border: 0;
background: transparent;
transform: none;
&:hover {
transform: none;
}
}
.model-source-link--compact .model-source-link__name {
font-size: 0.92rem;
font-weight: 600;
color: var(--color-text-muted);
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: currentColor;
}
.model-source-link--compact .model-source-link__name::after {
content: " ->";
font-weight: 500;
}
.faq-list {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.faq-item {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-muted);
padding: var(--space-4);
summary {
cursor: pointer;
font-weight: 600;
color: var(--color-text);
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
p {
margin: var(--space-3) 0 0;
color: var(--color-text-muted);
line-height: 1.65;
}
}

View File

@@ -2,7 +2,9 @@ import { of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CalculatorPageComponent } from './calculator-page.component'; import { CalculatorPageComponent } from './calculator-page.component';
import { import {
PendingCalculatorDraft,
QuoteEstimatorService, QuoteEstimatorService,
QuoteRequest,
QuoteResult, QuoteResult,
} from './services/quote-estimator.service'; } from './services/quote-estimator.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
@@ -31,15 +33,54 @@ describe('CalculatorPageComponent', () => {
notes, notes,
}); });
const createDraftRequest = (): QuoteRequest => ({
items: [
{
file: new File(['mesh'], 'part-a.stl', { type: 'model/stl' }),
quantity: 2,
material: 'PLA',
quality: 'standard',
color: 'Black',
supportEnabled: true,
infillDensity: 15,
infillPattern: 'grid',
layerHeight: 0.2,
nozzleDiameter: 0.4,
},
],
material: 'PLA',
quality: 'standard',
notes: 'draft note',
infillDensity: 15,
infillPattern: 'grid',
supportEnabled: true,
layerHeight: 0.2,
nozzleDiameter: 0.4,
mode: 'easy',
});
function createComponent() { function createComponent() {
const estimator = jasmine.createSpyObj<QuoteEstimatorService>( const estimator = jasmine.createSpyObj<QuoteEstimatorService>(
'QuoteEstimatorService', 'QuoteEstimatorService',
['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'], [
'updateLineItem',
'getQuoteSession',
'mapSessionToQuoteResult',
'setPendingCalculatorDraft',
'consumePendingCalculatorDraft',
],
); );
const router = jasmine.createSpyObj<Router>('Router', ['navigate']); const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
const route = { const route = {
data: of({}), data: of({}),
queryParams: of({}), queryParams: of({}),
snapshot: {
routeConfig: { path: 'basic' },
queryParams: {},
queryParamMap: {
get: () => null,
},
},
} as unknown as ActivatedRoute; } as unknown as ActivatedRoute;
const languageService = jasmine.createSpyObj<LanguageService>( const languageService = jasmine.createSpyObj<LanguageService>(
'LanguageService', 'LanguageService',
@@ -55,13 +96,25 @@ describe('CalculatorPageComponent', () => {
const uploadForm = jasmine.createSpyObj<UploadFormComponent>( const uploadForm = jasmine.createSpyObj<UploadFormComponent>(
'UploadFormComponent', 'UploadFormComponent',
['updateItemQuantityByIndex', 'updateItemQuantityByName'], [
'updateItemQuantityByIndex',
'updateItemQuantityByName',
'getCurrentRequestDraft',
'restoreRequestDraft',
],
); );
uploadForm.sameSettingsForAll = jasmine
.createSpy('sameSettingsForAll')
.and.returnValue(true) as any;
uploadForm.selectedFile = jasmine
.createSpy('selectedFile')
.and.returnValue(null) as any;
component.uploadForm = uploadForm; component.uploadForm = uploadForm;
return { return {
component, component,
estimator, estimator,
route,
uploadForm, uploadForm,
}; };
} }
@@ -109,4 +162,80 @@ describe('CalculatorPageComponent', () => {
expect(component.result()?.notes).toBe('persisted notes'); expect(component.result()?.notes).toBe('persisted notes');
expect(component.result()?.items[0].quantity).toBe(1); expect(component.result()?.items[0].quantity).toBe(1);
}); });
it('builds mode-specific content keys', () => {
const { component } = createComponent();
component.mode.set('easy');
expect(component.modeContentKey('TITLE')).toBe('CALC.MODES.BASIC.TITLE');
component.mode.set('advanced');
expect(component.modeContentKey('TITLE')).toBe('CALC.MODES.ADVANCED.TITLE');
});
it('exposes the expected external model sources and faq entries', () => {
const { component } = createComponent();
expect(component.favoriteModelSources.map((entry) => entry.id)).toEqual([
'PRINTABLES',
'MAKERWORLD',
]);
expect(component.otherModelSources.map((entry) => entry.id)).toEqual([
'THINGIVERSE',
'THANGS',
'CULTS3D',
'YEGGI',
]);
expect(component.modelSources.map((entry) => entry.id)).toEqual([
'PRINTABLES',
'MAKERWORLD',
'THINGIVERSE',
'THANGS',
'CULTS3D',
'YEGGI',
]);
expect(component.faqIds).toEqual([
'FILES',
'MODE',
'NO_MODEL',
'PRICE',
'BEFORE_UPLOAD',
]);
});
it('stores the current draft before switching mode without a session', () => {
const { component, estimator, uploadForm } = createComponent();
const draftRequest = createDraftRequest();
uploadForm.getCurrentRequestDraft.and.returnValue(draftRequest);
(uploadForm.sameSettingsForAll as jasmine.Spy).and.returnValue(false);
(uploadForm.selectedFile as jasmine.Spy).and.returnValue(
draftRequest.items[0].file,
);
component.switchMode('advanced');
expect(estimator.setPendingCalculatorDraft).toHaveBeenCalledWith({
request: draftRequest,
sameSettingsForAll: false,
selectedFileName: 'part-a.stl',
});
});
it('restores a pending draft after view init when there is no session', () => {
const { component, estimator, uploadForm } = createComponent();
const draftRequest = createDraftRequest();
const pendingDraft: PendingCalculatorDraft = {
request: draftRequest,
sameSettingsForAll: true,
selectedFileName: 'part-a.stl',
};
estimator.consumePendingCalculatorDraft.and.returnValue(pendingDraft);
component.ngAfterViewInit();
expect(uploadForm.restoreRequestDraft).toHaveBeenCalledWith(draftRequest, {
sameSettingsForAll: true,
selectedFileName: 'part-a.stl',
});
});
}); });

View File

@@ -1,4 +1,5 @@
import { import {
AfterViewInit,
Component, Component,
computed, computed,
signal, signal,
@@ -21,6 +22,7 @@ import { BrandAnimationLogoComponent } from '../../shared/components/brand-anima
import { UploadFormComponent } from './components/upload-form/upload-form.component'; import { UploadFormComponent } from './components/upload-form/upload-form.component';
import { QuoteResultComponent } from './components/quote-result/quote-result.component'; import { QuoteResultComponent } from './components/quote-result/quote-result.component';
import { import {
PendingCalculatorDraft,
QuoteRequest, QuoteRequest,
QuoteResult, QuoteResult,
QuoteEstimatorService, QuoteEstimatorService,
@@ -57,7 +59,7 @@ type TrackedPrintSettings = {
templateUrl: './calculator-page.component.html', templateUrl: './calculator-page.component.html',
styleUrl: './calculator-page.component.scss', styleUrl: './calculator-page.component.scss',
}) })
export class CalculatorPageComponent implements OnInit { export class CalculatorPageComponent implements OnInit, AfterViewInit {
private readonly isBrowser: boolean; private readonly isBrowser: boolean;
mode = signal<'easy' | 'advanced'>('easy'); mode = signal<'easy' | 'advanced'>('easy');
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload'); step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
@@ -71,6 +73,57 @@ export class CalculatorPageComponent implements OnInit {
isZeroQuoteError = computed( isZeroQuoteError = computed(
() => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE', () => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE',
); );
readonly faqIds = [
'FILES',
'MODE',
'NO_MODEL',
'PRICE',
'BEFORE_UPLOAD',
] as const;
readonly modelSources = [
{
id: 'PRINTABLES',
label: 'Printables',
url: 'https://www.printables.com',
},
{
id: 'MAKERWORLD',
label: 'MakerWorld',
url: 'https://makerworld.com',
},
{
id: 'THINGIVERSE',
label: 'Thingiverse',
url: 'https://www.thingiverse.com',
},
{
id: 'THANGS',
label: 'Thangs',
url: 'https://thangs.com',
},
{
id: 'CULTS3D',
label: 'Cults3D',
url: 'https://cults3d.com',
},
{
id: 'YEGGI',
label: 'Yeggi',
url: 'https://www.yeggi.com',
},
] as const;
readonly favoriteModelSourceIds = ['PRINTABLES', 'MAKERWORLD'] as const;
readonly favoriteModelSources = this.modelSources.filter((source) =>
this.favoriteModelSourceIds.includes(
source.id as (typeof this.favoriteModelSourceIds)[number],
),
);
readonly otherModelSources = this.modelSources.filter(
(source) =>
!this.favoriteModelSourceIds.includes(
source.id as (typeof this.favoriteModelSourceIds)[number],
),
);
orderSuccess = signal(false); orderSuccess = signal(false);
requiresRecalculation = signal(false); requiresRecalculation = signal(false);
@@ -115,6 +168,31 @@ export class CalculatorPageComponent implements OnInit {
}); });
} }
ngAfterViewInit() {
const pendingDraft = this.estimator.consumePendingCalculatorDraft();
if (!pendingDraft || this.currentSessionId()) {
return;
}
this.uploadForm?.restoreRequestDraft(pendingDraft.request, {
sameSettingsForAll: pendingDraft.sameSettingsForAll,
selectedFileName: pendingDraft.selectedFileName,
});
}
modeContentKey(field: string): string {
const modeKey = this.mode() === 'easy' ? 'BASIC' : 'ADVANCED';
return `CALC.MODES.${modeKey}.${field}`;
}
modelSourceDescriptionKey(id: string): string {
return `CALC.MODEL_SOURCES.ITEMS.${id}`;
}
faqKey(id: string, field: string): string {
return `CALC.FAQ.ITEMS.${id}.${field}`;
}
loadSession(sessionId: string) { loadSession(sessionId: string) {
this.loading.set(true); this.loading.set(true);
this.estimator.getQuoteSession(sessionId).subscribe({ this.estimator.getQuoteSession(sessionId).subscribe({
@@ -533,7 +611,7 @@ export class CalculatorPageComponent implements OnInit {
if (this.cadSessionLocked()) return; if (this.cadSessionLocked()) return;
const targetPath = nextMode === 'easy' ? 'basic' : 'advanced'; const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
const currentPath = this.route.snapshot.routeConfig?.path; const currentPath = this.route.snapshot?.routeConfig?.path;
this.mode.set(nextMode); this.mode.set(nextMode);
@@ -541,12 +619,54 @@ export class CalculatorPageComponent implements OnInit {
return; return;
} }
if (!this.currentSessionId()) {
this.persistPendingDraftForModeSwitch();
}
this.router.navigate(['..', targetPath], { this.router.navigate(['..', targetPath], {
relativeTo: this.route, relativeTo: this.route,
queryParamsHandling: 'preserve', queryParamsHandling: 'preserve',
}); });
} }
private currentSessionId(): string | null {
const fromResult = this.result()?.sessionId;
if (fromResult) {
return fromResult;
}
const snapshot = this.route.snapshot;
const fromQueryParamMap = snapshot?.queryParamMap?.get?.('session');
if (fromQueryParamMap) {
return fromQueryParamMap;
}
const fromQueryParams = snapshot?.queryParams?.['session'];
return typeof fromQueryParams === 'string' && fromQueryParams.length > 0
? fromQueryParams
: null;
}
private persistPendingDraftForModeSwitch(): void {
if (!this.uploadForm) {
this.estimator.setPendingCalculatorDraft(null);
return;
}
const request = this.uploadForm.getCurrentRequestDraft();
if (!request.items.length) {
this.estimator.setPendingCalculatorDraft(null);
return;
}
const draft: PendingCalculatorDraft = {
request,
sameSettingsForAll: this.uploadForm.sameSettingsForAll(),
selectedFileName: this.uploadForm.selectedFile()?.name ?? null,
};
this.estimator.setPendingCalculatorDraft(draft);
}
private toTrackedSettingsFromRequest( private toTrackedSettingsFromRequest(
req: QuoteRequest, req: QuoteRequest,
): TrackedPrintSettings { ): TrackedPrintSettings {

View File

@@ -573,12 +573,17 @@ export class UploadFormComponent implements OnInit {
const patch: any = {}; const patch: any = {};
if (settings.materialCode) patch.material = settings.materialCode; if (settings.materialCode) patch.material = settings.materialCode;
if (settings.quality) {
patch.quality = this.normalizeQualityValue(settings.quality);
}
const layer = Number(settings.layerHeightMm); const layer = Number(settings.layerHeightMm);
if (Number.isFinite(layer)) { if (Number.isFinite(layer)) {
patch.layerHeight = layer; patch.layerHeight = layer;
patch.quality = if (!patch.quality) {
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard'; patch.quality =
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard';
}
} }
const nozzle = Number(settings.nozzleDiameterMm); const nozzle = Number(settings.nozzleDiameterMm);
@@ -706,6 +711,69 @@ export class UploadFormComponent implements OnInit {
this.emitItemSettingsDiffChange(); this.emitItemSettingsDiffChange();
} }
restoreRequestDraft(
request: QuoteRequest,
options?: {
sameSettingsForAll?: boolean;
selectedFileName?: string | null;
},
) {
if (!request?.items?.length) {
return;
}
this.setFiles(request.items.map((item) => item.file));
this.patchSettings({
materialCode: request.material,
quality: request.quality,
layerHeightMm: request.layerHeight,
nozzleDiameterMm: request.nozzleDiameter,
infillPercent: request.infillDensity,
infillPattern: request.infillPattern,
supportsEnabled: request.supportEnabled,
notes: request.notes,
});
const sameSettingsForAll =
this.mode() === 'advanced' ? (options?.sameSettingsForAll ?? true) : true;
this.onSameSettingsToggle(sameSettingsForAll);
request.items.forEach((item, index) => {
this.updateItemQuantityByIndex(index, Number(item.quantity || 1));
this.setItemPrintSettingsByIndex(index, {
material: item.material ?? request.material,
quality: item.quality ?? request.quality,
nozzleDiameter: item.nozzleDiameter ?? request.nozzleDiameter,
layerHeight: item.layerHeight ?? request.layerHeight,
infillDensity: item.infillDensity ?? request.infillDensity,
infillPattern: item.infillPattern ?? request.infillPattern,
supportEnabled: item.supportEnabled ?? request.supportEnabled,
});
if (item.color) {
this.updateItemColor(index, {
colorName: item.color,
filamentVariantId: item.filamentVariantId,
});
}
});
const selectedFileName = this.normalizeFileName(
options?.selectedFileName ?? '',
);
const target =
this.items().find(
(item) => this.normalizeFileName(item.file.name) === selectedFileName,
) ?? this.items()[this.items().length - 1];
if (target) {
this.selectFile(target.file);
}
this.emitPrintSettingsChange();
this.emitItemSettingsDiffChange();
}
getCurrentRequestDraft(): QuoteRequest { getCurrentRequestDraft(): QuoteRequest {
const defaults = this.getCurrentGlobalItemDefaults(); const defaults = this.getCurrentGlobalItemDefaults();

View File

@@ -30,6 +30,12 @@ export interface QuoteRequest {
mode: 'easy' | 'advanced'; mode: 'easy' | 'advanced';
} }
export interface PendingCalculatorDraft {
request: QuoteRequest;
sameSettingsForAll: boolean;
selectedFileName?: string | null;
}
export interface QuoteItem { export interface QuoteItem {
id?: string; id?: string;
fileName: string; fileName: string;
@@ -130,6 +136,7 @@ export class QuoteEstimatorService {
files: File[]; files: File[];
message: string; message: string;
} | null>(null); } | null>(null);
private pendingCalculatorDraft = signal<PendingCalculatorDraft | null>(null);
getOptions(): Observable<OptionsResponse> { getOptions(): Observable<OptionsResponse> {
const headers: any = {}; const headers: any = {};
@@ -341,6 +348,16 @@ export class QuoteEstimatorService {
return data; return data;
} }
setPendingCalculatorDraft(data: PendingCalculatorDraft | null) {
this.pendingCalculatorDraft.set(data);
}
consumePendingCalculatorDraft(): PendingCalculatorDraft | null {
const data = this.pendingCalculatorDraft();
this.pendingCalculatorDraft.set(null);
return data;
}
getLineItemContent( getLineItemContent(
sessionId: string, sessionId: string,
lineItemId: string, lineItemId: string,

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,90 @@ 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', });
);
expect(errorResponse?.status).toBe(404); 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'
);
});
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,22 +290,14 @@ 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, {
), params: this.buildLangParams(),
switchMap((product) => { });
if (!product) {
return throwError(() => ({
status: 404,
}));
}
return this.getProduct(product.slug);
}),
);
} }
private normalizePublicPath(value: string | null | undefined): string { private normalizePublicPath(value: string | null | undefined): string {
@@ -314,6 +306,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 +462,10 @@ export class ShopService {
} }
private buildLangParams(): HttpParams { private buildLangParams(): HttpParams {
return new HttpParams().set('lang', this.languageService.selectedLang()); // Public shop URLs are localized. During direct loads the translation
// service can still momentarily reflect the browser language, while the
// route language has already been resolved from the URL.
return new HttpParams().set('lang', this.languageService.currentLang());
} }
private setCart(cart: ShopCartResponse): void { private setCart(cart: ShopCartResponse): void {

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

@@ -53,12 +53,12 @@
"TITLE": "3D-Druck-Angebotsrechner | 3D fab", "TITLE": "3D-Druck-Angebotsrechner | 3D fab",
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.", "DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
"BASIC": { "BASIC": {
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab", "TITLE": "Schnelles 3D-Druck-Angebot fuer fertige Dateien | 3D fab",
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow." "DESCRIPTION": "Laden Sie eine fertige STL- oder 3MF-Datei hoch und erhalten Sie in Sekunden einen passenden 3D-Druck-Preis mit dem Basis-Rechner."
}, },
"ADVANCED": { "ADVANCED": {
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab", "TITLE": "Praezises 3D-Druck-Angebot mit Material und Einstellungen | 3D fab",
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing." "DESCRIPTION": "Waehlen Sie Material, Schichthoehe, Stuetzstrukturen und Infill fuer ein genaueres 3D-Druck-Angebot."
} }
}, },
"SHOP": { "SHOP": {
@@ -108,11 +108,84 @@
"CALC": { "CALC": {
"TITLE": "3D-Angebot berechnen", "TITLE": "3D-Angebot berechnen",
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.", "SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
"EYEBROW": "3D-Druck-Angebot online",
"CTA_START": "Jetzt starten", "CTA_START": "Jetzt starten",
"BUSINESS": "Unternehmen", "BUSINESS": "Unternehmen",
"PRIVATE": "Privat", "PRIVATE": "Privat",
"MODE_EASY": "Basis", "MODE_EASY": "Basis",
"MODE_ADVANCED": "Erweitert", "MODE_ADVANCED": "Erweitert",
"WHEN_TO_USE_LABEL": "Wann verwenden:",
"MODES": {
"BASIC": {
"TOGGLE_HINT": "Fuer fertige Dateien",
"TITLE": "Schnelles Angebot fuer fertige 3D-Dateien",
"SUBTITLE": "Der Basis-Modus ist fuer Nutzer gedacht, die in Sekunden einen passenden Preis erhalten wollen, ohne technische Druckparameter im Detail zu verwalten.",
"UPLOAD_HELP": "Laden Sie eine bereits exportierte STL- oder 3MF-Datei hoch, waehlen Sie Material und Qualitaet und erhalten Sie sofort eine klare Preis- und Zeitabschaetzung.",
"WHEN_TO_USE": "Verwenden Sie Basis, wenn Ihr Modell fertig ist und Sie vor allem ein schnelles, einfaches Angebot brauchen."
},
"ADVANCED": {
"TOGGLE_HINT": "Mehr Kontrolle ueber Druck und Material",
"TITLE": "Genaueres Angebot mit Material und Einstellungen",
"SUBTITLE": "Der erweiterte Modus ist fuer Nutzer gedacht, die durch relevante Druckeinstellungen eine genauere Schaetzung erhalten wollen.",
"UPLOAD_HELP": "Laden Sie Ihre 3D-Datei hoch und stellen Sie Material, Schichthoehe, Stuetzstrukturen, Infill und Duese ein, um naeher an die spaetere Druckkonfiguration zu kommen.",
"WHEN_TO_USE": "Verwenden Sie Erweitert, wenn Sie Materialien oder Einstellungen vergleichen und mehr Kontrolle ueber das Ergebnis wollen."
}
},
"COMPARE_KICKER": "Schnelle Auswahl",
"COMPARE_TITLE": "Basis oder Erweitert?",
"COMPARE_SUBTITLE": "Die beiden Seiten sind fuer unterschiedliche Anwendungsfaelle gedacht. Starten Sie mit Basis fuer Geschwindigkeit und wechseln Sie zu Erweitert, wenn Material oder Druckeinstellungen wichtig werden.",
"COMPARE": {
"BASIC": {
"TITLE": "Basis: schnelles Angebot",
"TEXT": "Ideal, wenn Sie bereits eine fertige Datei haben und schnell einen passenden Preis moechten, ohne technische Druckdetails einzustellen."
},
"ADVANCED": {
"TITLE": "Erweitert: genaueres Angebot",
"TEXT": "Ideal, wenn Sie Material, Schichthoehe, Stuetzstrukturen, Infill und weitere Einstellungen waehlen moechten, die das Endergebnis staerker beeinflussen."
}
},
"MODEL_SOURCES": {
"KICKER": "3D-Modelle",
"TITLE": "Wo Sie 3D-Modelle zum Hochladen finden",
"TEXT": "Wenn Sie noch keine Datei haben, koennen Sie auf diesen Plattformen starten. Pruefen Sie vor dem Hochladen immer Lizenz, Abmessungen und Modellqualitaet.",
"FAVORITES_TITLE": "Unsere Favoriten",
"OTHERS_TITLE": "Weitere",
"ITEMS": {
"PRINTABLES": "Grosses Verzeichnis druckbarer Modelle aus der Maker-Community.",
"MAKERWORLD": "Repository mit druckfertigen Modellen, besonders nuetzlich fuer Maker-Projekte.",
"THINGIVERSE": "Langjaehriges Archiv mit sehr vielen kostenlosen Modellen in verschiedenen Kategorien.",
"THANGS": "3D-Suchmaschine und Repository mit verwandten Versionen und Sammlungen.",
"CULTS3D": "Marktplatz mit kostenlosen und kostenpflichtigen Modellen fuer viele Einsatzzwecke.",
"YEGGI": "Suchmaschine, die 3D-Modelle aus verschiedenen Seiten zusammenfuehrt."
}
},
"FAQ": {
"KICKER": "Hauefige Fragen",
"TITLE": "Mini-FAQ",
"SUBTITLE": "Kurze Antworten auf die Fragen, die nicht-technische Nutzer am haeufigsten ausbremsen.",
"ITEMS": {
"FILES": {
"Q": "Welche Dateien kann ich hochladen?",
"A": "Sie koennen STL- oder 3MF-Dateien hochladen. Wenn Sie ein Modell von einer externen Plattform herunterladen, stellen Sie sicher, dass es sich wirklich um eine 3D-Datei und nicht nur um Bilder oder Dokumentation handelt."
},
"MODE": {
"Q": "Wie waehle ich zwischen Basis und Erweitert?",
"A": "Waehlen Sie Basis fuer ein schnelles Angebot zu einer fertigen Datei. Waehlen Sie Erweitert, wenn Sie Materialien vergleichen oder Einstellungen aendern moechten, die Zeit, Gewicht und Preis beeinflussen."
},
"NO_MODEL": {
"Q": "Was, wenn ich noch kein 3D-Modell habe?",
"A": "Sie koennen auf den oben genannten Plattformen suchen oder uns kontaktieren, wenn Sie Hilfe bei CAD-Konstruktion oder Anpassungen brauchen."
},
"PRICE": {
"Q": "Ist der angezeigte Preis schon verlaesslich?",
"A": "Es handelt sich um eine automatische Schaetzung auf Basis Ihrer Datei und der gewaehlten Einstellungen. Der erweiterte Modus ist genauer, weil mehr Druckparameter beruecksichtigt werden."
},
"BEFORE_UPLOAD": {
"Q": "Was sollte ich vor dem Hochladen pruefen?",
"A": "Pruefen Sie, ob das Modell den richtigen Massstab hat, die Datei nicht beschaedigt ist und die Geometrie wirklich dem finalen Bauteil entspricht, das Sie drucken moechten."
}
}
},
"UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher", "UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher",
"UPLOAD_SUB": "Wir unterstützen STL, 3MF bis 50MB", "UPLOAD_SUB": "Wir unterstützen STL, 3MF bis 50MB",
"MATERIAL": "Material", "MATERIAL": "Material",
@@ -613,11 +686,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

@@ -53,12 +53,12 @@
"TITLE": "3D Printing Quote Calculator | 3D fab", "TITLE": "3D Printing Quote Calculator | 3D fab",
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.", "DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
"BASIC": { "BASIC": {
"TITLE": "Basic 3D Printing Calculator | 3D fab", "TITLE": "Fast 3D printing quote for ready files | 3D fab",
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow." "DESCRIPTION": "Upload a ready STL or 3MF file and get a correct 3D printing price in seconds with the basic calculator."
}, },
"ADVANCED": { "ADVANCED": {
"TITLE": "Advanced 3D Printing Calculator | 3D fab", "TITLE": "Precise 3D printing quote with materials and settings | 3D fab",
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing." "DESCRIPTION": "Set material, layer height, supports and infill to get a more precise 3D printing quote."
} }
}, },
"SHOP": { "SHOP": {
@@ -108,11 +108,84 @@
"CALC": { "CALC": {
"TITLE": "3D Print Calculator", "TITLE": "3D Print Calculator",
"SUBTITLE": "Upload your 3D file (STL, 3MF) and get an instant estimate of costs and print time.", "SUBTITLE": "Upload your 3D file (STL, 3MF) and get an instant estimate of costs and print time.",
"EYEBROW": "Online 3D printing quote",
"CTA_START": "Start Now", "CTA_START": "Start Now",
"BUSINESS": "Business", "BUSINESS": "Business",
"PRIVATE": "Private", "PRIVATE": "Private",
"MODE_EASY": "Quick", "MODE_EASY": "Quick",
"MODE_ADVANCED": "Advanced", "MODE_ADVANCED": "Advanced",
"WHEN_TO_USE_LABEL": "When to use it:",
"MODES": {
"BASIC": {
"TOGGLE_HINT": "For ready-to-print files",
"TITLE": "Fast quote for ready 3D files",
"SUBTITLE": "Basic mode is for users who want a correct price in seconds without dealing with advanced technical settings.",
"UPLOAD_HELP": "Upload an STL or 3MF file that is already exported, choose material and quality, and get a clear price and lead-time estimate right away.",
"WHEN_TO_USE": "Use Basic if your model is already final and you mainly need a simple, fast quote."
},
"ADVANCED": {
"TOGGLE_HINT": "More control over print and material",
"TITLE": "More precise quote with materials and settings",
"SUBTITLE": "Advanced mode is for users who want a more precise estimate by adjusting relevant print settings.",
"UPLOAD_HELP": "Upload your 3D file and set material, layer height, supports, infill and nozzle size to get closer to the final print configuration.",
"WHEN_TO_USE": "Use Advanced if you want to compare materials or settings and need more control over the outcome."
}
},
"COMPARE_KICKER": "Quick choice",
"COMPARE_TITLE": "Basic or Advanced?",
"COMPARE_SUBTITLE": "The two pages cover different use cases. Start with Basic for speed, then switch to Advanced when material or print settings matter.",
"COMPARE": {
"BASIC": {
"TITLE": "Basic: fast quote",
"TEXT": "Best if you already have a ready file and want a correct price quickly, without going into technical print details."
},
"ADVANCED": {
"TITLE": "Advanced: more precise quote",
"TEXT": "Best if you want to choose material, layer height, supports, infill and other settings that have more impact on the final result."
}
},
"MODEL_SOURCES": {
"KICKER": "3D models",
"TITLE": "Where to find 3D models to upload",
"TEXT": "If you do not have a file yet, start from these platforms. Always check license, dimensions and model quality before uploading it to the calculator.",
"FAVORITES_TITLE": "Our favorites",
"OTHERS_TITLE": "Others",
"ITEMS": {
"PRINTABLES": "Large catalog of printable models shared by the maker community.",
"MAKERWORLD": "Repository of ready-to-print models, especially useful for maker projects.",
"THINGIVERSE": "Long-running archive with a very large number of free models in many categories.",
"THANGS": "3D search engine and repository with related versions and collections.",
"CULTS3D": "Marketplace with both free and paid models across many use cases.",
"YEGGI": "Search engine that aggregates 3D models from different sites."
}
},
"FAQ": {
"KICKER": "Common questions",
"TITLE": "Mini FAQ",
"SUBTITLE": "Quick answers to the questions that most often block non-technical users.",
"ITEMS": {
"FILES": {
"Q": "Which files can I upload?",
"A": "You can upload STL or 3MF files. If you download a model from an external portal, make sure it is an actual 3D file and not just images or documentation."
},
"MODE": {
"Q": "How do I choose between Basic and Advanced?",
"A": "Choose Basic if you want a fast quote for a ready file. Choose Advanced if you need to compare materials or change settings that affect time, weight and price."
},
"NO_MODEL": {
"Q": "What if I do not have a 3D model yet?",
"A": "You can search on the platforms listed above or contact us if you need help with CAD design or adaptation."
},
"PRICE": {
"Q": "Is the shown price already reliable?",
"A": "It is an automatic estimate based on your file and the selected settings. Advanced mode is more precise because it accounts for more print parameters."
},
"BEFORE_UPLOAD": {
"Q": "What should I check before uploading?",
"A": "Check that the model has the correct scale, that the file is not corrupted and that the geometry really matches the final part you want to print."
}
}
},
"UPLOAD_LABEL": "Drag your 3D file here", "UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF up to 50MB", "UPLOAD_SUB": "Supports STL, 3MF up to 50MB",
"MATERIAL": "Material", "MATERIAL": "Material",

View File

@@ -27,12 +27,12 @@
"TITLE": "Calculateur de devis impression 3D | 3D fab", "TITLE": "Calculateur de devis impression 3D | 3D fab",
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.", "DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
"BASIC": { "BASIC": {
"TITLE": "Calculateur impression 3D de base | 3D fab", "TITLE": "Devis impression 3D rapide pour fichiers prets | 3D fab",
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base." "DESCRIPTION": "Chargez un fichier STL ou 3MF deja pret et obtenez en quelques secondes un prix d impression 3D fiable avec le calculateur de base."
}, },
"ADVANCED": { "ADVANCED": {
"TITLE": "Calculateur impression 3D avancé | 3D fab", "TITLE": "Devis impression 3D precis avec materiaux et reglages | 3D fab",
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing." "DESCRIPTION": "Definissez le materiau, la hauteur de couche, les supports et le remplissage pour obtenir un devis d impression 3D plus precis."
} }
}, },
"SHOP": { "SHOP": {
@@ -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",
@@ -140,11 +140,84 @@
"CALC": { "CALC": {
"TITLE": "Calculer un devis 3D", "TITLE": "Calculer un devis 3D",
"SUBTITLE": "Chargez votre fichier 3D (STL, 3MF), réglez la qualité et la couleur puis calculez immédiatement prix et délais.", "SUBTITLE": "Chargez votre fichier 3D (STL, 3MF), réglez la qualité et la couleur puis calculez immédiatement prix et délais.",
"EYEBROW": "Devis impression 3D en ligne",
"CTA_START": "Commencer maintenant", "CTA_START": "Commencer maintenant",
"BUSINESS": "Entreprises", "BUSINESS": "Entreprises",
"PRIVATE": "Particuliers", "PRIVATE": "Particuliers",
"MODE_EASY": "Base", "MODE_EASY": "Base",
"MODE_ADVANCED": "Avancée", "MODE_ADVANCED": "Avancée",
"WHEN_TO_USE_LABEL": "Quand l'utiliser :",
"MODES": {
"BASIC": {
"TOGGLE_HINT": "Pour fichiers deja prets",
"TITLE": "Devis rapide pour fichiers 3D deja prets",
"SUBTITLE": "Le mode Base est pense pour les utilisateurs qui veulent un prix fiable en quelques secondes sans gerer des reglages techniques avances.",
"UPLOAD_HELP": "Chargez un fichier STL ou 3MF deja exporte, choisissez le materiau et la qualite, puis obtenez tout de suite une estimation claire du prix et du delai.",
"WHEN_TO_USE": "Utilisez Base si votre modele est deja finalise et que vous voulez surtout un devis simple et rapide."
},
"ADVANCED": {
"TOGGLE_HINT": "Plus de controle sur l impression",
"TITLE": "Devis plus precis avec materiaux et reglages",
"SUBTITLE": "Le mode avance est pense pour les utilisateurs qui veulent une estimation plus precise en ajustant les parametres d impression importants.",
"UPLOAD_HELP": "Chargez votre fichier 3D et reglez le materiau, la hauteur de couche, les supports, le remplissage et la buse pour vous rapprocher de la configuration finale.",
"WHEN_TO_USE": "Utilisez Avancee si vous voulez comparer des materiaux ou des reglages et garder plus de controle sur le resultat."
}
},
"COMPARE_KICKER": "Choix rapide",
"COMPARE_TITLE": "Base ou Avancee ?",
"COMPARE_SUBTITLE": "Les deux pages repondent a des besoins differents. Commencez par Base pour aller vite, puis passez a Avancee quand le materiau ou les reglages d impression deviennent importants.",
"COMPARE": {
"BASIC": {
"TITLE": "Base : devis rapide",
"TEXT": "Ideal si vous avez deja un fichier pret et que vous voulez recevoir rapidement un prix fiable, sans entrer dans les details techniques."
},
"ADVANCED": {
"TITLE": "Avancee : devis plus precis",
"TEXT": "Ideal si vous voulez choisir le materiau, la hauteur de couche, les supports, le remplissage et d autres reglages qui influencent davantage le resultat final."
}
},
"MODEL_SOURCES": {
"KICKER": "Modeles 3D",
"TITLE": "Ou trouver des modeles 3D a charger",
"TEXT": "Si vous n avez pas encore de fichier, vous pouvez commencer par ces plateformes. Verifiez toujours la licence, les dimensions et la qualite du modele avant de le charger dans le calculateur.",
"FAVORITES_TITLE": "Nos preferes",
"OTHERS_TITLE": "Autres",
"ITEMS": {
"PRINTABLES": "Grand catalogue de modeles imprimables partages par la communaute maker.",
"MAKERWORLD": "Repository de modeles prets a imprimer, utile surtout pour les projets maker.",
"THINGIVERSE": "Archive historique avec un tres grand nombre de modeles gratuits dans de nombreuses categories.",
"THANGS": "Moteur de recherche et repository 3D avec versions et collections associees.",
"CULTS3D": "Marketplace avec des modeles gratuits et payants pour des usages tres varies.",
"YEGGI": "Moteur de recherche qui agrège des modeles 3D depuis plusieurs sites."
}
},
"FAQ": {
"KICKER": "Questions frequentes",
"TITLE": "Mini FAQ",
"SUBTITLE": "Des reponses rapides aux questions qui bloquent le plus souvent les utilisateurs non techniques.",
"ITEMS": {
"FILES": {
"Q": "Quels fichiers puis-je charger ?",
"A": "Vous pouvez charger des fichiers STL ou 3MF. Si vous telechargez un modele depuis un portail externe, verifiez qu il s agit bien d un vrai fichier 3D et pas seulement d images ou de documentation."
},
"MODE": {
"Q": "Comment choisir entre Base et Avancee ?",
"A": "Choisissez Base si vous voulez un devis rapide pour un fichier deja pret. Choisissez Avancee si vous devez comparer des materiaux ou modifier des reglages qui influencent le temps, le poids et le prix."
},
"NO_MODEL": {
"Q": "Et si je n ai pas encore de modele 3D ?",
"A": "Vous pouvez chercher sur les plateformes ci-dessus ou nous contacter si vous avez besoin d aide pour la conception ou l adaptation CAD."
},
"PRICE": {
"Q": "Le prix affiche est-il deja fiable ?",
"A": "C est une estimation automatique basee sur votre fichier et les reglages choisis. Le mode Avancee est plus precis car il tient compte d un plus grand nombre de parametres d impression."
},
"BEFORE_UPLOAD": {
"Q": "Que faut-il verifier avant le chargement ?",
"A": "Verifiez que le modele a la bonne echelle, que le fichier n est pas corrompu et que la geometrie correspond bien a la piece finale que vous voulez imprimer."
}
}
},
"UPLOAD_LABEL": "Glissez votre fichier 3D ici", "UPLOAD_LABEL": "Glissez votre fichier 3D ici",
"UPLOAD_SUB": "Nous prenons en charge STL, 3MF jusqu'à 50MB", "UPLOAD_SUB": "Nous prenons en charge STL, 3MF jusqu'à 50MB",
"MATERIAL": "Matériau", "MATERIAL": "Matériau",

View File

@@ -27,12 +27,12 @@
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab", "TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.", "DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
"BASIC": { "BASIC": {
"TITLE": "Calcolatore stampa 3D base | 3D fab", "TITLE": "Preventivo stampa 3D veloce da file pronti | 3D fab",
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base." "DESCRIPTION": "Carica un file STL o 3MF gia pronto e ricevi un prezzo corretto in pochi secondi con il calcolatore base."
}, },
"ADVANCED": { "ADVANCED": {
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab", "TITLE": "Preventivo stampa 3D preciso con materiali e impostazioni | 3D fab",
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale." "DESCRIPTION": "Configura materiale, layer, supporti e riempimento per ottenere un preventivo stampa 3D piu preciso."
} }
}, },
"SHOP": { "SHOP": {
@@ -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",
@@ -140,11 +140,84 @@
"CALC": { "CALC": {
"TITLE": "Calcola Preventivo 3D", "TITLE": "Calcola Preventivo 3D",
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.", "SUBTITLE": "Carica il tuo file 3D (STL, 3MF), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.",
"EYEBROW": "Preventivo stampa 3D online",
"CTA_START": "Inizia Ora", "CTA_START": "Inizia Ora",
"BUSINESS": "Aziende", "BUSINESS": "Aziende",
"PRIVATE": "Privati", "PRIVATE": "Privati",
"MODE_EASY": "Base", "MODE_EASY": "Base",
"MODE_ADVANCED": "Avanzata", "MODE_ADVANCED": "Avanzata",
"WHEN_TO_USE_LABEL": "Quando usarla:",
"MODES": {
"BASIC": {
"TOGGLE_HINT": "Per file gia pronti",
"TITLE": "Preventivo veloce per file 3D gia pronti",
"SUBTITLE": "La modalita Base e pensata per chi vuole un prezzo corretto in pochi secondi senza gestire parametri tecnici avanzati.",
"UPLOAD_HELP": "Carica un file STL o 3MF gia esportato, scegli materiale e qualita, poi ricevi subito una stima chiara di prezzo e tempo.",
"WHEN_TO_USE": "Usa Base se il modello e pronto e ti serve soprattutto un preventivo rapido e semplice."
},
"ADVANCED": {
"TOGGLE_HINT": "Piu controllo su stampa e materiale",
"TITLE": "Preventivo piu preciso con materiali e impostazioni",
"SUBTITLE": "La modalita Avanzata e pensata per chi vuole una stima piu precisa regolando parametri di stampa rilevanti.",
"UPLOAD_HELP": "Carica il file 3D e imposta materiale, layer, supporti, riempimento e ugello per avvicinare di piu il preventivo alla configurazione finale.",
"WHEN_TO_USE": "Usa Avanzata se devi confrontare materiali o impostazioni e vuoi piu controllo sul risultato."
}
},
"COMPARE_KICKER": "Scelta rapida",
"COMPARE_TITLE": "Basic o Advanced?",
"COMPARE_SUBTITLE": "Le due pagine servono a casi d uso diversi. Parti da Base per fare presto, passa ad Avanzata quando il materiale o le impostazioni fanno la differenza.",
"COMPARE": {
"BASIC": {
"TITLE": "Base: preventivo veloce",
"TEXT": "Ideale se hai un file gia pronto e vuoi ricevere un prezzo corretto in modo semplice, senza entrare nei dettagli tecnici."
},
"ADVANCED": {
"TITLE": "Avanzata: preventivo piu preciso",
"TEXT": "Ideale se vuoi scegliere materiale, layer, supporti, riempimento e altre impostazioni che incidono di piu sul risultato finale."
}
},
"MODEL_SOURCES": {
"KICKER": "Modelli 3D",
"TITLE": "Dove trovare modelli 3D da caricare",
"TEXT": "Se non hai ancora un file, puoi partire da queste piattaforme. Controlla sempre licenza, dimensioni e qualita del modello prima di caricarlo nel calcolatore.",
"FAVORITES_TITLE": "I nostri preferiti",
"OTHERS_TITLE": "Altri",
"ITEMS": {
"PRINTABLES": "Ampio catalogo di modelli stampabili condivisi dalla community.",
"MAKERWORLD": "Repository con modelli pronti alla stampa, utile soprattutto in ambito maker.",
"THINGIVERSE": "Archivio storico con moltissimi modelli gratuiti in diverse categorie.",
"THANGS": "Motore di ricerca e repository 3D con raccolte e versioni correlate.",
"CULTS3D": "Marketplace con modelli gratuiti e a pagamento per progetti molto diversi.",
"YEGGI": "Motore di ricerca che aggrega modelli 3D da siti differenti."
}
},
"FAQ": {
"KICKER": "Dubbi comuni",
"TITLE": "Mini FAQ",
"SUBTITLE": "Le risposte rapide alle domande che bloccano piu spesso chi non usa abitualmente la stampa 3D.",
"ITEMS": {
"FILES": {
"Q": "Che file posso caricare?",
"A": "Puoi caricare file STL o 3MF. Se scarichi un modello da un portale esterno, verifica che sia gia un file 3D pronto e non solo immagini o documentazione."
},
"MODE": {
"Q": "Come scelgo tra Base e Avanzata?",
"A": "Scegli Base se vuoi un preventivo rapido per un file gia pronto. Scegli Avanzata se devi confrontare materiali o modificare impostazioni che cambiano tempo, peso e costo."
},
"NO_MODEL": {
"Q": "E se non ho ancora il modello 3D?",
"A": "Puoi cercarlo nei portali suggeriti qui sopra oppure contattarci se ti serve aiuto con progettazione o adattamento CAD."
},
"PRICE": {
"Q": "Il prezzo mostrato e gia affidabile?",
"A": "E una stima automatica basata sul file e sulle impostazioni selezionate. In modalita Avanzata la stima e piu precisa perche considera piu parametri di stampa."
},
"BEFORE_UPLOAD": {
"Q": "Cosa conviene controllare prima del caricamento?",
"A": "Controlla che il modello abbia la scala corretta, che il file non sia corrotto e che la geometria sia davvero quella finale che vuoi stampare."
}
}
},
"UPLOAD_LABEL": "Trascina il tuo file 3D qui", "UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF fino a 50MB", "UPLOAD_SUB": "Supportiamo STL, 3MF fino a 50MB",
"MATERIAL": "Materiale", "MATERIAL": "Materiale",

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 382 382" xml:space="preserve">
<path style="fill:#0077B7;" d="M347.445,0H34.555C15.471,0,0,15.471,0,34.555v312.889C0,366.529,15.471,382,34.555,382h312.889
C366.529,382,382,366.529,382,347.444V34.555C382,15.471,366.529,0,347.445,0z M118.207,329.844c0,5.554-4.502,10.056-10.056,10.056
H65.345c-5.554,0-10.056-4.502-10.056-10.056V150.403c0-5.554,4.502-10.056,10.056-10.056h42.806
c5.554,0,10.056,4.502,10.056,10.056V329.844z M86.748,123.432c-22.459,0-40.666-18.207-40.666-40.666S64.289,42.1,86.748,42.1
s40.666,18.207,40.666,40.666S109.208,123.432,86.748,123.432z M341.91,330.654c0,5.106-4.14,9.246-9.246,9.246H286.73
c-5.106,0-9.246-4.14-9.246-9.246v-84.168c0-12.556,3.683-55.021-32.813-55.021c-28.309,0-34.051,29.066-35.204,42.11v97.079
c0,5.106-4.139,9.246-9.246,9.246h-44.426c-5.106,0-9.246-4.14-9.246-9.246V149.593c0-5.106,4.14-9.246,9.246-9.246h44.426
c5.106,0,9.246,4.14,9.246,9.246v15.655c10.497-15.753,26.097-27.912,59.312-27.912c73.552,0,73.131,68.716,73.131,106.472
L341.91,330.654L341.91,330.654z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

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