Compare commits
25 Commits
feat/brand
...
feat/mater
| Author | SHA1 | Date | |
|---|---|---|---|
| c95096ddf0 | |||
| c8913af660 | |||
| 9611049e01 | |||
| bad5947fb5 | |||
| d27558a3ee | |||
| 81f6f78c49 | |||
| bf593445bd | |||
| aa032c0140 | |||
|
|
95e60692c0 | ||
| fda2cdbecb | |||
| a1cc9f18c4 | |||
| 084d35d605 | |||
|
|
02aac24a09 | ||
| 51c2bf6985 | |||
| 4e99d12be1 | |||
| 8b5d8f92e0 | |||
| d3c9dd6eb9 | |||
| 254ff36c50 | |||
| b317196217 | |||
| cc343ee27c | |||
| 74d1b16b7c | |||
| adf6889712 | |||
| 653082868a | |||
| 9fc1fc97fa | |||
| 7010a81596 |
@@ -0,0 +1,88 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class AllowedOriginService {
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
public AllowedOriginService(
|
||||
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
|
||||
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
|
||||
) {
|
||||
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
|
||||
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
|
||||
|
||||
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
|
||||
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
|
||||
}
|
||||
|
||||
if (configuredOrigins.isEmpty()) {
|
||||
throw new IllegalStateException("At least one allowed origin must be configured.");
|
||||
}
|
||||
this.allowedOrigins = List.copyOf(configuredOrigins);
|
||||
}
|
||||
|
||||
public List<String> getAllowedOrigins() {
|
||||
return allowedOrigins;
|
||||
}
|
||||
|
||||
public boolean isAllowed(String rawOriginOrUrl) {
|
||||
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
|
||||
}
|
||||
|
||||
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
|
||||
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||
if (normalizedOrigin == null) {
|
||||
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
|
||||
}
|
||||
configuredOrigins.add(normalizedOrigin);
|
||||
}
|
||||
|
||||
private String normalizeRequestOrigin(String rawOriginOrUrl) {
|
||||
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
URI uri = URI.create(rawOriginOrUrl.trim());
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
if (scheme == null || host == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||
int port = uri.getPort();
|
||||
if (isDefaultPort(normalizedScheme, port) || port < 0) {
|
||||
return normalizedScheme + "://" + normalizedHost;
|
||||
}
|
||||
return normalizedScheme + "://" + normalizedHost + ":" + port;
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isDefaultPort(String scheme, int port) {
|
||||
return ("http".equals(scheme) && port == 80)
|
||||
|| ("https".equals(scheme) && port == 443);
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,27 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.cors.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
public class CorsConfig implements WebMvcConfigurer {
|
||||
public class CorsConfig {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(
|
||||
"http://localhost",
|
||||
"http://localhost:4200",
|
||||
"http://localhost:80",
|
||||
"http://127.0.0.1",
|
||||
"https://dev.3d-fab.ch",
|
||||
"https://int.3d-fab.ch",
|
||||
"https://3d-fab.ch"
|
||||
)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true);
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||
CorsConfiguration configuration = new CorsConfiguration();
|
||||
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||
configuration.setAllowedHeaders(List.of("*"));
|
||||
configuration.setAllowCredentials(true);
|
||||
configuration.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.printcalculator.config;
|
||||
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||
) throws Exception {
|
||||
http
|
||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||
}))
|
||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ public class PublicShopController {
|
||||
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/{slug}/model")
|
||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.printcalculator.security;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
@Component
|
||||
public class AdminCsrfProtectionFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Set<String> SAFE_METHODS = Set.of("GET", "HEAD", "OPTIONS", "TRACE");
|
||||
|
||||
private final AllowedOriginService allowedOriginService;
|
||||
|
||||
public AdminCsrfProtectionFilter(AllowedOriginService allowedOriginService) {
|
||||
this.allowedOriginService = allowedOriginService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = resolvePath(request);
|
||||
String method = request.getMethod() == null ? "" : request.getMethod().toUpperCase(Locale.ROOT);
|
||||
return !path.startsWith("/api/admin/") || SAFE_METHODS.contains(method);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String origin = request.getHeader(HttpHeaders.ORIGIN);
|
||||
String referer = request.getHeader(HttpHeaders.REFERER);
|
||||
|
||||
if (allowedOriginService.isAllowed(origin) || allowedOriginService.isAllowed(referer)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("{\"error\":\"CSRF_INVALID\"}");
|
||||
}
|
||||
|
||||
private String resolvePath(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
String contextPath = request.getContextPath();
|
||||
if (contextPath != null && !contextPath.isEmpty() && path.startsWith(contextPath)) {
|
||||
return path.substring(contextPath.length());
|
||||
}
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -126,24 +126,40 @@ public class PublicShopCatalogService {
|
||||
}
|
||||
|
||||
public ShopProductDetailDto getProduct(String slug, String language) {
|
||||
CategoryContext categoryContext = loadCategoryContext(language);
|
||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
|
||||
String normalizedLanguage = normalizeLanguage(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);
|
||||
if (entry == null) {
|
||||
public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
|
||||
if (normalizedPublicPath == 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");
|
||||
}
|
||||
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||
ProductEntry entry = requirePublicProductEntry(
|
||||
productContext.entriesByPublicPath().get(normalizedPublicPath),
|
||||
categoryContext
|
||||
);
|
||||
|
||||
return toProductDetailDto(
|
||||
entry,
|
||||
productContext.productMediaBySlug(),
|
||||
productContext.variantColorHexByMaterialAndColor(),
|
||||
language
|
||||
normalizedLanguage
|
||||
);
|
||||
}
|
||||
|
||||
@@ -197,6 +213,7 @@ public class PublicShopCatalogService {
|
||||
}
|
||||
|
||||
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
||||
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
||||
@@ -207,8 +224,21 @@ public class PublicShopCatalogService {
|
||||
|
||||
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
||||
.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
|
||||
));
|
||||
|
||||
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
|
||||
return new PublicProductContext(
|
||||
entries,
|
||||
entriesBySlug,
|
||||
entriesByPublicPath,
|
||||
productMediaBySlug,
|
||||
variantColorHexByMaterialAndColor
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, String> buildFilamentVariantColorHexMap() {
|
||||
@@ -399,6 +429,8 @@ public class PublicShopCatalogService {
|
||||
Map<String, String> variantColorHexByMaterialAndColor,
|
||||
String language) {
|
||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||
return new ShopProductSummaryDto(
|
||||
entry.product().getId(),
|
||||
@@ -417,7 +449,7 @@ public class PublicShopCatalogService {
|
||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||
selectPrimaryMedia(images),
|
||||
toProductModelDto(entry),
|
||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
||||
publicPathSegment,
|
||||
localizedPaths
|
||||
);
|
||||
}
|
||||
@@ -429,9 +461,10 @@ public class PublicShopCatalogService {
|
||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
||||
String normalizedLanguage = normalizeLanguage(language);
|
||||
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||
return new ShopProductDetailDto(
|
||||
entry.product().getId(),
|
||||
return new ShopProductDetailDto(entry.product().getId(),
|
||||
entry.product().getSlug(),
|
||||
entry.product().getNameForLanguage(language),
|
||||
entry.product().getExcerptForLanguage(language),
|
||||
@@ -458,7 +491,7 @@ public class PublicShopCatalogService {
|
||||
selectPrimaryMedia(images),
|
||||
images,
|
||||
toProductModelDto(entry),
|
||||
localizedPaths.getOrDefault(normalizeLanguage(language), localizedPaths.get("it")),
|
||||
publicPathSegment,
|
||||
localizedPaths
|
||||
);
|
||||
}
|
||||
@@ -512,6 +545,27 @@ public class PublicShopCatalogService {
|
||||
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 trimToNull(String value) {
|
||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||
if (raw.isEmpty()) {
|
||||
@@ -607,6 +661,7 @@ public class PublicShopCatalogService {
|
||||
private record PublicProductContext(
|
||||
List<ProductEntry> entries,
|
||||
Map<String, ProductEntry> entriesBySlug,
|
||||
Map<String, ProductEntry> entriesByPublicPath,
|
||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||
Map<String, String> variantColorHexByMaterialAndColor
|
||||
) {
|
||||
|
||||
@@ -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.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||
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}
|
||||
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.printcalculator.controller;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.controller.admin.AdminAuthController;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
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.assertTrue;
|
||||
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.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@WebMvcTest(controllers = AdminAuthController.class)
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class
|
||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminAuthSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.1");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.2");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.3");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.3");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"wrong-password\"}"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.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
|
||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||
mockMvc.perform(get("/api/admin/auth/me"))
|
||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.4");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.printcalculator.controller.admin;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class,
|
||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminOrderControllerSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.44");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.printcalculator.controller.admin;
|
||||
|
||||
import com.printcalculator.config.AllowedOriginService;
|
||||
import com.printcalculator.config.CorsConfig;
|
||||
import com.printcalculator.config.SecurityConfig;
|
||||
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||
import com.printcalculator.security.AdminLoginThrottleService;
|
||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||
import com.printcalculator.security.AdminSessionService;
|
||||
@@ -36,7 +39,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
|
||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||
@Import({
|
||||
CorsConfig.class,
|
||||
AllowedOriginService.class,
|
||||
SecurityConfig.class,
|
||||
AdminCsrfProtectionFilter.class,
|
||||
AdminSessionAuthenticationFilter.class,
|
||||
AdminSessionService.class,
|
||||
AdminLoginThrottleService.class,
|
||||
@@ -49,6 +55,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
||||
})
|
||||
class AdminShopProductControllerSecurityTest {
|
||||
|
||||
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@@ -61,11 +69,22 @@ class AdminShopProductControllerSecurityTest {
|
||||
@Test
|
||||
void translateProduct_withoutAdminCookie_shouldReturn401() throws Exception {
|
||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"sourceLanguage\":\"it\",\"names\":{\"it\":\"Supporto cavo\"}}"))
|
||||
.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
|
||||
void translateProduct_withAdminCookie_shouldReturnTranslations() throws Exception {
|
||||
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||
@@ -82,6 +101,7 @@ class AdminShopProductControllerSecurityTest {
|
||||
|
||||
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||
.cookie(loginAndExtractCookie())
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
@@ -107,6 +127,7 @@ class AdminShopProductControllerSecurityTest {
|
||||
req.setRemoteAddr("10.0.0.44");
|
||||
return req;
|
||||
})
|
||||
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"password\":\"test-admin-password\"}"))
|
||||
.andExpect(status().isOk())
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
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 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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
@@ -16,7 +16,7 @@
|
||||
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
@@ -26,7 +26,7 @@
|
||||
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
@@ -36,7 +36,7 @@
|
||||
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
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';
|
||||
|
||||
describe('serverOriginInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{
|
||||
provide: REQUEST,
|
||||
useValue: {
|
||||
protocol: 'https',
|
||||
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();
|
||||
});
|
||||
|
||||
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({});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,12 @@ import {
|
||||
resolveRequestOrigin,
|
||||
} from '../../../core/request-origin';
|
||||
|
||||
const FORWARDED_REQUEST_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'accept-language',
|
||||
] as const;
|
||||
|
||||
function isAbsoluteUrl(url: string): boolean {
|
||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||
}
|
||||
@@ -14,6 +20,20 @@ function normalizeRelativePath(url: string): string {
|
||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (isAbsoluteUrl(req.url)) {
|
||||
return next(req);
|
||||
@@ -26,5 +46,24 @@ export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
}
|
||||
|
||||
const absoluteUrl = `${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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -117,6 +117,34 @@ describe('SeoService', () => {
|
||||
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', () => {
|
||||
const { meta, title } = createService({
|
||||
url: '/en/about',
|
||||
|
||||
@@ -105,7 +105,7 @@ export class SeoService {
|
||||
cleanPath,
|
||||
canonicalPath,
|
||||
alternates,
|
||||
alternates.it ?? canonicalPath,
|
||||
this.buildXDefaultPath(canonicalPath, alternates),
|
||||
lang,
|
||||
);
|
||||
}
|
||||
@@ -119,8 +119,7 @@ export class SeoService {
|
||||
const alternates = this.normalizeAlternatePaths(override.alternates);
|
||||
const xDefault =
|
||||
this.normalizeSeoPath(override.xDefault) ??
|
||||
alternates?.it ??
|
||||
canonicalPath;
|
||||
this.buildXDefaultPath(canonicalPath, alternates);
|
||||
|
||||
this.applySeoValues(
|
||||
title,
|
||||
@@ -162,7 +161,7 @@ export class SeoService {
|
||||
cleanPath,
|
||||
canonicalPath,
|
||||
alternates,
|
||||
alternates.it ?? canonicalPath,
|
||||
this.buildXDefaultPath(canonicalPath, alternates),
|
||||
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(
|
||||
paths: SeoMap | null | undefined,
|
||||
): SeoMap | null {
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
<main class="materials-page">
|
||||
<section class="hero">
|
||||
<div class="container hero-inner">
|
||||
<p class="ui-eyebrow">Guida materiali</p>
|
||||
<h1>Qualita e Materiali</h1>
|
||||
<p class="hero-lead">
|
||||
Confronta materiali in modo interattivo con radar chart, metriche tecniche,
|
||||
vantaggi, limiti e fonti verificabili.
|
||||
</p>
|
||||
<p class="hero-note">
|
||||
Seleziona fino a {{ maxCompareCount }} materiali: il grafico aggiorna i
|
||||
punteggi in tempo reale.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="selector-section">
|
||||
<div class="container">
|
||||
<h2>Selezione confronto</h2>
|
||||
<div class="selector-grid" role="group" aria-label="Selezione materiali">
|
||||
@for (material of materials; track trackMaterial($index, material)) {
|
||||
<button
|
||||
type="button"
|
||||
class="selector-chip"
|
||||
[class.is-selected]="isSelected(material.id)"
|
||||
[disabled]="!canSelect(material.id)"
|
||||
(click)="toggleMaterial(material.id)"
|
||||
>
|
||||
<span
|
||||
class="selector-dot"
|
||||
[style.background-color]="legendDotColor(material.id)"
|
||||
></span>
|
||||
<span>{{ material.name }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<p class="selector-help">
|
||||
Nota: per l asse Economicita, un valore alto significa costo al kg piu
|
||||
conveniente.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="chart-section">
|
||||
<div class="container chart-layout">
|
||||
<article class="chart-card">
|
||||
<header class="chart-header">
|
||||
<h2>Radar chart comparativo</h2>
|
||||
<p>
|
||||
Punteggi normalizzati 0-100 su tutto il set materiali (min-max scaling).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<svg
|
||||
class="radar-chart"
|
||||
[attr.viewBox]="'0 0 ' + chartSize + ' ' + chartSize"
|
||||
role="img"
|
||||
aria-label="Radar chart materiali"
|
||||
>
|
||||
<g class="chart-rings">
|
||||
@for (ring of ringPolygons(); track $index) {
|
||||
<polygon [attr.points]="ring"></polygon>
|
||||
}
|
||||
</g>
|
||||
|
||||
<g class="chart-axes">
|
||||
@for (axis of axisGuides(); track axis.id) {
|
||||
<line
|
||||
[attr.x1]="axis.fromX"
|
||||
[attr.y1]="axis.fromY"
|
||||
[attr.x2]="axis.x"
|
||||
[attr.y2]="axis.y"
|
||||
></line>
|
||||
<text
|
||||
[attr.x]="axis.labelX"
|
||||
[attr.y]="axis.labelY"
|
||||
[attr.text-anchor]="axis.labelAnchor"
|
||||
>
|
||||
{{ radarAxes[$index].label }}
|
||||
</text>
|
||||
}
|
||||
</g>
|
||||
|
||||
<g class="chart-series">
|
||||
@for (series of radarSeries(); track series.material.id) {
|
||||
<polygon
|
||||
class="series-shape"
|
||||
[attr.points]="series.points"
|
||||
[style.stroke]="series.color"
|
||||
[style.fill]="series.fill"
|
||||
[class.is-hovered]="hoveredMaterialId() === series.material.id"
|
||||
(mouseenter)="setHoveredMaterial(series.material.id)"
|
||||
(mouseleave)="setHoveredMaterial(null)"
|
||||
></polygon>
|
||||
|
||||
@for (point of series.values; track point.axis.id) {
|
||||
<circle
|
||||
class="series-node"
|
||||
[attr.cx]="point.x"
|
||||
[attr.cy]="point.y"
|
||||
r="4"
|
||||
[style.fill]="series.color"
|
||||
></circle>
|
||||
}
|
||||
}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div class="chart-legend">
|
||||
@for (series of radarSeries(); track series.material.id) {
|
||||
<button
|
||||
type="button"
|
||||
class="legend-item"
|
||||
(mouseenter)="setHoveredMaterial(series.material.id)"
|
||||
(mouseleave)="setHoveredMaterial(null)"
|
||||
>
|
||||
<span
|
||||
class="legend-dot"
|
||||
[style.background-color]="series.color"
|
||||
></span>
|
||||
<span>{{ series.material.name }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="explain-card">
|
||||
<h3>Spiegazione completa del radar</h3>
|
||||
<p>
|
||||
Ogni asse mostra una proprieta tecnica. Il valore 100 rappresenta la
|
||||
miglior performance relativa nel dataset attuale; 0 la meno favorevole.
|
||||
</p>
|
||||
<ul>
|
||||
@for (axis of radarAxes; track axis.id) {
|
||||
<li>
|
||||
<strong>{{ axis.label }}:</strong>
|
||||
{{ axis.description }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p>
|
||||
La normalizzazione e calcolata su tutti i materiali mostrati in pagina.
|
||||
Per leggibilita il radar usa un raggio minimo visivo: i valori minimi
|
||||
restano i meno favorevoli, ma non collassano tutti nello stesso punto.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="table-section">
|
||||
<div class="container">
|
||||
<h2>Tabella tecnica di confronto</h2>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Parametro</th>
|
||||
@for (material of selectedMaterials(); track trackMaterial($index, material)) {
|
||||
<th>{{ material.name }}</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of comparisonRows(); track row.label) {
|
||||
<tr>
|
||||
<th>{{ row.label }}</th>
|
||||
@for (value of row.values; track $index) {
|
||||
<td>{{ value }}</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="quality-section">
|
||||
<div class="container">
|
||||
<h2>Layer, ugello e infill: esempi pratici</h2>
|
||||
<p class="quality-intro">
|
||||
Questa sezione non e un calcolatore interattivo: spiega visivamente cosa
|
||||
cambia su oggetti reali e come leggere i risultati del vostro calcolatore.
|
||||
</p>
|
||||
|
||||
<div class="visual-guide-grid">
|
||||
@for (guide of qualityVisualCards(); track trackVisualGuide($index, guide)) {
|
||||
<article class="visual-guide-card">
|
||||
<p class="visual-guide-category">{{ guide.category }}</p>
|
||||
<h3>{{ guide.title }}</h3>
|
||||
|
||||
<div class="visual-guide-media">
|
||||
@if (guide.image; as image) {
|
||||
<picture>
|
||||
@if (image.source.avifUrl) {
|
||||
<source [srcset]="image.source.avifUrl" type="image/avif" />
|
||||
}
|
||||
@if (image.source.webpUrl) {
|
||||
<source [srcset]="image.source.webpUrl" type="image/webp" />
|
||||
}
|
||||
<img
|
||||
[src]="image.source.fallbackUrl"
|
||||
[attr.alt]="image.altText || guide.title"
|
||||
width="640"
|
||||
height="420"
|
||||
/>
|
||||
</picture>
|
||||
} @else {
|
||||
<div class="media-fallback">
|
||||
<span>
|
||||
Nessuna immagine assegnata.
|
||||
Carica asset backend con usageType
|
||||
<code>MATERIALS_PAGE</code> e usageKey
|
||||
<code>{{ guide.usageKey }}</code>.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p><strong>Oggetto esempio:</strong> {{ guide.objectExample }}</p>
|
||||
<p><strong>Meglio per:</strong> {{ guide.bestFor }}</p>
|
||||
<p><strong>Limite:</strong> {{ guide.tradeoff }}</p>
|
||||
<p class="visual-guide-calc">
|
||||
<strong>Lettura nel calcolatore:</strong> {{ guide.calculatorRead }}
|
||||
</p>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
|
||||
<article class="calculator-logic-card">
|
||||
<h3>Come leggere il nostro calcolatore</h3>
|
||||
<p>
|
||||
Il calcolatore non sostituisce i profili slicer: serve a spiegare il
|
||||
compromesso tra estetica, robustezza e tempi in modo coerente.
|
||||
</p>
|
||||
<div class="logic-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Metrica</th>
|
||||
<th>Cosa significa</th>
|
||||
<th>Valore alto</th>
|
||||
<th>Valore basso</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (rule of calculatorRules; track rule.metric) {
|
||||
<tr>
|
||||
<th>{{ rule.metric }}</th>
|
||||
<td>{{ rule.whatItMeans }}</td>
|
||||
<td>{{ rule.whenHigh }}</td>
|
||||
<td>{{ rule.whenLow }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="quality-layout">
|
||||
<article class="quality-card">
|
||||
<h3>Regole rapide per l utente</h3>
|
||||
<ul>
|
||||
<li>
|
||||
Layer basso e ugello piccolo migliorano i dettagli, ma aumentano i
|
||||
tempi.
|
||||
</li>
|
||||
<li>
|
||||
Infill e perimetri alti migliorano resistenza, ma aumentano tempo e
|
||||
materiale.
|
||||
</li>
|
||||
<li>
|
||||
Per pezzi estetici usa profili fini; per pezzi funzionali scegli setup
|
||||
bilanciati o robusti.
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="guides-grid">
|
||||
@for (guide of qualityGuides; track trackGuide($index, guide)) {
|
||||
<article class="guide-card">
|
||||
<h3>{{ guide.title }}</h3>
|
||||
<p><strong>Range consigliato:</strong> {{ guide.recommendation }}</p>
|
||||
<p>{{ guide.explanation }}</p>
|
||||
<p class="guide-effect">{{ guide.practicalEffect }}</p>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="materials-section">
|
||||
<div class="container">
|
||||
<h2>Schede materiali: spiegazioni, pro/contro, fonti</h2>
|
||||
<div class="materials-grid">
|
||||
@for (card of selectedCards(); track card.material.id) {
|
||||
<article class="material-card">
|
||||
<header>
|
||||
<h3>{{ card.material.name }}</h3>
|
||||
<p>{{ card.material.summary }}</p>
|
||||
</header>
|
||||
|
||||
<div class="material-media">
|
||||
@if (card.image; as image) {
|
||||
<picture>
|
||||
@if (image.source.avifUrl) {
|
||||
<source [srcset]="image.source.avifUrl" type="image/avif" />
|
||||
}
|
||||
@if (image.source.webpUrl) {
|
||||
<source [srcset]="image.source.webpUrl" type="image/webp" />
|
||||
}
|
||||
<img
|
||||
[src]="image.source.fallbackUrl"
|
||||
[attr.alt]="image.altText || card.material.name"
|
||||
width="640"
|
||||
height="400"
|
||||
/>
|
||||
</picture>
|
||||
} @else {
|
||||
<div class="media-fallback">
|
||||
<span>
|
||||
Nessuna immagine assegnata.
|
||||
Carica asset backend con usageType
|
||||
<code>MATERIALS_PAGE</code> e usageKey
|
||||
<code>material-{{ card.material.id }}</code>.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="material-columns">
|
||||
<div>
|
||||
<h4>Vantaggi</h4>
|
||||
<ul>
|
||||
@for (item of card.material.pros; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Limiti</h4>
|
||||
<ul>
|
||||
@for (item of card.material.cons; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Ideale per</h4>
|
||||
<ul>
|
||||
@for (item of card.material.idealFor; track $index) {
|
||||
<li>{{ item }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="source-list">
|
||||
<h4>Fonti citate</h4>
|
||||
<ul>
|
||||
@for (source of card.material.sources; track trackSource($index, source)) {
|
||||
<li>
|
||||
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
</a>
|
||||
<span class="source-kind">{{ source.kind }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="global-sources">
|
||||
<div class="container">
|
||||
<h2>Indice completo fonti</h2>
|
||||
<p>
|
||||
Tutti i link usati per metriche e descrizioni sono riportati qui in forma
|
||||
centralizzata.
|
||||
</p>
|
||||
<ul class="global-source-list">
|
||||
@for (source of allSources(); track trackSource($index, source)) {
|
||||
<li>
|
||||
<a [href]="source.url" target="_blank" rel="noopener noreferrer">
|
||||
{{ source.label }}
|
||||
</a>
|
||||
<span>{{ source.kind }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -0,0 +1,546 @@
|
||||
.materials-page {
|
||||
--materials-bg: #ffffff;
|
||||
--materials-accent: #c23b22;
|
||||
--materials-muted: #5f6771;
|
||||
--materials-card: #ffffff;
|
||||
background: var(--materials-bg);
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 5rem 0 2.25rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -40% -15% auto auto;
|
||||
width: 420px;
|
||||
height: 420px;
|
||||
background: radial-gradient(circle, rgba(194, 59, 34, 0.08), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0.4rem 0 1rem;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.hero-lead {
|
||||
margin: 0;
|
||||
max-width: 68ch;
|
||||
font-size: 1.05rem;
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.hero-note {
|
||||
margin: 0.9rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.selector-section,
|
||||
.chart-section,
|
||||
.table-section,
|
||||
.quality-section,
|
||||
.materials-section,
|
||||
.global-sources {
|
||||
padding: 1.8rem 0;
|
||||
}
|
||||
|
||||
.selector-grid {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.selector-chip {
|
||||
border: 1px solid var(--color-border);
|
||||
background: #fff;
|
||||
color: var(--color-text-main);
|
||||
border-radius: 999px;
|
||||
padding: 0.5rem 0.9rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.selector-chip:hover:enabled {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--materials-accent);
|
||||
box-shadow: 0 4px 12px rgb(16 24 32 / 0.12);
|
||||
}
|
||||
|
||||
.selector-chip:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.selector-chip.is-selected {
|
||||
border-color: var(--materials-accent);
|
||||
background: #fff3ee;
|
||||
}
|
||||
|
||||
.selector-dot {
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgb(0 0 0 / 0.15);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.selector-help {
|
||||
margin-top: 0.8rem;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.chart-layout {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.explain-card,
|
||||
.material-card,
|
||||
.table-wrap,
|
||||
.quality-card,
|
||||
.guide-card {
|
||||
background: var(--materials-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.chart-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-header p {
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.radar-chart {
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-rings polygon {
|
||||
fill: none;
|
||||
stroke: #d7d9de;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-axes line {
|
||||
stroke: #c3c8cf;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chart-axes text {
|
||||
font-size: 0.75rem;
|
||||
fill: #4f5a66;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.series-shape {
|
||||
stroke-width: 2.2;
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.series-shape.is-hovered {
|
||||
filter: drop-shadow(0 3px 8px rgb(16 24 32 / 0.26));
|
||||
}
|
||||
|
||||
.series-node {
|
||||
stroke: #ffffff;
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.7rem;
|
||||
background: #fff;
|
||||
display: inline-flex;
|
||||
gap: 0.45rem;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.explain-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.explain-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.explain-card p {
|
||||
margin: 0.75rem 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.explain-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 760px;
|
||||
}
|
||||
|
||||
.table-wrap th,
|
||||
.table-wrap td {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table-wrap thead th {
|
||||
background: #f8fafd;
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.table-wrap tbody tr:hover {
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.quality-intro {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
max-width: 72ch;
|
||||
}
|
||||
|
||||
.visual-guide-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
|
||||
.visual-guide-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
||||
padding: 0.85rem;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.visual-guide-category {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #2563b8;
|
||||
}
|
||||
|
||||
.visual-guide-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.visual-guide-media {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: #f7f8fb;
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.visual-guide-media img {
|
||||
width: 100%;
|
||||
height: 185px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.visual-guide-card p {
|
||||
margin: 0;
|
||||
color: var(--materials-muted);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.42;
|
||||
}
|
||||
|
||||
.visual-guide-calc {
|
||||
margin-top: 0.2rem;
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.calculator-logic-card {
|
||||
margin-top: 1rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 10px 28px rgb(16 24 32 / 0.05);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.calculator-logic-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.calculator-logic-card p {
|
||||
margin: 0.6rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.logic-table-wrap {
|
||||
margin-top: 0.75rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.logic-table-wrap table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 720px;
|
||||
}
|
||||
|
||||
.logic-table-wrap th,
|
||||
.logic-table-wrap td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
padding: 0.62rem 0.7rem;
|
||||
}
|
||||
|
||||
.logic-table-wrap thead th {
|
||||
background: #f8fafd;
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.quality-layout {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quality-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.quality-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.quality-card ul {
|
||||
margin: 0.7rem 0 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.guides-grid {
|
||||
margin-top: 1rem;
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.guide-card {
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.guide-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.guide-card p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.guide-effect {
|
||||
color: var(--color-text-main);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.materials-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
}
|
||||
|
||||
.material-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.material-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.material-card header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.material-media {
|
||||
margin-top: 0.85rem;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
background: #f7f8fb;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.material-media img {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.media-fallback {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
color: var(--materials-muted);
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.material-columns {
|
||||
margin-top: 0.9rem;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.material-columns h4,
|
||||
.source-list h4 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.material-columns ul,
|
||||
.source-list ul,
|
||||
.global-source-list {
|
||||
margin: 0.45rem 0 0;
|
||||
padding-left: 1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.source-list {
|
||||
margin-top: 0.9rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.source-list li,
|
||||
.global-source-list li {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.source-list a,
|
||||
.global-source-list a {
|
||||
color: #14409b;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.source-kind {
|
||||
color: var(--materials-muted);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-sources p {
|
||||
color: var(--materials-muted);
|
||||
}
|
||||
|
||||
.global-source-list {
|
||||
background: #fff;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.9rem;
|
||||
padding: 1rem 1rem 1rem 1.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.chart-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quality-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.hero {
|
||||
padding-top: 4.2rem;
|
||||
}
|
||||
|
||||
.chart-card,
|
||||
.explain-card,
|
||||
.material-card {
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.table-wrap table {
|
||||
min-width: 640px;
|
||||
}
|
||||
|
||||
.source-list li,
|
||||
.global-source-list li {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
1018
frontend/src/app/features/materials/materials-page.component.ts
Normal file
1018
frontend/src/app/features/materials/materials-page.component.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
← {{ "SHOP.BACK" | translate }}
|
||||
</button>
|
||||
|
||||
@if (loading()) {
|
||||
@if (loading() || softFallbackActive()) {
|
||||
<div class="detail-grid skeleton-grid">
|
||||
<div class="skeleton-block"></div>
|
||||
<div class="skeleton-block"></div>
|
||||
|
||||
237
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
237
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
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') {
|
||||
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'>('de');
|
||||
const languageService = {
|
||||
currentLang,
|
||||
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('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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,24 @@ import {
|
||||
PLATFORM_ID,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
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 { 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 { 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 { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||
@@ -27,6 +35,7 @@ import {
|
||||
ShopService,
|
||||
} from './services/shop.service';
|
||||
import { ShopRouteService } from './services/shop-route.service';
|
||||
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||
|
||||
interface ShopMaterialOption {
|
||||
key: string;
|
||||
@@ -61,6 +70,7 @@ export class ProductDetailComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly location = inject(Location);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly seoService = inject(SeoService);
|
||||
@@ -70,10 +80,12 @@ export class ProductDetailComponent {
|
||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||
readonly shopService = inject(ShopService);
|
||||
|
||||
readonly categorySlug = input<string | undefined>();
|
||||
readonly productSlug = input<string | undefined>();
|
||||
readonly routeCategorySlug = signal<string | null>(
|
||||
this.readRouteParam('categorySlug'),
|
||||
);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly softFallbackActive = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly product = signal<ShopProductDetail | null>(null);
|
||||
readonly selectedVariantId = signal<string | null>(null);
|
||||
@@ -205,48 +217,74 @@ export class ProductDetailComponent {
|
||||
});
|
||||
|
||||
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, {
|
||||
injector: this.injector,
|
||||
}),
|
||||
}).pipe(distinctUntilChanged()),
|
||||
])
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.loading.set(true);
|
||||
this.softFallbackActive.set(false);
|
||||
this.error.set(null);
|
||||
this.addSuccess.set(false);
|
||||
this.modelError.set(false);
|
||||
this.colorPopupOpen.set(false);
|
||||
this.modelModalOpen.set(false);
|
||||
}),
|
||||
switchMap(([productSlug]) => {
|
||||
if (!productSlug) {
|
||||
switchMap(([routeParams]) => {
|
||||
this.routeCategorySlug.set(routeParams.categorySlug);
|
||||
if (!routeParams.productSlug) {
|
||||
this.languageService.clearLocalizedRouteOverrides();
|
||||
this.error.set('SHOP.NOT_FOUND');
|
||||
this.setResponseStatus(404);
|
||||
this.applyFallbackSeo();
|
||||
this.applyHardFallbackSeo();
|
||||
this.loading.set(false);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
||||
catchError((error) => {
|
||||
this.languageService.clearLocalizedRouteOverrides();
|
||||
this.product.set(null);
|
||||
this.selectedVariantId.set(null);
|
||||
this.setSelectedImageAssetId(null);
|
||||
this.modelFile.set(null);
|
||||
this.error.set(
|
||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||
);
|
||||
if (error?.status === 404) {
|
||||
this.setResponseStatus(404);
|
||||
}
|
||||
this.applyFallbackSeo();
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.loading.set(false)),
|
||||
);
|
||||
const productSlug = routeParams.productSlug as string;
|
||||
return this.shopService
|
||||
.getProductByPublicPath(productSlug)
|
||||
.pipe(
|
||||
catchError((error) => {
|
||||
this.languageService.clearLocalizedRouteOverrides();
|
||||
this.product.set(null);
|
||||
this.selectedVariantId.set(null);
|
||||
this.setSelectedImageAssetId(null);
|
||||
this.modelFile.set(null);
|
||||
const isNotFound = error?.status === 404;
|
||||
if (isNotFound) {
|
||||
this.error.set('SHOP.NOT_FOUND');
|
||||
this.setResponseStatus(404);
|
||||
this.applyHardFallbackSeo();
|
||||
return of(null);
|
||||
}
|
||||
|
||||
if (this.shouldUseSoftSeoFallback(error)) {
|
||||
this.error.set(null);
|
||||
this.softFallbackActive.set(true);
|
||||
this.setResponseStatus(200);
|
||||
this.applySoftFallbackSeo(productSlug);
|
||||
return of(null);
|
||||
}
|
||||
|
||||
this.error.set('SHOP.LOAD_ERROR');
|
||||
this.setResponseStatus(503);
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.loading.set(false)),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
@@ -256,6 +294,7 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
this.product.set(product);
|
||||
this.softFallbackActive.set(false);
|
||||
this.selectedVariantId.set(
|
||||
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
||||
);
|
||||
@@ -492,7 +531,8 @@ export class ProductDetailComponent {
|
||||
}
|
||||
|
||||
productLinkRoot(): string[] {
|
||||
const categorySlug = this.product()?.category.slug || this.categorySlug();
|
||||
const categorySlug =
|
||||
this.product()?.category.slug || this.routeCategorySlug();
|
||||
return this.shopRouteService.shopRootCommands(categorySlug);
|
||||
}
|
||||
|
||||
@@ -583,7 +623,7 @@ export class ProductDetailComponent {
|
||||
});
|
||||
}
|
||||
|
||||
private applyFallbackSeo(): void {
|
||||
private applyHardFallbackSeo(): void {
|
||||
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
||||
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||
this.seoService.applyResolvedSeo({
|
||||
@@ -598,6 +638,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(
|
||||
variant: ShopProductVariantOption | null,
|
||||
): string {
|
||||
@@ -810,4 +899,15 @@ export class ProductDetailComponent {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from '@angular/common/http/testing';
|
||||
import {
|
||||
ShopCartResponse,
|
||||
ShopProductCatalogResponse,
|
||||
ShopProductDetail,
|
||||
ShopService,
|
||||
} from './shop.service';
|
||||
@@ -90,39 +89,6 @@ describe('ShopService', () => {
|
||||
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 => ({
|
||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
||||
slug: 'desk-cable-clip',
|
||||
@@ -226,24 +192,15 @@ describe('ShopService', () => {
|
||||
response = product;
|
||||
});
|
||||
|
||||
const catalogRequest = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
catalogRequest.flush(buildCatalog());
|
||||
|
||||
const detailRequest = httpMock.expectOne((request) => {
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678-supporto-cavo-scrivania' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
detailRequest.flush(buildProduct());
|
||||
request.flush(buildProduct());
|
||||
|
||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||
@@ -259,18 +216,15 @@ describe('ShopService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const catalogRequest = httpMock.expectOne((request) => {
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678-qualunque-nome' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
catalogRequest.flush(buildCatalog());
|
||||
|
||||
httpMock.expectNone(
|
||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
||||
);
|
||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||
expect(errorResponse?.status).toBe(404);
|
||||
});
|
||||
|
||||
@@ -284,18 +238,15 @@ describe('ShopService', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const catalogRequest = httpMock.expectOne((request) => {
|
||||
const request = httpMock.expectOne((request) => {
|
||||
return (
|
||||
request.method === 'GET' &&
|
||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
||||
request.url ===
|
||||
'http://localhost:8000/api/shop/products/by-path/12345678' &&
|
||||
request.params.get('lang') === 'it'
|
||||
);
|
||||
});
|
||||
catalogRequest.flush(buildCatalog());
|
||||
|
||||
httpMock.expectNone(
|
||||
'http://localhost:8000/api/shop/products/desk-cable-clip',
|
||||
);
|
||||
request.flush('Not found', { status: 404, statusText: 'Not Found' });
|
||||
expect(errorResponse?.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||
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 {
|
||||
PublicMediaUsageDto,
|
||||
@@ -290,21 +290,11 @@ export class ShopService {
|
||||
}));
|
||||
}
|
||||
|
||||
return this.getProductCatalog().pipe(
|
||||
map((catalog) =>
|
||||
catalog.products.find(
|
||||
(product) =>
|
||||
this.normalizePublicPath(product.publicPath) === normalizedPath,
|
||||
),
|
||||
),
|
||||
switchMap((product) => {
|
||||
if (!product) {
|
||||
return throwError(() => ({
|
||||
status: 404,
|
||||
}));
|
||||
}
|
||||
return this.getProduct(product.slug);
|
||||
}),
|
||||
return this.http.get<ShopProductDetail>(
|
||||
`${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`,
|
||||
{
|
||||
params: this.buildLangParams(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
<section class="shop-page">
|
||||
<div class="container ui-simple-hero shop-hero">
|
||||
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
|
||||
<p class="ui-simple-hero__subtitle">
|
||||
{{
|
||||
selectedCategory()
|
||||
? selectedCategory()?.description ||
|
||||
("SHOP.CATEGORY_META"
|
||||
| translate: { count: selectedCategory()?.productCount || 0 })
|
||||
: ("SHOP.SUBTITLE" | translate)
|
||||
}}
|
||||
</p>
|
||||
<p class="ui-simple-hero__subtitle">{{ heroSubtitle() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="container shop-layout">
|
||||
@@ -181,17 +173,9 @@
|
||||
<div class="section-head catalog-head">
|
||||
<div>
|
||||
<p class="ui-eyebrow ui-eyebrow--compact">
|
||||
{{
|
||||
selectedCategory()
|
||||
? ("SHOP.SELECTED_CATEGORY" | translate)
|
||||
: ("SHOP.CATALOG_LABEL" | translate)
|
||||
}}
|
||||
{{ catalogEyebrow() }}
|
||||
</p>
|
||||
<h2 class="section-title">
|
||||
{{
|
||||
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
|
||||
}}
|
||||
</h2>
|
||||
<h2 class="section-title">{{ catalogTitle() }}</h2>
|
||||
</div>
|
||||
<span class="catalog-counter">
|
||||
{{ products().length }}
|
||||
@@ -199,7 +183,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@if (loading() || softFallbackActive()) {
|
||||
<div class="product-grid skeleton-grid">
|
||||
@for (ghost of [1, 2, 3, 4]; track ghost) {
|
||||
<div class="skeleton-card"></div>
|
||||
|
||||
219
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
219
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { of } from 'rxjs';
|
||||
import { SeoService } from '../../core/services/seo.service';
|
||||
import { LanguageService } from '../../core/services/language.service';
|
||||
import {
|
||||
ShopCategoryDetail,
|
||||
ShopCategoryTree,
|
||||
ShopProductCatalogResponse,
|
||||
ShopService,
|
||||
} from './services/shop.service';
|
||||
import { ShopRouteService } from './services/shop-route.service';
|
||||
import { ShopPageComponent } from './shop-page.component';
|
||||
|
||||
describe('ShopPageComponent', () => {
|
||||
function buildCategory(
|
||||
overrides: Partial<ShopCategoryDetail> = {},
|
||||
): ShopCategoryDetail {
|
||||
return {
|
||||
id: 'cat-1',
|
||||
slug: 'compatible-with-garmin',
|
||||
name: 'Compatible with Garmin',
|
||||
description: 'Accessories compatible with Garmin devices.',
|
||||
seoTitle: null,
|
||||
seoDescription: null,
|
||||
ogTitle: null,
|
||||
ogDescription: null,
|
||||
indexable: true,
|
||||
sortOrder: 0,
|
||||
productCount: 3,
|
||||
breadcrumbs: [],
|
||||
primaryImage: null,
|
||||
images: [],
|
||||
children: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCatalog(
|
||||
overrides: Partial<ShopProductCatalogResponse> = {},
|
||||
): ShopProductCatalogResponse {
|
||||
return {
|
||||
categorySlug: null,
|
||||
featuredOnly: null,
|
||||
category: null,
|
||||
products: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createComponent(routerUrl = '/de/shop') {
|
||||
const responseInit: { status?: number } = {};
|
||||
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||
'applyResolvedSeo',
|
||||
'applyPageSeo',
|
||||
]);
|
||||
const translate = jasmine.createSpyObj<TranslateService>(
|
||||
'TranslateService',
|
||||
['instant'],
|
||||
);
|
||||
translate.instant.and.callFake((key: string, params?: { count?: number }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'SHOP.TITLE': 'Technische Lösungen',
|
||||
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
|
||||
'SHOP.CATALOG_TITLE': 'Alle Produkte',
|
||||
'SHOP.CATALOG_LABEL': 'Katalog',
|
||||
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
|
||||
'SHOP.CATALOG_META_DESCRIPTION':
|
||||
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
|
||||
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
|
||||
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
|
||||
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||
};
|
||||
if (key === 'SHOP.CATEGORY_META') {
|
||||
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
|
||||
}
|
||||
return translations[key] ?? key;
|
||||
});
|
||||
|
||||
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||
const languageService = {
|
||||
currentLang,
|
||||
selectedLang: () => currentLang(),
|
||||
};
|
||||
|
||||
const shopService = {
|
||||
cart: signal(null),
|
||||
cartLoading: signal(false),
|
||||
cartLoaded: signal(false),
|
||||
cartItemCount: signal(0),
|
||||
cartSessionId: signal<string | null>(null),
|
||||
getCategories: jasmine
|
||||
.createSpy('getCategories')
|
||||
.and.returnValue(of([] as ShopCategoryTree[])),
|
||||
getProductCatalog: jasmine
|
||||
.createSpy('getProductCatalog')
|
||||
.and.returnValue(of(buildCatalog())),
|
||||
flattenCategoryTree: jasmine
|
||||
.createSpy('flattenCategoryTree')
|
||||
.and.returnValue([]),
|
||||
quantityForProduct: jasmine.createSpy('quantityForProduct').and.returnValue(0),
|
||||
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
|
||||
removeCartItem: jasmine.createSpy('removeCartItem').and.returnValue(of(null)),
|
||||
updateCartItem: jasmine.createSpy('updateCartItem').and.returnValue(of(null)),
|
||||
};
|
||||
|
||||
const router = {
|
||||
url: routerUrl,
|
||||
navigate: jasmine.createSpy('navigate'),
|
||||
} as unknown as Router;
|
||||
|
||||
const activatedRoute = {
|
||||
paramMap: of(convertToParamMap({})),
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({}),
|
||||
},
|
||||
} as unknown as ActivatedRoute;
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ShopPageComponent],
|
||||
providers: [
|
||||
{ provide: SeoService, useValue: seoService },
|
||||
{ provide: TranslateService, useValue: translate },
|
||||
{ provide: LanguageService, useValue: languageService },
|
||||
{ provide: ShopService, useValue: shopService },
|
||||
{
|
||||
provide: ShopRouteService,
|
||||
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||
'shopRootCommands',
|
||||
]),
|
||||
},
|
||||
{ provide: Router, useValue: router },
|
||||
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||
],
|
||||
});
|
||||
|
||||
const fixture: ComponentFixture<ShopPageComponent> =
|
||||
TestBed.createComponent(ShopPageComponent);
|
||||
|
||||
return {
|
||||
component: fixture.componentInstance,
|
||||
seoService,
|
||||
responseInit,
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps index follow on the public shop root', () => {
|
||||
const { component, seoService } = createComponent();
|
||||
|
||||
(component as any).applyDefaultSeo();
|
||||
|
||||
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Technische Lösungen | 3D fab',
|
||||
robots: 'index, follow',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
||||
const { component, seoService } = createComponent('/de/shop/compatible-with-garmin');
|
||||
|
||||
(component as any).applySeo(buildCategory({ indexable: false }));
|
||||
|
||||
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a soft SSR fallback for non-404 category load errors', () => {
|
||||
const { component, seoService, responseInit } = createComponent(
|
||||
'/de/shop/compatible-with-garmin',
|
||||
);
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 500 })).toBeTrue();
|
||||
(component as any).setResponseStatus(200);
|
||||
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
||||
|
||||
expect(responseInit.status).toBe(200);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
title: 'Compatible With Garmin | Technische Lösungen | 3D fab',
|
||||
description:
|
||||
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||
robots: 'index, follow',
|
||||
canonicalPath: '/de/shop/compatible-with-garmin',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps hard 404 noindex behavior for missing categories', () => {
|
||||
const { component, seoService, responseInit } = createComponent(
|
||||
'/de/shop/compatible-with-garmin',
|
||||
);
|
||||
|
||||
expect((component as any).shouldUseSoftSeoFallback({ status: 404 })).toBeFalse();
|
||||
(component as any).setResponseStatus(404);
|
||||
(component as any).applyHardErrorSeo();
|
||||
|
||||
expect(responseInit.status).toBe(404);
|
||||
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||
jasmine.objectContaining({
|
||||
robots: 'noindex, nofollow',
|
||||
alternates: null,
|
||||
xDefault: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
PLATFORM_ID,
|
||||
RESPONSE_INIT,
|
||||
afterNextRender,
|
||||
Component,
|
||||
@@ -7,17 +8,18 @@ import {
|
||||
Injector,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
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 {
|
||||
catchError,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
finalize,
|
||||
forkJoin,
|
||||
map,
|
||||
of,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
ShopService,
|
||||
} from './services/shop.service';
|
||||
import { ShopRouteService } from './services/shop-route.service';
|
||||
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shop-page',
|
||||
@@ -58,17 +61,23 @@ import { ShopRouteService } from './services/shop-route.service';
|
||||
export class ShopPageComponent {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly translate = inject(TranslateService);
|
||||
private readonly seoService = inject(SeoService);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||
readonly languageService = inject(LanguageService);
|
||||
private readonly shopRouteService = inject(ShopRouteService);
|
||||
readonly shopService = inject(ShopService);
|
||||
|
||||
readonly categorySlug = input<string | undefined>();
|
||||
readonly routeCategorySlug = signal<string | null>(
|
||||
this.readRouteParam('categorySlug'),
|
||||
);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly softFallbackActive = signal(false);
|
||||
readonly softFallbackCategoryLabel = signal<string | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly categories = signal<ShopCategoryTree[]>([]);
|
||||
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
||||
@@ -82,7 +91,7 @@ export class ShopPageComponent {
|
||||
readonly cartLoading = this.shopService.cartLoading;
|
||||
readonly cartItemCount = this.shopService.cartItemCount;
|
||||
readonly currentCategorySlug = computed(
|
||||
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
|
||||
() => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
|
||||
);
|
||||
readonly cartItems = computed(() =>
|
||||
(this.cart()?.items ?? []).filter(
|
||||
@@ -90,6 +99,44 @@ export class ShopPageComponent {
|
||||
),
|
||||
);
|
||||
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() {
|
||||
afterNextRender(() => {
|
||||
@@ -97,38 +144,58 @@ export class ShopPageComponent {
|
||||
});
|
||||
|
||||
combineLatest([
|
||||
toObservable(this.categorySlug, { injector: this.injector }),
|
||||
this.route.paramMap.pipe(
|
||||
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
|
||||
distinctUntilChanged(),
|
||||
),
|
||||
toObservable(this.languageService.currentLang, {
|
||||
injector: this.injector,
|
||||
}),
|
||||
}).pipe(distinctUntilChanged()),
|
||||
])
|
||||
.pipe(
|
||||
tap(() => {
|
||||
this.loading.set(true);
|
||||
this.softFallbackActive.set(false);
|
||||
this.softFallbackCategoryLabel.set(null);
|
||||
this.error.set(null);
|
||||
}),
|
||||
switchMap(([categorySlug]) =>
|
||||
forkJoin({
|
||||
switchMap(([categorySlug]) => {
|
||||
this.routeCategorySlug.set(categorySlug);
|
||||
return forkJoin({
|
||||
categories: this.shopService.getCategories(),
|
||||
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
||||
}).pipe(
|
||||
catchError((error) => {
|
||||
const isNotFound = error?.status === 404;
|
||||
this.categories.set([]);
|
||||
this.categoryNodes.set([]);
|
||||
this.selectedCategory.set(null);
|
||||
this.products.set([]);
|
||||
this.error.set(
|
||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
||||
);
|
||||
if (error?.status === 404) {
|
||||
if (isNotFound) {
|
||||
this.error.set('SHOP.NOT_FOUND');
|
||||
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);
|
||||
}),
|
||||
finalize(() => this.loading.set(false)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((result) => {
|
||||
@@ -140,11 +207,13 @@ export class ShopPageComponent {
|
||||
this.categoryNodes.set(
|
||||
this.shopService.flattenCategoryTree(
|
||||
result.categories,
|
||||
result.catalog.category?.slug ?? this.categorySlug() ?? null,
|
||||
result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
|
||||
),
|
||||
);
|
||||
this.selectedCategory.set(result.catalog.category ?? null);
|
||||
this.products.set(result.catalog.products);
|
||||
this.softFallbackActive.set(false);
|
||||
this.softFallbackCategoryLabel.set(null);
|
||||
this.applySeo(result.catalog.category ?? null);
|
||||
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 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 {
|
||||
if (this.responseInit) {
|
||||
this.responseInit.status = status;
|
||||
@@ -401,4 +523,15 @@ export class ShopPageComponent {
|
||||
window.setTimeout(restore, 60);
|
||||
});
|
||||
}
|
||||
|
||||
private readRouteParam(name: string): string | null {
|
||||
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||
}
|
||||
|
||||
private normalizeRouteParam(
|
||||
value: string | null | undefined,
|
||||
): string | null {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
}
|
||||
|
||||
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal file
72
frontend/src/app/features/shop/shop-seo-fallback.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
const PRODUCT_ID_PREFIX_PATTERN = /^[0-9a-f]{8}-(?=[a-z0-9])/i;
|
||||
const UPPERCASE_TOKENS = new Set([
|
||||
'3d',
|
||||
'abs',
|
||||
'asa',
|
||||
'cad',
|
||||
'cf',
|
||||
'gf',
|
||||
'pa',
|
||||
'pc',
|
||||
'petg',
|
||||
'pla',
|
||||
'pp',
|
||||
'tpu',
|
||||
'uv',
|
||||
]);
|
||||
|
||||
export function humanizeShopSlug(
|
||||
value: string | null | undefined,
|
||||
options?: {
|
||||
stripProductIdPrefix?: boolean;
|
||||
},
|
||||
): string {
|
||||
const normalized = normalizeShopSlug(value, options?.stripProductIdPrefix);
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map(formatSlugToken)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeShopSlug(
|
||||
value: string | null | undefined,
|
||||
stripProductIdPrefix = false,
|
||||
): string {
|
||||
const normalized = String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/g, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.at(-1)
|
||||
?.toLowerCase();
|
||||
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return stripProductIdPrefix
|
||||
? normalized.replace(PRODUCT_ID_PREFIX_PATTERN, '')
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function formatSlugToken(token: string): string {
|
||||
if (!token) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(token)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
if (UPPERCASE_TOKENS.has(token)) {
|
||||
return token.toUpperCase();
|
||||
}
|
||||
|
||||
return `${token.charAt(0).toUpperCase()}${token.slice(1)}`;
|
||||
}
|
||||
@@ -613,11 +613,11 @@
|
||||
"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_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_LOCATIONS_LABEL": "Standorte",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||
"HERO_SWISS_LOCATION_2": "Zürich",
|
||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||
"BTN_CALCULATE": "Angebot berechnen",
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"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_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_LOCATIONS_LABEL": "Sites",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
|
||||
@@ -84,11 +84,11 @@
|
||||
"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_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_LOCATIONS_LABEL": "Sedi",
|
||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
||||
"HERO_SWISS_LOCATION_2": "Zurigo",
|
||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||
"BTN_CALCULATE": "Calcola Preventivo",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<html lang="it-CH">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>3D fab | Stampa 3D su misura</title>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolvePublicRedirectTarget } from './server-routing';
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,15 +42,22 @@ app.get(
|
||||
);
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
const acceptLanguage = req.get('accept-language');
|
||||
const preferredLanguages = parseAcceptLanguage(acceptLanguage);
|
||||
const userAgent = req.get('user-agent');
|
||||
const preferredLanguages = parseAcceptLanguage(req.get('accept-language'));
|
||||
const lang = resolveInitialLanguage({
|
||||
preferredLanguages,
|
||||
});
|
||||
const stableRedirect = shouldUseStableRootRedirect(
|
||||
userAgent,
|
||||
preferredLanguages,
|
||||
);
|
||||
|
||||
res.setHeader('Vary', 'Accept-Language');
|
||||
res.setHeader('Vary', 'Accept-Language, User-Agent');
|
||||
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) => {
|
||||
@@ -99,3 +106,21 @@ function querySuffix(url: string): string {
|
||||
const queryIndex = String(url ?? '').indexOf('?');
|
||||
return queryIndex >= 0 ? String(url).slice(queryIndex) : '';
|
||||
}
|
||||
|
||||
function shouldUseStableRootRedirect(
|
||||
userAgent: string | undefined,
|
||||
preferredLanguages: readonly string[],
|
||||
): boolean {
|
||||
return preferredLanguages.length === 0 || isLikelyCrawler(userAgent);
|
||||
}
|
||||
|
||||
function isLikelyCrawler(userAgent: string | undefined): boolean {
|
||||
const normalized = String(userAgent ?? '').toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /(bot|crawler|spider|slurp|bingpreview|google-read-aloud)/.test(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user