Compare commits
49 Commits
a3cd451575
...
feat/calcu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5117726432 | ||
| 3ac23173bf | |||
| 132f0f3646 | |||
|
|
8835175fb3 | ||
| 28c3abdb4a | |||
| b30bfc9293 | |||
| d70423fcc0 | |||
| 1b7c0c48e7 | |||
|
|
cb86137730 | ||
| c8913af660 | |||
| 9611049e01 | |||
| bad5947fb5 | |||
| d27558a3ee | |||
| 81f6f78c49 | |||
| bf593445bd | |||
| aa032c0140 | |||
|
|
95e60692c0 | ||
| fda2cdbecb | |||
| a1cc9f18c4 | |||
| 084d35d605 | |||
|
|
02aac24a09 | ||
| 51c2bf6985 | |||
| 4e99d12be1 | |||
| 8b5d8f92e0 | |||
| d3c9dd6eb9 | |||
| 254ff36c50 | |||
| b317196217 | |||
| cc343ee27c | |||
| 74d1b16b7c | |||
| adf6889712 | |||
| 653082868a | |||
| 997e770256 | |||
| fb1a6456e6 | |||
| 43cd80600e | |||
|
|
23e1abdbbb | ||
| e575021f53 | |||
| 7e8c89ce45 | |||
| a40a8df894 | |||
|
|
41f36ed18a | ||
| e04189bbfe | |||
| 20988e425a | |||
| df63937406 | |||
| 4ba408859d | |||
| 996e95f93c | |||
|
|
c4bd0b5a67 | ||
| 5c43873ede | |||
| 249645619e | |||
| be9f303b37 | |||
| 6da8b3b6e4 |
@@ -217,9 +217,12 @@ jobs:
|
|||||||
ADMIN_TTL="${ADMIN_TTL:-480}"
|
ADMIN_TTL="${ADMIN_TTL:-480}"
|
||||||
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
printf 'ADMIN_PASSWORD="%s"\nADMIN_SESSION_SECRET="%s"\nADMIN_SESSION_TTL_MINUTES="%s"\n' \
|
||||||
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
"${{ secrets.ADMIN_PASSWORD }}" "${{ secrets.ADMIN_SESSION_SECRET }}" "$ADMIN_TTL" >> /tmp/full_env.env
|
||||||
|
if [[ -n "${{ secrets.OPENAI_API_KEY }}" ]]; then
|
||||||
|
printf 'OPENAI_API_KEY="%s"\n' "${{ secrets.OPENAI_API_KEY }}" >> /tmp/full_env.env
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Preparing to send env file with variables:"
|
echo "Preparing to send env file with variables:"
|
||||||
grep -Ev "PASSWORD|SECRET" /tmp/full_env.env || true
|
grep -Ev "PASSWORD|SECRET|KEY|TOKEN" /tmp/full_env.env || true
|
||||||
|
|
||||||
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
ssh -i ~/.ssh/id_ed25519 -o BatchMode=yes "${{ secrets.SERVER_USER }}@${{ secrets.SERVER_HOST }}" \
|
||||||
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
"setenv ${{ env.ENV }}" < /tmp/full_env.env
|
||||||
|
|||||||
@@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A
|
|||||||
|
|
||||||
### Database connection
|
### Database connection
|
||||||
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
Verifica le credenziali in `application.properties`. Se usi Docker, puoi passare `DB_URL`, `DB_USERNAME` e `DB_PASSWORD` come variabili d'ambiente.
|
||||||
|
|
||||||
|
### Deploy e traduzioni OpenAI
|
||||||
|
Nel deploy Gitea la chiave OpenAI deve stare nel secret `OPENAI_API_KEY`. La pipeline la aggiunge al file `.env` dell'ambiente durante il deploy e il container backend la riceve come variabile runtime. I file `deploy/envs/*.env` restano per i valori specifici di `dev/int/prod`.
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AllowedOriginService {
|
||||||
|
|
||||||
|
private final List<String> allowedOrigins;
|
||||||
|
|
||||||
|
public AllowedOriginService(
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl,
|
||||||
|
@Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins
|
||||||
|
) {
|
||||||
|
LinkedHashSet<String> configuredOrigins = new LinkedHashSet<>();
|
||||||
|
addConfiguredOrigin(configuredOrigins, frontendBaseUrl, "app.frontend.base-url");
|
||||||
|
|
||||||
|
for (String rawOrigin : additionalAllowedOrigins.split(",")) {
|
||||||
|
addConfiguredOrigin(configuredOrigins, rawOrigin, "app.cors.additional-allowed-origins");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configuredOrigins.isEmpty()) {
|
||||||
|
throw new IllegalStateException("At least one allowed origin must be configured.");
|
||||||
|
}
|
||||||
|
this.allowedOrigins = List.copyOf(configuredOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAllowedOrigins() {
|
||||||
|
return allowedOrigins;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed(String rawOriginOrUrl) {
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addConfiguredOrigin(Set<String> configuredOrigins, String rawOriginOrUrl, String propertyName) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl);
|
||||||
|
if (normalizedOrigin == null) {
|
||||||
|
throw new IllegalStateException(propertyName + " must contain absolute http(s) URLs.");
|
||||||
|
}
|
||||||
|
configuredOrigins.add(normalizedOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRequestOrigin(String rawOriginOrUrl) {
|
||||||
|
if (rawOriginOrUrl == null || rawOriginOrUrl.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(rawOriginOrUrl.trim());
|
||||||
|
String scheme = uri.getScheme();
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (scheme == null || host == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||||
|
if (!"http".equals(normalizedScheme) && !"https".equals(normalizedScheme)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||||
|
int port = uri.getPort();
|
||||||
|
if (isDefaultPort(normalizedScheme, port) || port < 0) {
|
||||||
|
return normalizedScheme + "://" + normalizedHost;
|
||||||
|
}
|
||||||
|
return normalizedScheme + "://" + normalizedHost + ":" + port;
|
||||||
|
} catch (IllegalArgumentException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDefaultPort(String scheme, int port) {
|
||||||
|
return ("http".equals(scheme) && port == 80)
|
||||||
|
|| ("https".equals(scheme) && port == 443);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,27 +1,27 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.context.annotation.Profile;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class CorsConfig implements WebMvcConfigurer {
|
public class CorsConfig {
|
||||||
|
|
||||||
@Override
|
@Bean
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) {
|
||||||
registry.addMapping("/**")
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
.allowedOrigins(
|
configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins());
|
||||||
"http://localhost",
|
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"));
|
||||||
"http://localhost:4200",
|
configuration.setAllowedHeaders(List.of("*"));
|
||||||
"http://localhost:80",
|
configuration.setAllowCredentials(true);
|
||||||
"http://127.0.0.1",
|
configuration.setMaxAge(3600L);
|
||||||
"https://dev.3d-fab.ch",
|
|
||||||
"https://int.3d-fab.ch",
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
"https://3d-fab.ch"
|
source.registerCorsConfiguration("/**", configuration);
|
||||||
)
|
return source;
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
|
||||||
.allowedHeaders("*")
|
|
||||||
.allowCredentials(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.printcalculator.config;
|
package com.printcalculator.config;
|
||||||
|
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -18,6 +19,7 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity http,
|
HttpSecurity http,
|
||||||
|
AdminCsrfProtectionFilter adminCsrfProtectionFilter,
|
||||||
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
AdminSessionAuthenticationFilter adminSessionAuthenticationFilter
|
||||||
) throws Exception {
|
) throws Exception {
|
||||||
http
|
http
|
||||||
@@ -40,7 +42,8 @@ public class SecurityConfig {
|
|||||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}");
|
||||||
}))
|
}))
|
||||||
.addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
|
.addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class);
|
||||||
|
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,18 @@ public class PublicShopController {
|
|||||||
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-path/{publicPath}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByPublicPath(@PathVariable String publicPath,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/products/by-id-prefix/{idPrefix}")
|
||||||
|
public ResponseEntity<ShopProductDetailDto> getProductByIdPrefix(@PathVariable String idPrefix,
|
||||||
|
@RequestParam(required = false) String lang) {
|
||||||
|
return ResponseEntity.ok(publicShopCatalogService.getProductByIdPrefix(idPrefix, lang));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/products/{slug}/model")
|
@GetMapping("/products/{slug}/model")
|
||||||
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
public ResponseEntity<Resource> getProductModel(@PathVariable String slug) throws IOException {
|
||||||
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import java.math.BigDecimal;
|
|||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
||||||
@@ -124,6 +125,9 @@ public class QuoteController {
|
|||||||
if (file.isEmpty()) {
|
if (file.isEmpty()) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
if (!isSupportedInputFile(file)) {
|
||||||
|
throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
|
||||||
|
}
|
||||||
|
|
||||||
// Scan for virus
|
// Scan for virus
|
||||||
clamAVService.scan(file.getInputStream());
|
clamAVService.scan(file.getInputStream());
|
||||||
@@ -153,4 +157,14 @@ public class QuoteController {
|
|||||||
Files.deleteIfExists(tempInput);
|
Files.deleteIfExists(tempInput);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isSupportedInputFile(MultipartFile file) {
|
||||||
|
String originalFilename = file.getOriginalFilename();
|
||||||
|
if (originalFilename == null || originalFilename.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized = originalFilename.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.endsWith(".stl") || normalized.endsWith(".3mf");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ public class QuoteSessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
public ResponseEntity<Map<String, Object>> getQuoteSession(@PathVariable UUID id) {
|
||||||
QuoteSession session = sessionRepo.findById(id)
|
QuoteSession session = sessionRepo.findById(id)
|
||||||
.orElseThrow(() -> new RuntimeException("Session not found"));
|
.orElseThrow(() -> new RuntimeException("Session not found"));
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
import com.printcalculator.dto.AdminShopProductDto;
|
import com.printcalculator.dto.AdminShopProductDto;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
import com.printcalculator.dto.AdminUpsertShopProductRequest;
|
||||||
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
import com.printcalculator.service.admin.AdminShopProductControllerService;
|
||||||
|
import com.printcalculator.service.admin.AdminShopProductTranslationService;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.core.io.UrlResource;
|
import org.springframework.core.io.UrlResource;
|
||||||
import org.springframework.http.HttpHeaders;
|
import org.springframework.http.HttpHeaders;
|
||||||
@@ -29,9 +32,12 @@ import java.util.UUID;
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public class AdminShopProductController {
|
public class AdminShopProductController {
|
||||||
private final AdminShopProductControllerService adminShopProductControllerService;
|
private final AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
private final AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) {
|
public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService,
|
||||||
|
AdminShopProductTranslationService adminShopProductTranslationService) {
|
||||||
this.adminShopProductControllerService = adminShopProductControllerService;
|
this.adminShopProductControllerService = adminShopProductControllerService;
|
||||||
|
this.adminShopProductTranslationService = adminShopProductTranslationService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -50,6 +56,11 @@ public class AdminShopProductController {
|
|||||||
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/translate")
|
||||||
|
public ResponseEntity<AdminTranslateShopProductResponse> translateProduct(@RequestBody AdminTranslateShopProductRequest payload) {
|
||||||
|
return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload));
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/{productId}")
|
@PutMapping("/{productId}")
|
||||||
@Transactional
|
@Transactional
|
||||||
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
public ResponseEntity<AdminShopProductDto> updateProduct(@PathVariable UUID productId,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductRequest {
|
||||||
|
private UUID categoryId;
|
||||||
|
private String sourceLanguage;
|
||||||
|
private Boolean overwriteExisting;
|
||||||
|
private List<String> materialCodes;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public UUID getCategoryId() {
|
||||||
|
return categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategoryId(UUID categoryId) {
|
||||||
|
this.categoryId = categoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Boolean getOverwriteExisting() {
|
||||||
|
return overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOverwriteExisting(Boolean overwriteExisting) {
|
||||||
|
this.overwriteExisting = overwriteExisting;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getMaterialCodes() {
|
||||||
|
return materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMaterialCodes(List<String> materialCodes) {
|
||||||
|
this.materialCodes = materialCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
public class AdminTranslateShopProductResponse {
|
||||||
|
private String sourceLanguage;
|
||||||
|
private List<String> targetLanguages;
|
||||||
|
private Map<String, String> names;
|
||||||
|
private Map<String, String> excerpts;
|
||||||
|
private Map<String, String> descriptions;
|
||||||
|
private Map<String, String> seoTitles;
|
||||||
|
private Map<String, String> seoDescriptions;
|
||||||
|
|
||||||
|
public String getSourceLanguage() {
|
||||||
|
return sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSourceLanguage(String sourceLanguage) {
|
||||||
|
this.sourceLanguage = sourceLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getTargetLanguages() {
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTargetLanguages(List<String> targetLanguages) {
|
||||||
|
this.targetLanguages = targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getNames() {
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNames(Map<String, String> names) {
|
||||||
|
this.names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getExcerpts() {
|
||||||
|
return excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExcerpts(Map<String, String> excerpts) {
|
||||||
|
this.excerpts = excerpts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getDescriptions() {
|
||||||
|
return descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptions(Map<String, String> descriptions) {
|
||||||
|
this.descriptions = descriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoTitles() {
|
||||||
|
return seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoTitles(Map<String, String> seoTitles) {
|
||||||
|
this.seoTitles = seoTitles;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, String> getSeoDescriptions() {
|
||||||
|
return seoDescriptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSeoDescriptions(Map<String, String> seoDescriptions) {
|
||||||
|
this.seoDescriptions = seoDescriptions;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.printcalculator.dto;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductDetailDto(
|
public record ShopProductDetailDto(
|
||||||
@@ -25,6 +26,8 @@ public record ShopProductDetailDto(
|
|||||||
List<ShopProductVariantOptionDto> variants,
|
List<ShopProductVariantOptionDto> variants,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
List<PublicMediaUsageDto> images,
|
List<PublicMediaUsageDto> images,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.dto;
|
package com.printcalculator.dto;
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public record ShopProductSummaryDto(
|
public record ShopProductSummaryDto(
|
||||||
@@ -15,6 +16,8 @@ public record ShopProductSummaryDto(
|
|||||||
BigDecimal priceToChf,
|
BigDecimal priceToChf,
|
||||||
ShopProductVariantOptionDto defaultVariant,
|
ShopProductVariantOptionDto defaultVariant,
|
||||||
PublicMediaUsageDto primaryImage,
|
PublicMediaUsageDto primaryImage,
|
||||||
ShopProductModelDto model3d
|
ShopProductModelDto model3d,
|
||||||
|
String publicPath,
|
||||||
|
Map<String, String> localizedPaths
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -223,10 +223,15 @@ public class OrderEmailListener {
|
|||||||
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
|
order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale))
|
||||||
);
|
);
|
||||||
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
|
templateData.put("totalCost", currencyFormatter.format(order.getTotalChf()));
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
return templateData;
|
return templateData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildLogoUrl() {
|
||||||
|
return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
||||||
|
}
|
||||||
|
|
||||||
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
|
private String applyOrderConfirmationTexts(Map<String, Object> templateData, String language, String orderNumber) {
|
||||||
return switch (language) {
|
return switch (language) {
|
||||||
case "en" -> {
|
case "en" -> {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.printcalculator.repository;
|
package com.printcalculator.repository;
|
||||||
|
|
||||||
import com.printcalculator.entity.QuoteLineItem;
|
import com.printcalculator.entity.QuoteLineItem;
|
||||||
|
import org.springframework.data.jpa.repository.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -8,9 +9,16 @@ import java.util.Optional;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
public interface QuoteLineItemRepository extends JpaRepository<QuoteLineItem, UUID> {
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
List<QuoteLineItem> findByQuoteSessionId(UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
|
List<QuoteLineItem> findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"})
|
||||||
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
|
Optional<QuoteLineItem> findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId);
|
||||||
|
|
||||||
|
@EntityGraph(attributePaths = {"shopProductVariant"})
|
||||||
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
Optional<QuoteLineItem> findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id(
|
||||||
UUID quoteSessionId,
|
UUID quoteSessionId,
|
||||||
String lineItemType,
|
String lineItemType,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,685 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.safety.Safelist;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class AdminShopProductTranslationService {
|
||||||
|
private static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
|
||||||
|
private static final Safelist PRODUCT_DESCRIPTION_SAFELIST = Safelist.none()
|
||||||
|
.addTags("p", "div", "br", "strong", "b", "em", "i", "u", "ul", "ol", "li", "a")
|
||||||
|
.addAttributes("a", "href")
|
||||||
|
.addProtocols("a", "href", "http", "https", "mailto", "tel");
|
||||||
|
private static final String DEFAULT_SHOP_CONTEXT = """
|
||||||
|
3D fab is a Swiss-based 3D printing shop and technical service.
|
||||||
|
The tone must be practical, clear, technical, and trustworthy.
|
||||||
|
Avoid hype, avoid invented claims, and avoid vague marketing filler.
|
||||||
|
Preserve all brand names, measurements, materials, SKUs, codes, and technical terminology exactly when they should not be translated.
|
||||||
|
When the source field is empty, return an empty string rather than inventing content.
|
||||||
|
For descriptions, preserve safe HTML structure when present and keep output ready for an ecommerce/admin form.
|
||||||
|
For SEO, prefer concise, natural phrases suitable for ecommerce and search snippets.
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final ShopCategoryRepository shopCategoryRepository;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
private final String apiKey;
|
||||||
|
private final String baseUrl;
|
||||||
|
private final String model;
|
||||||
|
private final Duration timeout;
|
||||||
|
private final String promptCacheKeyPrefix;
|
||||||
|
private final String additionalBusinessContext;
|
||||||
|
|
||||||
|
public AdminShopProductTranslationService(ShopCategoryRepository shopCategoryRepository,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
@Value("${openai.translation.api-key:}") String apiKey,
|
||||||
|
@Value("${openai.translation.base-url:https://api.openai.com/v1}") String baseUrl,
|
||||||
|
@Value("${openai.translation.model:gpt-5.4}") String model,
|
||||||
|
@Value("${openai.translation.timeout-seconds:45}") long timeoutSeconds,
|
||||||
|
@Value("${openai.translation.prompt-cache-key-prefix:printcalc-shop-product-translation-v1}") String promptCacheKeyPrefix,
|
||||||
|
@Value("${openai.translation.business-context:}") String additionalBusinessContext) {
|
||||||
|
this.shopCategoryRepository = shopCategoryRepository;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.apiKey = apiKey != null ? apiKey.trim() : "";
|
||||||
|
this.baseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
this.model = model != null ? model.trim() : "";
|
||||||
|
this.timeout = Duration.ofSeconds(Math.max(timeoutSeconds, 5));
|
||||||
|
this.promptCacheKeyPrefix = promptCacheKeyPrefix != null && !promptCacheKeyPrefix.isBlank()
|
||||||
|
? promptCacheKeyPrefix.trim()
|
||||||
|
: "printcalc-shop-product-translation-v1";
|
||||||
|
this.additionalBusinessContext = additionalBusinessContext != null ? additionalBusinessContext.trim() : "";
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(this.timeout)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AdminTranslateShopProductResponse translateProduct(AdminTranslateShopProductRequest payload) {
|
||||||
|
ensureConfigured();
|
||||||
|
NormalizedTranslationRequest normalizedRequest = normalizeRequest(payload);
|
||||||
|
List<String> targetLanguages = resolveTargetLanguages(normalizedRequest);
|
||||||
|
if (targetLanguages.isEmpty()) {
|
||||||
|
return emptyResponse(normalizedRequest.sourceLanguage());
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedRequest.categoryId());
|
||||||
|
String businessContext = buildBusinessContext(categoryContext, normalizedRequest.materialCodes());
|
||||||
|
|
||||||
|
TranslationBundle generated = callOpenAiFunction(
|
||||||
|
"generate_product_translations",
|
||||||
|
"Generate translated product copy for the requested target languages.",
|
||||||
|
buildInstructions("Generate the first-pass translations.", businessContext),
|
||||||
|
buildGenerationInput(normalizedRequest, targetLanguages, categoryContext),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"generate"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle normalizedGenerated = sanitizeBundle(generated, targetLanguages);
|
||||||
|
List<String> validationNotes = buildValidationNotes(normalizedGenerated, targetLanguages);
|
||||||
|
|
||||||
|
TranslationBundle reviewed = callOpenAiFunction(
|
||||||
|
"review_product_translations",
|
||||||
|
"Review and correct translated product copy while preserving meaning, SEO limits, and technical terminology.",
|
||||||
|
buildInstructions("Review and correct the generated translations.", businessContext),
|
||||||
|
buildReviewInput(normalizedRequest, normalizedGenerated, targetLanguages, categoryContext, validationNotes),
|
||||||
|
buildTranslationToolSchema(targetLanguages),
|
||||||
|
"review"
|
||||||
|
);
|
||||||
|
|
||||||
|
TranslationBundle finalBundle = sanitizeBundle(reviewed, targetLanguages);
|
||||||
|
ensureRequiredTranslations(finalBundle, targetLanguages);
|
||||||
|
return toResponse(normalizedRequest.sourceLanguage(), targetLanguages, finalBundle);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureConfigured() {
|
||||||
|
if (apiKey.isBlank() || model.isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
|
"OpenAI translation is not configured on the backend"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private NormalizedTranslationRequest normalizeRequest(AdminTranslateShopProductRequest payload) {
|
||||||
|
if (payload == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Translation payload is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
String sourceLanguage = normalizeLanguage(payload.getSourceLanguage());
|
||||||
|
if (!SUPPORTED_LANGUAGES.contains(sourceLanguage)) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported source language");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> names = normalizeLocalizedMap(payload.getNames(), false);
|
||||||
|
Map<String, String> excerpts = normalizeLocalizedMap(payload.getExcerpts(), false);
|
||||||
|
Map<String, String> descriptions = normalizeLocalizedMap(payload.getDescriptions(), true);
|
||||||
|
Map<String, String> seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false);
|
||||||
|
Map<String, String> seoDescriptions = normalizeLocalizedMap(payload.getSeoDescriptions(), false);
|
||||||
|
|
||||||
|
if (names.get(sourceLanguage).isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"The active source language must have a product name before translation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> materialCodes = new LinkedHashSet<>();
|
||||||
|
if (payload.getMaterialCodes() != null) {
|
||||||
|
for (String materialCode : payload.getMaterialCodes()) {
|
||||||
|
String normalizedCode = normalizeOptional(materialCode);
|
||||||
|
if (normalizedCode != null) {
|
||||||
|
materialCodes.add(normalizedCode.toUpperCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NormalizedTranslationRequest(
|
||||||
|
payload.getCategoryId(),
|
||||||
|
sourceLanguage,
|
||||||
|
Boolean.TRUE.equals(payload.getOverwriteExisting()),
|
||||||
|
List.copyOf(materialCodes),
|
||||||
|
names,
|
||||||
|
excerpts,
|
||||||
|
descriptions,
|
||||||
|
seoTitles,
|
||||||
|
seoDescriptions
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> resolveTargetLanguages(NormalizedTranslationRequest request) {
|
||||||
|
List<String> targetLanguages = new ArrayList<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
if (language.equals(request.sourceLanguage())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (request.overwriteExisting() || needsTranslation(request, language)) {
|
||||||
|
targetLanguages.add(language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return targetLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean needsTranslation(NormalizedTranslationRequest request, String language) {
|
||||||
|
return request.names().get(language).isBlank()
|
||||||
|
|| request.excerpts().get(language).isBlank()
|
||||||
|
|| normalizeRichTextOptional(request.descriptions().get(language)) == null
|
||||||
|
|| request.seoTitles().get(language).isBlank()
|
||||||
|
|| request.seoDescriptions().get(language).isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
private CategoryContext loadCategoryContext(UUID categoryId) {
|
||||||
|
if (categoryId == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ShopCategory category = shopCategoryRepository.findById(categoryId).orElse(null);
|
||||||
|
if (category == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new CategoryContext(
|
||||||
|
category.getSlug(),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getNameIt()),
|
||||||
|
"en", safeValue(category.getNameEn()),
|
||||||
|
"de", safeValue(category.getNameDe()),
|
||||||
|
"fr", safeValue(category.getNameFr())
|
||||||
|
),
|
||||||
|
Map.of(
|
||||||
|
"it", safeValue(category.getDescriptionIt()),
|
||||||
|
"en", safeValue(category.getDescriptionEn()),
|
||||||
|
"de", safeValue(category.getDescriptionDe()),
|
||||||
|
"fr", safeValue(category.getDescriptionFr())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildBusinessContext(CategoryContext categoryContext, List<String> materialCodes) {
|
||||||
|
StringBuilder context = new StringBuilder(DEFAULT_SHOP_CONTEXT);
|
||||||
|
if (!additionalBusinessContext.isBlank()) {
|
||||||
|
context.append('\n').append(additionalBusinessContext.trim());
|
||||||
|
}
|
||||||
|
if (categoryContext != null) {
|
||||||
|
context.append("\nCategory slug: ").append(categoryContext.slug());
|
||||||
|
context.append("\nCategory names: ").append(writeJson(categoryContext.names()));
|
||||||
|
if (categoryContext.descriptions().values().stream().anyMatch(value -> !value.isBlank())) {
|
||||||
|
context.append("\nCategory descriptions: ").append(writeJson(categoryContext.descriptions()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (materialCodes != null && !materialCodes.isEmpty()) {
|
||||||
|
context.append("\nMaterial codes present in the product: ").append(String.join(", ", materialCodes));
|
||||||
|
}
|
||||||
|
return context.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildInstructions(String task, String businessContext) {
|
||||||
|
return """
|
||||||
|
You are a senior ecommerce localization editor.
|
||||||
|
Task: %s
|
||||||
|
Return only the function call arguments that match the provided schema.
|
||||||
|
Always preserve meaning, HTML safety, and technical precision.
|
||||||
|
Never invent specifications or marketing claims not present in the source.
|
||||||
|
If a source field is empty, return an empty string for that field.
|
||||||
|
General context:
|
||||||
|
%s
|
||||||
|
""".formatted(task, businessContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildGenerationInput(NormalizedTranslationRequest request,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.put("overwriteExisting", request.overwriteExisting());
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("existingTranslations", existingTranslationsNode(request, targetLanguages));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildReviewInput(NormalizedTranslationRequest request,
|
||||||
|
TranslationBundle generated,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
CategoryContext categoryContext,
|
||||||
|
List<String> validationNotes) {
|
||||||
|
ObjectNode input = objectMapper.createObjectNode();
|
||||||
|
input.put("sourceLanguage", request.sourceLanguage());
|
||||||
|
input.set("targetLanguages", objectMapper.valueToTree(targetLanguages));
|
||||||
|
input.set("source", localizedFieldNode(request, request.sourceLanguage()));
|
||||||
|
input.set("generatedTranslations", generated.toJsonNode(objectMapper));
|
||||||
|
input.set("validationNotes", objectMapper.valueToTree(validationNotes));
|
||||||
|
input.set("materialCodes", objectMapper.valueToTree(request.materialCodes()));
|
||||||
|
if (categoryContext != null) {
|
||||||
|
input.put("categorySlug", categoryContext.slug());
|
||||||
|
input.set("categoryNames", objectMapper.valueToTree(categoryContext.names()));
|
||||||
|
}
|
||||||
|
return writeJson(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode localizedFieldNode(NormalizedTranslationRequest request, String language) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
node.put("name", request.names().get(language));
|
||||||
|
node.put("excerpt", request.excerpts().get(language));
|
||||||
|
node.put("description", request.descriptions().get(language));
|
||||||
|
node.put("seoTitle", request.seoTitles().get(language));
|
||||||
|
node.put("seoDescription", request.seoDescriptions().get(language));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode existingTranslationsNode(NormalizedTranslationRequest request, List<String> targetLanguages) {
|
||||||
|
ObjectNode node = objectMapper.createObjectNode();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
node.set(language, localizedFieldNode(request, language));
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationToolSchema(List<String> targetLanguages) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
root.put("type", "object");
|
||||||
|
root.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode properties = root.putObject("properties");
|
||||||
|
ObjectNode translations = properties.putObject("translations");
|
||||||
|
translations.put("type", "object");
|
||||||
|
translations.put("additionalProperties", false);
|
||||||
|
|
||||||
|
ObjectNode translationProperties = translations.putObject("properties");
|
||||||
|
ArrayNode requiredTranslations = translations.putArray("required");
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
translationProperties.set(language, buildTranslationSchemaForLanguage(language));
|
||||||
|
requiredTranslations.add(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayNode required = root.putArray("required");
|
||||||
|
required.add("translations");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ObjectNode buildTranslationSchemaForLanguage(String language) {
|
||||||
|
ObjectNode languageSchema = objectMapper.createObjectNode();
|
||||||
|
languageSchema.put("type", "object");
|
||||||
|
languageSchema.put("additionalProperties", false);
|
||||||
|
languageSchema.put("description", "Localized product copy for language " + language);
|
||||||
|
|
||||||
|
ObjectNode properties = languageSchema.putObject("properties");
|
||||||
|
addSchemaString(properties, "name", "Translated product name. Never empty.");
|
||||||
|
addSchemaString(properties, "excerpt", "Short excerpt. Empty string if source excerpt is empty.");
|
||||||
|
addSchemaString(properties, "description", "Product description as safe HTML or empty string if source description is empty.");
|
||||||
|
addSchemaString(properties, "seoTitle", "SEO title. Empty string if source SEO title is empty.");
|
||||||
|
addSchemaString(properties, "seoDescription", "SEO description, ideally under 160 characters. Empty string if source SEO description is empty.");
|
||||||
|
|
||||||
|
ArrayNode required = languageSchema.putArray("required");
|
||||||
|
required.add("name");
|
||||||
|
required.add("excerpt");
|
||||||
|
required.add("description");
|
||||||
|
required.add("seoTitle");
|
||||||
|
required.add("seoDescription");
|
||||||
|
return languageSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addSchemaString(ObjectNode properties, String name, String description) {
|
||||||
|
ObjectNode property = properties.putObject(name);
|
||||||
|
property.put("type", "string");
|
||||||
|
property.put("description", description);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle callOpenAiFunction(String functionName,
|
||||||
|
String functionDescription,
|
||||||
|
String instructions,
|
||||||
|
String input,
|
||||||
|
ObjectNode parametersSchema,
|
||||||
|
String cacheSuffix) {
|
||||||
|
ObjectNode requestPayload = objectMapper.createObjectNode();
|
||||||
|
requestPayload.put("model", model);
|
||||||
|
requestPayload.put("instructions", instructions);
|
||||||
|
requestPayload.put("input", input);
|
||||||
|
requestPayload.put("tool_choice", "required");
|
||||||
|
requestPayload.put("temperature", 0.2);
|
||||||
|
requestPayload.put("store", false);
|
||||||
|
requestPayload.put("prompt_cache_key", promptCacheKeyPrefix + ":" + cacheSuffix);
|
||||||
|
|
||||||
|
ArrayNode tools = requestPayload.putArray("tools");
|
||||||
|
ObjectNode tool = tools.addObject();
|
||||||
|
tool.put("type", "function");
|
||||||
|
tool.put("name", functionName);
|
||||||
|
tool.put("description", functionDescription);
|
||||||
|
tool.put("strict", true);
|
||||||
|
tool.set("parameters", parametersSchema);
|
||||||
|
|
||||||
|
JsonNode responseNode = postResponsesRequest(requestPayload);
|
||||||
|
JsonNode output = responseNode.path("output");
|
||||||
|
if (output.isArray()) {
|
||||||
|
for (JsonNode item : output) {
|
||||||
|
if ("function_call".equals(item.path("type").asText())) {
|
||||||
|
String arguments = item.path("arguments").asText("");
|
||||||
|
if (arguments.isBlank()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JsonNode argumentsNode = objectMapper.readTree(arguments);
|
||||||
|
JsonNode translationsNode = argumentsNode.path("translations");
|
||||||
|
if (!translationsNode.isObject()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned a function call without translations"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return TranslationBundle.fromJson(translationsNode);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI returned invalid JSON arguments",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return the expected function call"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode postResponsesRequest(ObjectNode requestPayload) {
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/responses"))
|
||||||
|
.timeout(timeout)
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(writeJson(requestPayload)))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
try {
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
JsonNode body = readJson(response.body());
|
||||||
|
if (response.statusCode() >= 400) {
|
||||||
|
String message = body.path("error").path("message").asText("").trim();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
message.isBlank() ? "OpenAI translation request failed" : message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
} catch (IOException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"Unable to read the OpenAI translation response",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"The OpenAI translation request was interrupted",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> buildValidationNotes(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
List<String> notes = new ArrayList<>();
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
notes.add(language + ": translated name is empty and must be fixed");
|
||||||
|
}
|
||||||
|
String seoDescription = bundle.seoDescriptions().getOrDefault(language, "");
|
||||||
|
if (seoDescription.length() > 160) {
|
||||||
|
notes.add(language + ": seoDescription exceeds 160 characters and must be shortened");
|
||||||
|
}
|
||||||
|
String description = bundle.descriptions().getOrDefault(language, "");
|
||||||
|
if (!description.isBlank() && normalizeRichTextOptional(description) == null) {
|
||||||
|
notes.add(language + ": description lost meaningful text during sanitization");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (notes.isEmpty()) {
|
||||||
|
notes.add("No structural validation issues were found. Review naturalness, terminology, SEO clarity, and consistency.");
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TranslationBundle sanitizeBundle(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
names.put(language, safeValue(bundle.names().get(language)));
|
||||||
|
excerpts.put(language, safeValue(bundle.excerpts().get(language)));
|
||||||
|
descriptions.put(language, safeDescription(bundle.descriptions().get(language)));
|
||||||
|
seoTitles.put(language, safeValue(bundle.seoTitles().get(language)));
|
||||||
|
seoDescriptions.put(language, limitSeoDescription(safeValue(bundle.seoDescriptions().get(language))));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensureRequiredTranslations(TranslationBundle bundle, List<String> targetLanguages) {
|
||||||
|
for (String language : targetLanguages) {
|
||||||
|
if (bundle.names().getOrDefault(language, "").isBlank()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_GATEWAY,
|
||||||
|
"OpenAI did not return a valid translated name for " + language.toUpperCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse toResponse(String sourceLanguage,
|
||||||
|
List<String> targetLanguages,
|
||||||
|
TranslationBundle bundle) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(targetLanguages);
|
||||||
|
response.setNames(bundle.names());
|
||||||
|
response.setExcerpts(bundle.excerpts());
|
||||||
|
response.setDescriptions(bundle.descriptions());
|
||||||
|
response.setSeoTitles(bundle.seoTitles());
|
||||||
|
response.setSeoDescriptions(bundle.seoDescriptions());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AdminTranslateShopProductResponse emptyResponse(String sourceLanguage) {
|
||||||
|
AdminTranslateShopProductResponse response = new AdminTranslateShopProductResponse();
|
||||||
|
response.setSourceLanguage(sourceLanguage);
|
||||||
|
response.setTargetLanguages(List.of());
|
||||||
|
response.setNames(Map.of());
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> normalizeLocalizedMap(Map<String, String> rawValues, boolean richText) {
|
||||||
|
Map<String, String> normalized = new LinkedHashMap<>();
|
||||||
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
|
String value = rawValues != null ? rawValues.get(language) : null;
|
||||||
|
if (richText) {
|
||||||
|
normalized.put(language, normalizeRichTextOptional(value) != null ? normalizeRichTextOptional(value) : "");
|
||||||
|
} else {
|
||||||
|
normalized.put(language, safeValue(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeValue(String value) {
|
||||||
|
return value == null ? "" : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String safeDescription(String value) {
|
||||||
|
String normalized = normalizeRichTextOptional(value);
|
||||||
|
return normalized != null ? normalized : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private String limitSeoDescription(String value) {
|
||||||
|
String normalized = safeValue(value);
|
||||||
|
if (normalized.length() <= 160) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
int lastSpace = normalized.lastIndexOf(' ', 157);
|
||||||
|
if (lastSpace >= 120) {
|
||||||
|
return normalized.substring(0, lastSpace).trim();
|
||||||
|
}
|
||||||
|
return normalized.substring(0, 160).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeOptional(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String normalized = value.trim();
|
||||||
|
return normalized.isBlank() ? null : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
if (language == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String normalized = language.trim().toLowerCase(Locale.ROOT);
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeRichTextOptional(String value) {
|
||||||
|
String normalized = normalizeOptional(value);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String sanitized = Jsoup.clean(
|
||||||
|
normalized,
|
||||||
|
"",
|
||||||
|
PRODUCT_DESCRIPTION_SAFELIST,
|
||||||
|
new Document.OutputSettings().prettyPrint(false)
|
||||||
|
).trim();
|
||||||
|
if (sanitized.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String plainText = Jsoup.parse(sanitized).text();
|
||||||
|
return plainText != null && !plainText.trim().isEmpty() ? sanitized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeBaseUrl(String rawBaseUrl) {
|
||||||
|
String normalized = rawBaseUrl != null && !rawBaseUrl.isBlank()
|
||||||
|
? rawBaseUrl.trim()
|
||||||
|
: "https://api.openai.com/v1";
|
||||||
|
while (normalized.endsWith("/")) {
|
||||||
|
normalized = normalized.substring(0, normalized.length() - 1);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String writeJson(Object value) {
|
||||||
|
try {
|
||||||
|
return objectMapper.writeValueAsString(value);
|
||||||
|
} catch (JsonProcessingException exception) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
"Unable to serialize translation payload",
|
||||||
|
exception
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readJson(String rawJson) throws IOException {
|
||||||
|
return objectMapper.readTree(rawJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NormalizedTranslationRequest(UUID categoryId,
|
||||||
|
String sourceLanguage,
|
||||||
|
boolean overwriteExisting,
|
||||||
|
List<String> materialCodes,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CategoryContext(String slug,
|
||||||
|
Map<String, String> names,
|
||||||
|
Map<String, String> descriptions) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TranslationBundle(Map<String, String> names,
|
||||||
|
Map<String, String> excerpts,
|
||||||
|
Map<String, String> descriptions,
|
||||||
|
Map<String, String> seoTitles,
|
||||||
|
Map<String, String> seoDescriptions) {
|
||||||
|
static TranslationBundle fromJson(JsonNode translationsNode) {
|
||||||
|
Map<String, String> names = new LinkedHashMap<>();
|
||||||
|
Map<String, String> excerpts = new LinkedHashMap<>();
|
||||||
|
Map<String, String> descriptions = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoTitles = new LinkedHashMap<>();
|
||||||
|
Map<String, String> seoDescriptions = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
translationsNode.fieldNames().forEachRemaining(language -> {
|
||||||
|
JsonNode localizedNode = translationsNode.path(language);
|
||||||
|
names.put(language, localizedNode.path("name").asText(""));
|
||||||
|
excerpts.put(language, localizedNode.path("excerpt").asText(""));
|
||||||
|
descriptions.put(language, localizedNode.path("description").asText(""));
|
||||||
|
seoTitles.put(language, localizedNode.path("seoTitle").asText(""));
|
||||||
|
seoDescriptions.put(language, localizedNode.path("seoDescription").asText(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
return new TranslationBundle(names, excerpts, descriptions, seoTitles, seoDescriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
ObjectNode toJsonNode(ObjectMapper objectMapper) {
|
||||||
|
ObjectNode root = objectMapper.createObjectNode();
|
||||||
|
ObjectNode translations = root.putObject("translations");
|
||||||
|
for (String language : names.keySet()) {
|
||||||
|
ObjectNode languageNode = translations.putObject(language);
|
||||||
|
languageNode.put("name", names.getOrDefault(language, ""));
|
||||||
|
languageNode.put("excerpt", excerpts.getOrDefault(language, ""));
|
||||||
|
languageNode.put("description", descriptions.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoTitle", seoTitles.getOrDefault(language, ""));
|
||||||
|
languageNode.put("seoDescription", seoDescriptions.getOrDefault(language, ""));
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,10 +72,14 @@ public class QuoteSessionItemService {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Cannot modify a converted session");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "");
|
||||||
|
if (ext.isBlank()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf");
|
||||||
|
}
|
||||||
|
|
||||||
clamAVService.scan(file.getInputStream());
|
clamAVService.scan(file.getInputStream());
|
||||||
|
|
||||||
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
|
Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId());
|
||||||
String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl");
|
|
||||||
String storedFilename = UUID.randomUUID() + "." + ext;
|
String storedFilename = UUID.randomUUID() + "." + ext;
|
||||||
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
|
Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename);
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ public class QuoteStorageService {
|
|||||||
return switch (ext) {
|
return switch (ext) {
|
||||||
case "stl" -> "stl";
|
case "stl" -> "stl";
|
||||||
case "3mf" -> "3mf";
|
case "3mf" -> "3mf";
|
||||||
case "step", "stp" -> "step";
|
|
||||||
default -> fallback;
|
default -> fallback;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
@Value("${app.mail.contact-request.customer.enabled:true}")
|
@Value("${app.mail.contact-request.customer.enabled:true}")
|
||||||
private boolean contactRequestCustomerMailEnabled;
|
private boolean contactRequestCustomerMailEnabled;
|
||||||
|
|
||||||
|
@Value("${app.frontend.base-url:http://localhost:4200}")
|
||||||
|
private String frontendBaseUrl;
|
||||||
|
|
||||||
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
|
public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService,
|
||||||
ContactRequestLocalizationService localizationService) {
|
ContactRequestLocalizationService localizationService) {
|
||||||
this.emailNotificationService = emailNotificationService;
|
this.emailNotificationService = emailNotificationService;
|
||||||
@@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
templateData.put("phone", safeValue(request.getPhone()));
|
templateData.put("phone", safeValue(request.getPhone()));
|
||||||
templateData.put("message", safeValue(request.getMessage()));
|
templateData.put("message", safeValue(request.getMessage()));
|
||||||
templateData.put("attachmentsCount", attachmentsCount);
|
templateData.put("attachmentsCount", attachmentsCount);
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
|
|
||||||
emailNotificationService.sendEmail(
|
emailNotificationService.sendEmail(
|
||||||
@@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
templateData.put("phone", safeValue(request.getPhone()));
|
templateData.put("phone", safeValue(request.getPhone()));
|
||||||
templateData.put("message", safeValue(request.getMessage()));
|
templateData.put("message", safeValue(request.getMessage()));
|
||||||
templateData.put("attachmentsCount", attachmentsCount);
|
templateData.put("attachmentsCount", attachmentsCount);
|
||||||
|
templateData.put("logoUrl", buildLogoUrl());
|
||||||
templateData.put("currentYear", Year.now().getValue());
|
templateData.put("currentYear", Year.now().getValue());
|
||||||
|
|
||||||
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
|
String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId());
|
||||||
@@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String buildLogoUrl() {
|
||||||
|
String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank()
|
||||||
|
? "http://localhost:4200"
|
||||||
|
: frontendBaseUrl;
|
||||||
|
return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,24 +126,62 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ShopProductDetailDto getProduct(String slug, String language) {
|
public ShopProductDetailDto getProduct(String slug, String language) {
|
||||||
CategoryContext categoryContext = loadCategoryContext(language);
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
PublicProductContext productContext = loadPublicProductContext(categoryContext, language);
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesBySlug().get(slug),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
ProductEntry entry = productContext.entriesBySlug().get(slug);
|
public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) {
|
||||||
if (entry == null) {
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment);
|
||||||
|
if (normalizedPublicPath == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
ShopCategory category = entry.product().getCategory();
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
}
|
productContext.entriesByPublicPath().get(normalizedPublicPath),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
return toProductDetailDto(
|
return toProductDetailDto(
|
||||||
entry,
|
entry,
|
||||||
productContext.productMediaBySlug(),
|
productContext.productMediaBySlug(),
|
||||||
productContext.variantColorHexByMaterialAndColor(),
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
language
|
normalizedLanguage
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShopProductDetailDto getProductByIdPrefix(String idPrefix, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String normalizedIdPrefix = normalizeProductIdPrefix(idPrefix);
|
||||||
|
if (normalizedIdPrefix == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
CategoryContext categoryContext = loadCategoryContext(normalizedLanguage);
|
||||||
|
PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage);
|
||||||
|
ProductEntry entry = requirePublicProductEntry(
|
||||||
|
productContext.entriesByIdPrefix().get(normalizedIdPrefix),
|
||||||
|
categoryContext
|
||||||
|
);
|
||||||
|
|
||||||
|
return toProductDetailDto(
|
||||||
|
entry,
|
||||||
|
productContext.productMediaBySlug(),
|
||||||
|
productContext.variantColorHexByMaterialAndColor(),
|
||||||
|
normalizedLanguage
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +235,7 @@ public class PublicShopCatalogService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) {
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
List<ProductEntry> entries = loadPublicProducts(categoryContext.categoriesById().keySet());
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug = publicMediaQueryService.getUsageMediaMap(
|
||||||
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
SHOP_PRODUCT_MEDIA_USAGE_TYPE,
|
||||||
@@ -207,8 +246,29 @@ public class PublicShopCatalogService {
|
|||||||
|
|
||||||
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
Map<String, ProductEntry> entriesBySlug = entries.stream()
|
||||||
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
.collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new));
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix = entries.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
entry -> normalizeProductIdPrefix(ShopPublicPathSupport.productIdPrefix(entry.product().getId())),
|
||||||
|
entry -> entry,
|
||||||
|
(left, right) -> left,
|
||||||
|
LinkedHashMap::new
|
||||||
|
));
|
||||||
|
|
||||||
return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor);
|
return new PublicProductContext(
|
||||||
|
entries,
|
||||||
|
entriesBySlug,
|
||||||
|
entriesByPublicPath,
|
||||||
|
entriesByIdPrefix,
|
||||||
|
productMediaBySlug,
|
||||||
|
variantColorHexByMaterialAndColor
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<String, String> buildFilamentVariantColorHexMap() {
|
private Map<String, String> buildFilamentVariantColorHexMap() {
|
||||||
@@ -399,6 +459,9 @@ public class PublicShopCatalogService {
|
|||||||
Map<String, String> variantColorHexByMaterialAndColor,
|
Map<String, String> variantColorHexByMaterialAndColor,
|
||||||
String language) {
|
String language) {
|
||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||||
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
return new ShopProductSummaryDto(
|
return new ShopProductSummaryDto(
|
||||||
entry.product().getId(),
|
entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
@@ -415,7 +478,9 @@ public class PublicShopCatalogService {
|
|||||||
resolvePriceTo(entry.variants()),
|
resolvePriceTo(entry.variants()),
|
||||||
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,8 +491,10 @@ public class PublicShopCatalogService {
|
|||||||
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
List<PublicMediaUsageDto> images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of());
|
||||||
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language);
|
||||||
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language);
|
||||||
return new ShopProductDetailDto(
|
String normalizedLanguage = normalizeLanguage(language);
|
||||||
entry.product().getId(),
|
String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage);
|
||||||
|
Map<String, String> localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product());
|
||||||
|
return new ShopProductDetailDto(entry.product().getId(),
|
||||||
entry.product().getSlug(),
|
entry.product().getSlug(),
|
||||||
entry.product().getNameForLanguage(language),
|
entry.product().getNameForLanguage(language),
|
||||||
entry.product().getExcerptForLanguage(language),
|
entry.product().getExcerptForLanguage(language),
|
||||||
@@ -453,7 +520,9 @@ public class PublicShopCatalogService {
|
|||||||
.toList(),
|
.toList(),
|
||||||
selectPrimaryMedia(images),
|
selectPrimaryMedia(images),
|
||||||
images,
|
images,
|
||||||
toProductModelDto(entry)
|
toProductModelDto(entry),
|
||||||
|
publicPathSegment,
|
||||||
|
localizedPaths
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,6 +575,36 @@ public class PublicShopCatalogService {
|
|||||||
return raw.toLowerCase(Locale.ROOT);
|
return raw.toLowerCase(Locale.ROOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) {
|
||||||
|
if (entry == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
ShopCategory category = entry.product().getCategory();
|
||||||
|
if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizePublicPathSegment(String publicPathSegment) {
|
||||||
|
String normalized = trimToNull(publicPathSegment);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalized.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeProductIdPrefix(String idPrefix) {
|
||||||
|
String normalized = trimToNull(idPrefix);
|
||||||
|
if (normalized == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||||
|
return normalized.matches("^[0-9a-f]{8}$") ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
private String trimToNull(String value) {
|
private String trimToNull(String value) {
|
||||||
String raw = String.valueOf(value == null ? "" : value).trim();
|
String raw = String.valueOf(value == null ? "" : value).trim();
|
||||||
if (raw.isEmpty()) {
|
if (raw.isEmpty()) {
|
||||||
@@ -514,6 +613,22 @@ public class PublicShopCatalogService {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String normalizeLanguage(String language) {
|
||||||
|
String normalized = trimToNull(language);
|
||||||
|
if (normalized == null) {
|
||||||
|
return "it";
|
||||||
|
}
|
||||||
|
normalized = normalized.toLowerCase(Locale.ROOT);
|
||||||
|
int separatorIndex = normalized.indexOf('-');
|
||||||
|
if (separatorIndex > 0) {
|
||||||
|
normalized = normalized.substring(0, separatorIndex);
|
||||||
|
}
|
||||||
|
return switch (normalized) {
|
||||||
|
case "en", "de", "fr" -> normalized;
|
||||||
|
default -> "it";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
|
private ShopProductModelDto toProductModelDto(ProductEntry entry) {
|
||||||
if (entry.modelAsset() == null) {
|
if (entry.modelAsset() == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -585,6 +700,8 @@ public class PublicShopCatalogService {
|
|||||||
private record PublicProductContext(
|
private record PublicProductContext(
|
||||||
List<ProductEntry> entries,
|
List<ProductEntry> entries,
|
||||||
Map<String, ProductEntry> entriesBySlug,
|
Map<String, ProductEntry> entriesBySlug,
|
||||||
|
Map<String, ProductEntry> entriesByPublicPath,
|
||||||
|
Map<String, ProductEntry> entriesByIdPrefix,
|
||||||
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
Map<String, List<PublicMediaUsageDto>> productMediaBySlug,
|
||||||
Map<String, String> variantColorHexByMaterialAndColor
|
Map<String, String> variantColorHexByMaterialAndColor
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.printcalculator.service.shop;
|
||||||
|
|
||||||
|
import com.printcalculator.entity.ShopProduct;
|
||||||
|
|
||||||
|
import java.text.Normalizer;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
final class ShopPublicPathSupport {
|
||||||
|
private static final String PRODUCT_ROUTE_PREFIX = "/shop/p/";
|
||||||
|
|
||||||
|
private ShopPublicPathSupport() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static String buildProductPathSegment(ShopProduct product, String language) {
|
||||||
|
String localizedName = product.getNameForLanguage(language);
|
||||||
|
String idPrefix = productIdPrefix(product.getId());
|
||||||
|
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
|
||||||
|
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, String> buildLocalizedProductPaths(ShopProduct product) {
|
||||||
|
Map<String, String> localizedPaths = new LinkedHashMap<>();
|
||||||
|
for (String language : ShopProduct.SUPPORTED_LANGUAGES) {
|
||||||
|
localizedPaths.put(language, "/" + language + PRODUCT_ROUTE_PREFIX + buildProductPathSegment(product, language));
|
||||||
|
}
|
||||||
|
return localizedPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String productIdPrefix(UUID productId) {
|
||||||
|
if (productId == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
|
||||||
|
int dashIndex = raw.indexOf('-');
|
||||||
|
if (dashIndex > 0) {
|
||||||
|
return raw.substring(0, dashIndex);
|
||||||
|
}
|
||||||
|
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String slugify(String rawValue) {
|
||||||
|
String safeValue = rawValue == null ? "" : rawValue;
|
||||||
|
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
|
||||||
|
.replaceAll("\\p{M}+", "")
|
||||||
|
.toLowerCase(Locale.ROOT)
|
||||||
|
.replaceAll("[^a-z0-9]+", "-")
|
||||||
|
.replaceAll("^-+|-+$", "")
|
||||||
|
.replaceAll("-{2,}", "-");
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String firstNonBlank(String... values) {
|
||||||
|
if (values == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (String value : values) {
|
||||||
|
if (value != null && !value.isBlank()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.net.URLEncoder;
|
import java.net.URLEncoder;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.text.Normalizer;
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -19,7 +18,6 @@ import java.time.OffsetDateTime;
|
|||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@@ -31,6 +29,12 @@ public class ShopSitemapService {
|
|||||||
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
|
private static final List<String> SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES;
|
||||||
private static final String DEFAULT_LANGUAGE = "it";
|
private static final String DEFAULT_LANGUAGE = "it";
|
||||||
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
|
||||||
|
private static final Map<String, String> HREFLANG_BY_LANGUAGE = Map.of(
|
||||||
|
"it", "it-CH",
|
||||||
|
"en", "en-CH",
|
||||||
|
"de", "de-CH",
|
||||||
|
"fr", "fr-CH"
|
||||||
|
);
|
||||||
|
|
||||||
private final ShopCategoryRepository shopCategoryRepository;
|
private final ShopCategoryRepository shopCategoryRepository;
|
||||||
private final ShopProductRepository shopProductRepository;
|
private final ShopProductRepository shopProductRepository;
|
||||||
@@ -130,7 +134,7 @@ public class ShopSitemapService {
|
|||||||
|
|
||||||
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
|
Map<String, String> hrefByLanguage = new LinkedHashMap<>();
|
||||||
for (String language : SUPPORTED_LANGUAGES) {
|
for (String language : SUPPORTED_LANGUAGES) {
|
||||||
String publicSegment = localizedProductPathSegment(product, language);
|
String publicSegment = ShopPublicPathSupport.buildProductPathSegment(product, language);
|
||||||
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
|
hrefByLanguage.put(language, frontendBaseUrl + "/" + language + "/shop/p/" + pathEncodeSegment(publicSegment));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +173,7 @@ public class ShopSitemapService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
|
xml.append(" <xhtml:link rel=\"alternate\" hreflang=\"")
|
||||||
.append(language)
|
.append(HREFLANG_BY_LANGUAGE.getOrDefault(language, language))
|
||||||
.append("\" href=\"")
|
.append("\" href=\"")
|
||||||
.append(xmlEscape(href))
|
.append(xmlEscape(href))
|
||||||
.append("\" />\n");
|
.append("\" />\n");
|
||||||
@@ -186,48 +190,6 @@ public class ShopSitemapService {
|
|||||||
xml.append(" </url>\n");
|
xml.append(" </url>\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
private String localizedProductPathSegment(ShopProduct product, String language) {
|
|
||||||
String localizedName = product.getNameForLanguage(language);
|
|
||||||
String idPrefix = productIdPrefix(product.getId());
|
|
||||||
String tail = firstNonBlank(slugify(localizedName), slugify(product.getSlug()), "product");
|
|
||||||
return idPrefix.isBlank() ? tail : idPrefix + "-" + tail;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String productIdPrefix(UUID productId) {
|
|
||||||
if (productId == null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
String raw = productId.toString().trim().toLowerCase(Locale.ROOT);
|
|
||||||
int dashIndex = raw.indexOf('-');
|
|
||||||
if (dashIndex > 0) {
|
|
||||||
return raw.substring(0, dashIndex);
|
|
||||||
}
|
|
||||||
return raw.length() >= 8 ? raw.substring(0, 8) : raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String slugify(String rawValue) {
|
|
||||||
String safeValue = rawValue == null ? "" : rawValue;
|
|
||||||
String normalized = Normalizer.normalize(safeValue, Normalizer.Form.NFD)
|
|
||||||
.replaceAll("\\p{M}+", "")
|
|
||||||
.toLowerCase(Locale.ROOT)
|
|
||||||
.replaceAll("[^a-z0-9]+", "-")
|
|
||||||
.replaceAll("^-+|-+$", "")
|
|
||||||
.replaceAll("-{2,}", "-");
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String firstNonBlank(String... values) {
|
|
||||||
if (values == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (String value : values) {
|
|
||||||
if (value != null && !value.isBlank()) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private String pathEncodeSegment(String rawSegment) {
|
private String pathEncodeSegment(String rawSegment) {
|
||||||
String safeSegment = rawSegment == null ? "" : rawSegment;
|
String safeSegment = rawSegment == null ? "" : rawSegment;
|
||||||
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
|
return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20");
|
||||||
|
|||||||
@@ -56,7 +56,14 @@ app.mail.contact-request.admin.enabled=${APP_MAIL_CONTACT_REQUEST_ADMIN_ENABLED:
|
|||||||
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
app.mail.contact-request.admin.address=${APP_MAIL_CONTACT_REQUEST_ADMIN_ADDRESS:info@3d-fab.ch}
|
||||||
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
app.mail.contact-request.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true}
|
||||||
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200}
|
||||||
|
app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:}
|
||||||
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600}
|
||||||
|
openai.translation.api-key=${OPENAI_API_KEY:}
|
||||||
|
openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1}
|
||||||
|
openai.translation.model=${OPENAI_TRANSLATION_MODEL:gpt-5.4}
|
||||||
|
openai.translation.timeout-seconds=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:45}
|
||||||
|
openai.translation.prompt-cache-key-prefix=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:printcalc-shop-product-translation-v1}
|
||||||
|
openai.translation.business-context=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:}
|
||||||
|
|
||||||
# Admin back-office authentication
|
# Admin back-office authentication
|
||||||
admin.password=${ADMIN_PASSWORD}
|
admin.password=${ADMIN_PASSWORD}
|
||||||
|
|||||||
@@ -25,6 +25,21 @@
|
|||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
color: #444444;
|
color: #444444;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -63,7 +78,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Nuova richiesta di contatto</h1>
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
|
<h1>Nuova richiesta di contatto</h1>
|
||||||
|
</div>
|
||||||
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
|
<p>E' stata ricevuta una nuova richiesta dal form contatti/su misura.</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -25,6 +25,21 @@
|
|||||||
color: #222222;
|
color: #222222;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 1px solid #eeeeee;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
color: #222222;
|
color: #222222;
|
||||||
@@ -69,7 +84,10 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 th:text="${headlineText}">We received your contact request</h1>
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
|
<h1 th:text="${headlineText}">We received your contact request</h1>
|
||||||
|
</div>
|
||||||
<p th:text="${greetingText}">Hi customer,</p>
|
<p th:text="${greetingText}">Hi customer,</p>
|
||||||
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
|
<p th:text="${introText}">Thank you for contacting us. Our team will reply as soon as possible.</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -67,6 +76,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
|
<h1 th:text="${headlineText}">Thank you for your order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
|
<h1 th:text="${headlineText}">Your order #00000000 has been shipped</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
|
<h1 th:text="${headlineText}">Payment confirmed for order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,17 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
color: #333333;
|
color: #333333;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
@@ -70,6 +79,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
<img class="brand-logo" th:src="${logoUrl}" src="https://example.com/assets/images/brand-logo-yellow.svg" alt="3D-Fab">
|
||||||
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
|
<h1 th:text="${headlineText}">Payment reported for order #00000000</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller;
|
package com.printcalculator.controller;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.controller.admin.AdminAuthController;
|
import com.printcalculator.controller.admin.AdminAuthController;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@WebMvcTest(controllers = AdminAuthController.class)
|
@WebMvcTest(controllers = AdminAuthController.class)
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class
|
AdminLoginThrottleService.class
|
||||||
@@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminAuthSecurityTest {
|
class AdminAuthSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -47,6 +57,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.1");
|
req.setRemoteAddr("10.0.0.1");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
@@ -69,6 +80,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.2");
|
req.setRemoteAddr("10.0.0.2");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -83,6 +95,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isUnauthorized())
|
.andExpect(status().isUnauthorized())
|
||||||
@@ -93,12 +106,36 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.3");
|
req.setRemoteAddr("10.0.0.3");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"wrong-password\"}"))
|
.content("{\"password\":\"wrong-password\"}"))
|
||||||
.andExpect(status().isTooManyRequests())
|
.andExpect(status().isTooManyRequests())
|
||||||
.andExpect(jsonPath("$.authenticated").value(false));
|
.andExpect(jsonPath("$.authenticated").value(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
req.setRemoteAddr("10.0.0.30");
|
||||||
|
return req;
|
||||||
|
})
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.error").value("CSRF_INVALID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception {
|
||||||
|
mockMvc.perform(options("/api/admin/auth/login")
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN))
|
||||||
|
.andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
void adminAccessWithoutCookie_ShouldReturn401() throws Exception {
|
||||||
mockMvc.perform(get("/api/admin/auth/me"))
|
mockMvc.perform(get("/api/admin/auth/me"))
|
||||||
@@ -112,6 +149,7 @@ class AdminAuthSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.4");
|
req.setRemoteAddr("10.0.0.4");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.printcalculator.controller.admin;
|
package com.printcalculator.controller.admin;
|
||||||
|
|
||||||
|
import com.printcalculator.config.AllowedOriginService;
|
||||||
|
import com.printcalculator.config.CorsConfig;
|
||||||
import com.printcalculator.config.SecurityConfig;
|
import com.printcalculator.config.SecurityConfig;
|
||||||
import com.printcalculator.service.order.AdminOrderControllerService;
|
import com.printcalculator.service.order.AdminOrderControllerService;
|
||||||
|
import com.printcalculator.security.AdminCsrfProtectionFilter;
|
||||||
import com.printcalculator.security.AdminLoginThrottleService;
|
import com.printcalculator.security.AdminLoginThrottleService;
|
||||||
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
import com.printcalculator.security.AdminSessionAuthenticationFilter;
|
||||||
import com.printcalculator.security.AdminSessionService;
|
import com.printcalculator.security.AdminSessionService;
|
||||||
@@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
|
|
||||||
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class})
|
||||||
@Import({
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
SecurityConfig.class,
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
AdminSessionAuthenticationFilter.class,
|
AdminSessionAuthenticationFilter.class,
|
||||||
AdminSessionService.class,
|
AdminSessionService.class,
|
||||||
AdminLoginThrottleService.class,
|
AdminLoginThrottleService.class,
|
||||||
@@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
|
|||||||
})
|
})
|
||||||
class AdminOrderControllerSecurityTest {
|
class AdminOrderControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private MockMvc mockMvc;
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
@@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest {
|
|||||||
req.setRemoteAddr("10.0.0.44");
|
req.setRemoteAddr("10.0.0.44");
|
||||||
return req;
|
return req;
|
||||||
})
|
})
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content("{\"password\":\"test-admin-password\"}"))
|
.content("{\"password\":\"test-admin-password\"}"))
|
||||||
.andExpect(status().isOk())
|
.andExpect(status().isOk())
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
|
||||||
|
import org.springframework.boot.test.context.TestConfiguration;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.MvcResult;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.TransactionDefinition;
|
||||||
|
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
@WebMvcTest(controllers = {AdminAuthController.class, AdminShopProductController.class})
|
||||||
|
@Import({
|
||||||
|
CorsConfig.class,
|
||||||
|
AllowedOriginService.class,
|
||||||
|
SecurityConfig.class,
|
||||||
|
AdminCsrfProtectionFilter.class,
|
||||||
|
AdminSessionAuthenticationFilter.class,
|
||||||
|
AdminSessionService.class,
|
||||||
|
AdminLoginThrottleService.class,
|
||||||
|
AdminShopProductControllerSecurityTest.TransactionTestConfig.class
|
||||||
|
})
|
||||||
|
@TestPropertySource(properties = {
|
||||||
|
"admin.password=test-admin-password",
|
||||||
|
"admin.session.secret=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||||
|
"admin.session.ttl-minutes=60"
|
||||||
|
})
|
||||||
|
class AdminShopProductControllerSecurityTest {
|
||||||
|
|
||||||
|
private static final String ALLOWED_ORIGIN = "http://localhost:4200";
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductControllerService adminShopProductControllerService;
|
||||||
|
|
||||||
|
@MockitoBean
|
||||||
|
private AdminShopProductTranslationService adminShopProductTranslationService;
|
||||||
|
|
||||||
|
@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();
|
||||||
|
response.setSourceLanguage("it");
|
||||||
|
response.setTargetLanguages(List.of("en", "de", "fr"));
|
||||||
|
response.setNames(Map.of("en", "Desk cable clip"));
|
||||||
|
response.setExcerpts(Map.of());
|
||||||
|
response.setDescriptions(Map.of());
|
||||||
|
response.setSeoTitles(Map.of());
|
||||||
|
response.setSeoDescriptions(Map.of());
|
||||||
|
|
||||||
|
when(adminShopProductTranslationService.translateProduct(org.mockito.ArgumentMatchers.any()))
|
||||||
|
.thenReturn(response);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/admin/shop/products/translate")
|
||||||
|
.cookie(loginAndExtractCookie())
|
||||||
|
.header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{
|
||||||
|
"sourceLanguage":"it",
|
||||||
|
"overwriteExisting":false,
|
||||||
|
"materialCodes":["PLA"],
|
||||||
|
"names":{"it":"Supporto cavo"},
|
||||||
|
"excerpts":{"it":"Accessorio tecnico"},
|
||||||
|
"descriptions":{"it":"<p>Descrizione</p>"},
|
||||||
|
"seoTitles":{"it":"SEO IT"},
|
||||||
|
"seoDescriptions":{"it":"SEO description IT"}
|
||||||
|
}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.sourceLanguage").value("it"))
|
||||||
|
.andExpect(jsonPath("$.targetLanguages[0]").value("en"))
|
||||||
|
.andExpect(jsonPath("$.names.en").value("Desk cable clip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie loginAndExtractCookie() throws Exception {
|
||||||
|
MvcResult login = mockMvc.perform(post("/api/admin/auth/login")
|
||||||
|
.with(req -> {
|
||||||
|
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())
|
||||||
|
.andReturn();
|
||||||
|
|
||||||
|
String setCookie = login.getResponse().getHeader(HttpHeaders.SET_COOKIE);
|
||||||
|
assertNotNull(setCookie);
|
||||||
|
String[] parts = setCookie.split(";", 2);
|
||||||
|
String[] keyValue = parts[0].split("=", 2);
|
||||||
|
return new Cookie(keyValue[0], keyValue.length > 1 ? keyValue[1] : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@TestConfiguration
|
||||||
|
static class TransactionTestConfig {
|
||||||
|
@Bean
|
||||||
|
PlatformTransactionManager transactionManager() {
|
||||||
|
return new AbstractPlatformTransactionManager() {
|
||||||
|
@Override
|
||||||
|
protected Object doGetTransaction() {
|
||||||
|
return new Object();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doBegin(Object transaction, TransactionDefinition definition) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doCommit(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doRollback(DefaultTransactionStatus status) {
|
||||||
|
// No-op transaction manager for WebMvc security tests.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package com.printcalculator.service.admin;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductRequest;
|
||||||
|
import com.printcalculator.dto.AdminTranslateShopProductResponse;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.sun.net.httpserver.HttpExchange;
|
||||||
|
import com.sun.net.httpserver.HttpServer;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class AdminShopProductTranslationServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
|
||||||
|
private HttpServer server;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
if (server != null) {
|
||||||
|
server.stop(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldCallOpenAiTwiceAndReturnReviewedTranslations() throws Exception {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
List<JsonNode> capturedRequests = new CopyOnWriteArrayList<>();
|
||||||
|
AtomicInteger requestCounter = new AtomicInteger();
|
||||||
|
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||||
|
server.createContext("/v1/responses", exchange -> {
|
||||||
|
capturedRequests.add(readBody(objectMapper, exchange));
|
||||||
|
int currentRequest = requestCounter.incrementAndGet();
|
||||||
|
String functionName = currentRequest == 1
|
||||||
|
? "generate_product_translations"
|
||||||
|
: "review_product_translations";
|
||||||
|
String body = functionResponse(
|
||||||
|
objectMapper,
|
||||||
|
functionName,
|
||||||
|
Map.of(
|
||||||
|
"en", localized("Desk cable clip", "Technical desk accessory", "<p>Desk cable clip for clean cable routing.</p>", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."),
|
||||||
|
"de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "<p>Kabelhalter fur einen aufgeraumten Schreibtisch.</p>", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."),
|
||||||
|
"fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "<p>Support de cable pour un bureau ordonne.</p>", "Support de cable de bureau | 3D fab", "Support de cable de bureau imprime en 3D pour garder un espace ordonne.")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
writeJsonResponse(exchange, body);
|
||||||
|
});
|
||||||
|
server.start();
|
||||||
|
|
||||||
|
when(shopCategoryRepository.findById(UUID.fromString("00000000-0000-0000-0000-000000000001")))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:" + server.getAddress().getPort() + "/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
"Use concise ecommerce wording."
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setCategoryId(UUID.fromString("00000000-0000-0000-0000-000000000001"));
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setMaterialCodes(List.of("pla", "petg"));
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Supporto per tenere i cavi ordinati sulla scrivania.</p>",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania | 3D fab",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "Supporto tecnico stampato in 3D per tenere i cavi in ordine sulla scrivania.",
|
||||||
|
"en", "",
|
||||||
|
"de", "",
|
||||||
|
"fr", ""
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
|
||||||
|
assertEquals(List.of("en", "de", "fr"), response.getTargetLanguages());
|
||||||
|
assertEquals("Desk cable clip", response.getNames().get("en"));
|
||||||
|
assertTrue(response.getDescriptions().get("en").contains("<p>"));
|
||||||
|
assertEquals(2, capturedRequests.size());
|
||||||
|
assertEquals("required", capturedRequests.get(0).path("tool_choice").asText());
|
||||||
|
assertEquals("test-cache-key:generate", capturedRequests.get(0).path("prompt_cache_key").asText());
|
||||||
|
assertEquals("test-cache-key:review", capturedRequests.get(1).path("prompt_cache_key").asText());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void translateProduct_shouldSkipOpenAiWhenNoTargetLanguageNeedsUpdates() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
AdminShopProductTranslationService service = new AdminShopProductTranslationService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
objectMapper,
|
||||||
|
"test-key",
|
||||||
|
"http://127.0.0.1:65535/v1",
|
||||||
|
"gpt-5.4",
|
||||||
|
20,
|
||||||
|
"test-cache-key",
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
AdminTranslateShopProductRequest payload = new AdminTranslateShopProductRequest();
|
||||||
|
payload.setSourceLanguage("it");
|
||||||
|
payload.setOverwriteExisting(false);
|
||||||
|
payload.setNames(Map.of(
|
||||||
|
"it", "Supporto cavo scrivania",
|
||||||
|
"en", "Desk cable clip",
|
||||||
|
"de", "Schreibtisch-Kabelhalter",
|
||||||
|
"fr", "Support de cable de bureau"
|
||||||
|
));
|
||||||
|
payload.setExcerpts(Map.of(
|
||||||
|
"it", "Accessorio tecnico",
|
||||||
|
"en", "Technical desk accessory",
|
||||||
|
"de", "Technisches Schreibtisch-Zubehor",
|
||||||
|
"fr", "Accessoire technique de bureau"
|
||||||
|
));
|
||||||
|
payload.setDescriptions(Map.of(
|
||||||
|
"it", "<p>Descrizione</p>",
|
||||||
|
"en", "<p>Description</p>",
|
||||||
|
"de", "<p>Beschreibung</p>",
|
||||||
|
"fr", "<p>Description</p>"
|
||||||
|
));
|
||||||
|
payload.setSeoTitles(Map.of(
|
||||||
|
"it", "SEO IT",
|
||||||
|
"en", "SEO EN",
|
||||||
|
"de", "SEO DE",
|
||||||
|
"fr", "SEO FR"
|
||||||
|
));
|
||||||
|
payload.setSeoDescriptions(Map.of(
|
||||||
|
"it", "SEO description IT",
|
||||||
|
"en", "SEO description EN",
|
||||||
|
"de", "SEO description DE",
|
||||||
|
"fr", "SEO description FR"
|
||||||
|
));
|
||||||
|
|
||||||
|
AdminTranslateShopProductResponse response = service.translateProduct(payload);
|
||||||
|
assertTrue(response.getTargetLanguages().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonNode readBody(ObjectMapper objectMapper, HttpExchange exchange) throws IOException {
|
||||||
|
return objectMapper.readTree(exchange.getRequestBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeJsonResponse(HttpExchange exchange, String body) throws IOException {
|
||||||
|
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||||
|
exchange.getResponseHeaders().set("Content-Type", "application/json");
|
||||||
|
exchange.sendResponseHeaders(200, bytes.length);
|
||||||
|
try (OutputStream outputStream = exchange.getResponseBody()) {
|
||||||
|
outputStream.write(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String functionResponse(ObjectMapper objectMapper,
|
||||||
|
String functionName,
|
||||||
|
Map<String, Map<String, String>> translations) throws IOException {
|
||||||
|
Map<String, Object> arguments = Map.of("translations", translations);
|
||||||
|
Map<String, Object> item = Map.of(
|
||||||
|
"type", "function_call",
|
||||||
|
"name", functionName,
|
||||||
|
"arguments", objectMapper.writeValueAsString(arguments)
|
||||||
|
);
|
||||||
|
Map<String, Object> response = Map.of(
|
||||||
|
"id", "resp_test",
|
||||||
|
"output", List.of(item)
|
||||||
|
);
|
||||||
|
return objectMapper.writeValueAsString(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, String> localized(String name,
|
||||||
|
String excerpt,
|
||||||
|
String description,
|
||||||
|
String seoTitle,
|
||||||
|
String seoDescription) {
|
||||||
|
return Map.of(
|
||||||
|
"name", name,
|
||||||
|
"excerpt", excerpt,
|
||||||
|
"description", description,
|
||||||
|
"seoTitle", seoTitle,
|
||||||
|
"seoDescription", seoDescription
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package com.printcalculator.service.shop;
|
||||||
|
|
||||||
|
import com.printcalculator.dto.ShopProductCatalogResponseDto;
|
||||||
|
import com.printcalculator.dto.ShopProductDetailDto;
|
||||||
|
import com.printcalculator.entity.ShopCategory;
|
||||||
|
import com.printcalculator.entity.ShopProduct;
|
||||||
|
import com.printcalculator.entity.ShopProductVariant;
|
||||||
|
import com.printcalculator.repository.FilamentVariantRepository;
|
||||||
|
import com.printcalculator.repository.ShopCategoryRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductModelAssetRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductRepository;
|
||||||
|
import com.printcalculator.repository.ShopProductVariantRepository;
|
||||||
|
import com.printcalculator.service.media.PublicMediaQueryService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class PublicShopCatalogServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ShopCategoryRepository shopCategoryRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductRepository shopProductRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductVariantRepository shopProductVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private ShopProductModelAssetRepository shopProductModelAssetRepository;
|
||||||
|
@Mock
|
||||||
|
private FilamentVariantRepository filamentVariantRepository;
|
||||||
|
@Mock
|
||||||
|
private PublicMediaQueryService publicMediaQueryService;
|
||||||
|
@Mock
|
||||||
|
private ShopStorageService shopStorageService;
|
||||||
|
|
||||||
|
private PublicShopCatalogService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = new PublicShopCatalogService(
|
||||||
|
shopCategoryRepository,
|
||||||
|
shopProductRepository,
|
||||||
|
shopProductVariantRepository,
|
||||||
|
shopProductModelAssetRepository,
|
||||||
|
filamentVariantRepository,
|
||||||
|
publicMediaQueryService,
|
||||||
|
shopStorageService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductCatalog_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en");
|
||||||
|
|
||||||
|
assertEquals(1, response.products().size());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProduct_shouldExposePublicPathAsSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldResolveLocalizedSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByIdPrefix_shouldResolveLocalizedProduct() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ShopProductDetailDto response = service.getProductByIdPrefix("12345678", "de");
|
||||||
|
|
||||||
|
assertEquals("bike-wall-hanger", response.slug());
|
||||||
|
assertEquals("12345678-bike-wall-hanger", response.publicPath());
|
||||||
|
assertEquals("/de/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("de"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getProductByPublicPath_shouldRejectNonCanonicalSegment() {
|
||||||
|
ShopCategory category = buildCategory();
|
||||||
|
ShopProduct product = buildProduct(category);
|
||||||
|
ShopProductVariant variant = buildVariant(product);
|
||||||
|
|
||||||
|
stubPublicCatalog(category, product, variant);
|
||||||
|
|
||||||
|
ResponseStatusException exception = assertThrows(
|
||||||
|
ResponseStatusException.class,
|
||||||
|
() -> service.getProductByPublicPath("12345678-wrong-tail", "en")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(404, exception.getStatusCode().value());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) {
|
||||||
|
when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category));
|
||||||
|
when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product));
|
||||||
|
when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList()))
|
||||||
|
.thenReturn(List.of(variant));
|
||||||
|
when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of());
|
||||||
|
when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of());
|
||||||
|
when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopCategory buildCategory() {
|
||||||
|
ShopCategory category = new ShopCategory();
|
||||||
|
category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111"));
|
||||||
|
category.setSlug("accessori");
|
||||||
|
category.setName("Accessori");
|
||||||
|
category.setNameIt("Accessori");
|
||||||
|
category.setNameEn("Accessories");
|
||||||
|
category.setIsActive(true);
|
||||||
|
category.setSortOrder(0);
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProduct buildProduct(ShopCategory category) {
|
||||||
|
ShopProduct product = new ShopProduct();
|
||||||
|
product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab"));
|
||||||
|
product.setCategory(category);
|
||||||
|
product.setSlug("bike-wall-hanger");
|
||||||
|
product.setName("Bike Wall-Hanger");
|
||||||
|
product.setNameIt("Supporto bici");
|
||||||
|
product.setNameEn("Bike Wall-Hanger");
|
||||||
|
product.setIsActive(true);
|
||||||
|
product.setIsFeatured(true);
|
||||||
|
product.setSortOrder(0);
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ShopProductVariant buildVariant(ShopProduct product) {
|
||||||
|
ShopProductVariant variant = new ShopProductVariant();
|
||||||
|
variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"));
|
||||||
|
variant.setProduct(product);
|
||||||
|
variant.setVariantLabel("PLA");
|
||||||
|
variant.setColorName("Grigio");
|
||||||
|
variant.setInternalMaterialCode("PLA");
|
||||||
|
variant.setPriceChf(new BigDecimal("29.90"));
|
||||||
|
variant.setIsActive(true);
|
||||||
|
variant.setIsDefault(true);
|
||||||
|
variant.setSortOrder(0);
|
||||||
|
return variant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,15 +92,15 @@ class ShopSitemapServiceTest {
|
|||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/accessori</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/accessori\""));
|
assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/accessori\""));
|
||||||
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
|
assertFalse(xml.contains("https://3d-fab.ch/it/shop/bozza"));
|
||||||
|
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/it/shop/p/123e4567-supporto-bici</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/en/shop/p/123e4567-bike-holder</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter</loc>"));
|
||||||
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
assertTrue(xml.contains("<loc>https://3d-fab.ch/fr/shop/p/123e4567-support-velo</loc>"));
|
||||||
assertTrue(xml.contains("hreflang=\"en\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
assertTrue(xml.contains("hreflang=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\""));
|
||||||
assertTrue(xml.contains("hreflang=\"de\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
assertTrue(xml.contains("hreflang=\"de-CH\" href=\"https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter\""));
|
||||||
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
|
assertTrue(xml.contains("hreflang=\"x-default\" href=\"https://3d-fab.ch/it/shop/p/123e4567-supporto-bici\""));
|
||||||
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
|
assertTrue(xml.contains("<lastmod>2026-03-11T07:30:00Z</lastmod>"));
|
||||||
assertFalse(xml.contains("33333333-draft"));
|
assertFalse(xml.contains("33333333-draft"));
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ services:
|
|||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
- ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET}
|
||||||
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
- ADMIN_SESSION_TTL_MINUTES=${ADMIN_SESSION_TTL_MINUTES:-480}
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-}
|
||||||
|
- OPENAI_TRANSLATION_MODEL=${OPENAI_TRANSLATION_MODEL:-}
|
||||||
|
- OPENAI_TRANSLATION_TIMEOUT_SECONDS=${OPENAI_TRANSLATION_TIMEOUT_SECONDS:-}
|
||||||
|
- OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX=${OPENAI_TRANSLATION_PROMPT_CACHE_KEY_PREFIX:-}
|
||||||
|
- OPENAI_TRANSLATION_BUSINESS_CONTEXT=${OPENAI_TRANSLATION_BUSINESS_CONTEXT:-}
|
||||||
- TEMP_DIR=/app/temp
|
- TEMP_DIR=/app/temp
|
||||||
- PROFILES_DIR=/app/profiles
|
- PROFILES_DIR=/app/profiles
|
||||||
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
- MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media}
|
||||||
@@ -56,6 +62,8 @@ services:
|
|||||||
container_name: print-calculator-frontend-${ENV}
|
container_name: print-calculator-frontend-${ENV}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT}:80"
|
- "${FRONTEND_PORT}:80"
|
||||||
|
environment:
|
||||||
|
- SSR_INTERNAL_API_ORIGIN=http://backend:8000
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 31 KiB |
23
frontend/public/site.webmanifest
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "3D fab",
|
||||||
|
"short_name": "3D fab",
|
||||||
|
"description": "Stampa 3D su misura con preventivo online immediato.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/images/Fav-icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/images/Fav-icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,81 +2,81 @@
|
|||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it</loc>
|
<loc>https://3d-fab.ch/it</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en</loc>
|
<loc>https://3d-fab.ch/en</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de</loc>
|
<loc>https://3d-fab.ch/de</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr</loc>
|
<loc>https://3d-fab.ch/fr</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
<loc>https://3d-fab.ch/it/calculator/basic</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/calculator/basic</loc>
|
<loc>https://3d-fab.ch/en/calculator/basic</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/calculator/basic</loc>
|
<loc>https://3d-fab.ch/de/calculator/basic</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
|
<loc>https://3d-fab.ch/fr/calculator/basic</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/basic" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/basic" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
@@ -84,40 +84,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
<loc>https://3d-fab.ch/it/calculator/advanced</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
|
<loc>https://3d-fab.ch/en/calculator/advanced</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
|
<loc>https://3d-fab.ch/de/calculator/advanced</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
|
<loc>https://3d-fab.ch/fr/calculator/advanced</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/calculator/advanced" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/calculator/advanced" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
@@ -125,40 +125,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/shop</loc>
|
<loc>https://3d-fab.ch/it/shop</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/shop</loc>
|
<loc>https://3d-fab.ch/en/shop</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/shop</loc>
|
<loc>https://3d-fab.ch/de/shop</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/shop</loc>
|
<loc>https://3d-fab.ch/fr/shop</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/shop" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/shop" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/shop" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/shop" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/shop" />
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
@@ -166,40 +166,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/about</loc>
|
<loc>https://3d-fab.ch/it/about</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/about</loc>
|
<loc>https://3d-fab.ch/en/about</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/about</loc>
|
<loc>https://3d-fab.ch/de/about</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/about</loc>
|
<loc>https://3d-fab.ch/fr/about</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/about" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/about" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/about" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/about" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/about" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
@@ -207,40 +207,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/contact</loc>
|
<loc>https://3d-fab.ch/it/contact</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/contact</loc>
|
<loc>https://3d-fab.ch/en/contact</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/contact</loc>
|
<loc>https://3d-fab.ch/de/contact</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/contact</loc>
|
<loc>https://3d-fab.ch/fr/contact</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/contact" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/contact" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/contact" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/contact" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/contact" />
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
@@ -248,40 +248,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/privacy</loc>
|
<loc>https://3d-fab.ch/it/privacy</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/privacy</loc>
|
<loc>https://3d-fab.ch/en/privacy</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/privacy</loc>
|
<loc>https://3d-fab.ch/de/privacy</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/privacy</loc>
|
<loc>https://3d-fab.ch/fr/privacy</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/privacy" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/privacy" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/privacy" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/privacy" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/privacy" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
@@ -289,40 +289,40 @@
|
|||||||
|
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/it/terms</loc>
|
<loc>https://3d-fab.ch/it/terms</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/en/terms</loc>
|
<loc>https://3d-fab.ch/en/terms</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/de/terms</loc>
|
<loc>https://3d-fab.ch/de/terms</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://3d-fab.ch/fr/terms</loc>
|
<loc>https://3d-fab.ch/fr/terms</loc>
|
||||||
<xhtml:link rel="alternate" hreflang="it" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="it-CH" href="https://3d-fab.ch/it/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="en" href="https://3d-fab.ch/en/terms" />
|
<xhtml:link rel="alternate" hreflang="en-CH" href="https://3d-fab.ch/en/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="de" href="https://3d-fab.ch/de/terms" />
|
<xhtml:link rel="alternate" hreflang="de-CH" href="https://3d-fab.ch/de/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="fr" href="https://3d-fab.ch/fr/terms" />
|
<xhtml:link rel="alternate" hreflang="fr-CH" href="https://3d-fab.ch/fr/terms" />
|
||||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
<xhtml:link rel="alternate" hreflang="x-default" href="https://3d-fab.ch/it/terms" />
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.4</priority>
|
<priority>0.4</priority>
|
||||||
|
|||||||
@@ -1 +1,14 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
|
|
||||||
|
@if (siteIntroState() !== "hidden") {
|
||||||
|
<div
|
||||||
|
class="site-intro"
|
||||||
|
[class.site-intro--closing]="siteIntroState() === 'closing'"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<app-brand-animation-logo
|
||||||
|
class="site-intro__logo"
|
||||||
|
variant="site-intro"
|
||||||
|
></app-brand-animation-logo>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.site-intro {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.24s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro--closing {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro__logo {
|
||||||
|
width: min(calc(100vw - 2rem), 23rem);
|
||||||
|
--brand-animation-width: 23rem;
|
||||||
|
--brand-animation-height: 7.1rem;
|
||||||
|
--brand-animation-letter-width: 3.75rem;
|
||||||
|
--brand-animation-scale: 0.88;
|
||||||
|
--brand-animation-width-mobile: 16.8rem;
|
||||||
|
--brand-animation-height-mobile: 5.3rem;
|
||||||
|
--brand-animation-letter-width-mobile: 2.8rem;
|
||||||
|
--brand-animation-scale-mobile: 0.68;
|
||||||
|
--brand-animation-site-intro-duration: 1.05s;
|
||||||
|
justify-self: center;
|
||||||
|
align-self: center;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
transition:
|
||||||
|
opacity 0.24s ease-out,
|
||||||
|
transform 0.24s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-intro--closing .site-intro__logo {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,50 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
Inject,
|
||||||
|
Optional,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
import { RouterOutlet } from '@angular/router';
|
import { RouterOutlet } from '@angular/router';
|
||||||
import { SeoService } from './core/services/seo.service';
|
import { SeoService } from './core/services/seo.service';
|
||||||
|
import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterOutlet],
|
imports: [RouterOutlet, BrandAnimationLogoComponent],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
readonly siteIntroState = signal<'hidden' | 'active' | 'closing'>('hidden');
|
||||||
|
|
||||||
|
constructor(@Optional() @Inject(PLATFORM_ID) platformId?: Object) {
|
||||||
|
if (!isPlatformBrowser(platformId ?? 'browser')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterNextRender(() => {
|
||||||
|
this.siteIntroState.set('active');
|
||||||
|
|
||||||
|
const closeTimeoutId = window.setTimeout(() => {
|
||||||
|
this.siteIntroState.set('closing');
|
||||||
|
}, 1020);
|
||||||
|
|
||||||
|
const hideTimeoutId = window.setTimeout(() => {
|
||||||
|
this.siteIntroState.set('hidden');
|
||||||
|
}, 1280);
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
window.clearTimeout(closeTimeoutId);
|
||||||
|
window.clearTimeout(hideTimeoutId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,21 +28,12 @@ import {
|
|||||||
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
|
import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor';
|
||||||
import { catchError, firstValueFrom, of } from 'rxjs';
|
import { catchError, firstValueFrom, of } from 'rxjs';
|
||||||
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
|
import { StaticTranslateLoader } from './core/i18n/static-translate.loader';
|
||||||
|
import {
|
||||||
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
getNavigatorLanguagePreferences,
|
||||||
const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr'];
|
parseAcceptLanguage,
|
||||||
|
resolveInitialLanguage,
|
||||||
function resolveLangFromUrl(url: string): SupportedLang {
|
SUPPORTED_LANGS,
|
||||||
const firstSegment = (url || '/')
|
} from './core/i18n/language-resolution';
|
||||||
.split('?')[0]
|
|
||||||
.split('#')[0]
|
|
||||||
.split('/')
|
|
||||||
.filter(Boolean)[0]
|
|
||||||
?.toLowerCase();
|
|
||||||
return SUPPORTED_LANGS.includes(firstSegment as SupportedLang)
|
|
||||||
? (firstSegment as SupportedLang)
|
|
||||||
: 'it';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withComponentInputBinding(),
|
withComponentInputBinding(),
|
||||||
withViewTransitions(),
|
withViewTransitions(),
|
||||||
withInMemoryScrolling({
|
withInMemoryScrolling({
|
||||||
scrollPositionRestoration: 'top',
|
scrollPositionRestoration: 'enabled',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
@@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
),
|
),
|
||||||
importProvidersFrom(
|
importProvidersFrom(
|
||||||
TranslateModule.forRoot({
|
TranslateModule.forRoot({
|
||||||
defaultLanguage: 'it',
|
fallbackLang: 'it',
|
||||||
loader: {
|
loader: {
|
||||||
provide: TranslateLoader,
|
provide: TranslateLoader,
|
||||||
useClass: StaticTranslateLoader,
|
useClass: StaticTranslateLoader,
|
||||||
@@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = {
|
|||||||
const router = inject(Router);
|
const router = inject(Router);
|
||||||
const request = inject(REQUEST, { optional: true }) as {
|
const request = inject(REQUEST, { optional: true }) as {
|
||||||
url?: string;
|
url?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
translate.addLangs([...SUPPORTED_LANGS]);
|
translate.addLangs([...SUPPORTED_LANGS]);
|
||||||
translate.setDefaultLang('it');
|
translate.setFallbackLang('it');
|
||||||
const requestedUrl =
|
const requestedUrl =
|
||||||
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
(typeof request?.url === 'string' && request.url) || router.url || '/';
|
||||||
const lang = resolveLangFromUrl(requestedUrl);
|
const lang = resolveInitialLanguage({
|
||||||
|
url: requestedUrl,
|
||||||
|
preferredLanguages: request
|
||||||
|
? parseAcceptLanguage(readRequestHeader(request, 'accept-language'))
|
||||||
|
: getNavigatorLanguagePreferences(
|
||||||
|
typeof navigator === 'undefined' ? null : navigator,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
return firstValueFrom(
|
return firstValueFrom(
|
||||||
translate.use(lang).pipe(
|
translate.use(lang).pipe(
|
||||||
@@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideClientHydration(withEventReplay()),
|
provideClientHydration(withEventReplay()),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function readRequestHeader(
|
||||||
|
request: {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
} | null,
|
||||||
|
headerName: string,
|
||||||
|
): string | null {
|
||||||
|
if (!request?.headers) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = request.headers[headerName.toLowerCase()];
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -134,6 +134,31 @@ const appChildRoutes: Routes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: ':lang/calculator/animation-test',
|
||||||
|
canMatch: [langPrefixCanMatch],
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/calculator/calculator-animation-test.component').then(
|
||||||
|
(m) => m.CalculatorAnimationTestComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'calculator/animation-test',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/calculator/calculator-animation-test.component').then(
|
||||||
|
(m) => m.CalculatorAnimationTestComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: ':lang',
|
path: ':lang',
|
||||||
canMatch: [langPrefixCanMatch],
|
canMatch: [langPrefixCanMatch],
|
||||||
|
|||||||
135
frontend/src/app/core/i18n/language-resolution.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
|
export const SUPPORTED_LANGS: readonly SupportedLang[] = [
|
||||||
|
'it',
|
||||||
|
'en',
|
||||||
|
'de',
|
||||||
|
'fr',
|
||||||
|
];
|
||||||
|
|
||||||
|
type InitialLanguageOptions = {
|
||||||
|
url?: string | null;
|
||||||
|
preferredLanguages?: readonly string[] | null;
|
||||||
|
fallbackLang?: SupportedLang;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NavigatorLike = {
|
||||||
|
language?: string;
|
||||||
|
languages?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveInitialLanguage({
|
||||||
|
url,
|
||||||
|
preferredLanguages,
|
||||||
|
fallbackLang = 'it',
|
||||||
|
}: InitialLanguageOptions): SupportedLang {
|
||||||
|
const explicitLang = resolveExplicitLanguageFromUrl(url);
|
||||||
|
if (explicitLang) {
|
||||||
|
return explicitLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of preferredLanguages ?? []) {
|
||||||
|
const normalized = normalizeSupportedLanguage(candidate);
|
||||||
|
if (normalized) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackLang;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAcceptLanguage(
|
||||||
|
header: string | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!header) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return header
|
||||||
|
.split(',')
|
||||||
|
.map((entry, index) => {
|
||||||
|
const [rawTag, ...params] = entry.split(';').map((part) => part.trim());
|
||||||
|
if (!rawTag) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityParam = params.find((param) => param.startsWith('q='));
|
||||||
|
const quality = qualityParam
|
||||||
|
? Number.parseFloat(qualityParam.slice(2))
|
||||||
|
: 1;
|
||||||
|
return {
|
||||||
|
tag: rawTag,
|
||||||
|
quality: Number.isFinite(quality) ? quality : 0,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
entry,
|
||||||
|
): entry is {
|
||||||
|
tag: string;
|
||||||
|
quality: number;
|
||||||
|
index: number;
|
||||||
|
} => entry !== null && entry.quality > 0,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) => right.quality - left.quality || left.index - right.index,
|
||||||
|
)
|
||||||
|
.map((entry) => entry.tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNavigatorLanguagePreferences(
|
||||||
|
navigatorLike: NavigatorLike | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!navigatorLike) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedLanguages = [
|
||||||
|
...(Array.isArray(navigatorLike.languages) ? navigatorLike.languages : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof navigatorLike.language === 'string' &&
|
||||||
|
navigatorLike.language &&
|
||||||
|
!orderedLanguages.includes(navigatorLike.language)
|
||||||
|
) {
|
||||||
|
orderedLanguages.push(navigatorLike.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedLanguages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExplicitLanguageFromUrl(
|
||||||
|
url: string | null | undefined,
|
||||||
|
): SupportedLang | null {
|
||||||
|
const normalizedUrl = String(url ?? '/');
|
||||||
|
const [pathAndQuery] = normalizedUrl.split('#', 1);
|
||||||
|
const [rawPath, rawQuery] = pathAndQuery.split('?', 2);
|
||||||
|
const firstSegment = rawPath.split('/').filter(Boolean)[0];
|
||||||
|
const pathLanguage = normalizeSupportedLanguage(firstSegment);
|
||||||
|
if (pathLanguage) {
|
||||||
|
return pathLanguage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryLanguage = new URLSearchParams(rawQuery ?? '').get('lang');
|
||||||
|
return normalizeSupportedLanguage(queryLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSupportedLanguage(
|
||||||
|
rawLanguage: string | null | undefined,
|
||||||
|
): SupportedLang | null {
|
||||||
|
if (typeof rawLanguage !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawLanguage.trim().toLowerCase();
|
||||||
|
if (!normalized || normalized === '*') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [baseLanguage] = normalized.split('-', 1);
|
||||||
|
return SUPPORTED_LANGS.includes(baseLanguage as SupportedLang)
|
||||||
|
? (baseLanguage as SupportedLang)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
@@ -1,22 +1,93 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
TransferState,
|
||||||
|
inject,
|
||||||
|
makeStateKey,
|
||||||
|
} from '@angular/core';
|
||||||
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
|
import { TranslateLoader, TranslationObject } from '@ngx-translate/core';
|
||||||
import { Observable, of } from 'rxjs';
|
import { from, Observable } from 'rxjs';
|
||||||
import de from '../../../assets/i18n/de.json';
|
|
||||||
import en from '../../../assets/i18n/en.json';
|
|
||||||
import fr from '../../../assets/i18n/fr.json';
|
|
||||||
import it from '../../../assets/i18n/it.json';
|
|
||||||
|
|
||||||
const TRANSLATIONS: Record<string, TranslationObject> = {
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
it: it as TranslationObject,
|
|
||||||
en: en as TranslationObject,
|
const FALLBACK_LANG: SupportedLang = 'it';
|
||||||
de: de as TranslationObject,
|
const translationCache = new Map<SupportedLang, Promise<TranslationObject>>();
|
||||||
fr: fr as TranslationObject,
|
|
||||||
|
const translationLoaders: Record<
|
||||||
|
SupportedLang,
|
||||||
|
() => Promise<TranslationObject>
|
||||||
|
> = {
|
||||||
|
it: () =>
|
||||||
|
import('../../../assets/i18n/it.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
en: () =>
|
||||||
|
import('../../../assets/i18n/en.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
de: () =>
|
||||||
|
import('../../../assets/i18n/de.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
|
fr: () =>
|
||||||
|
import('../../../assets/i18n/fr.json').then(
|
||||||
|
(module) => module.default as TranslationObject,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StaticTranslateLoader implements TranslateLoader {
|
export class StaticTranslateLoader implements TranslateLoader {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private readonly transferState = inject(TransferState);
|
||||||
|
|
||||||
getTranslation(lang: string): Observable<TranslationObject> {
|
getTranslation(lang: string): Observable<TranslationObject> {
|
||||||
const normalized = String(lang || 'it').toLowerCase();
|
const normalized = this.normalizeLanguage(lang);
|
||||||
return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']);
|
return from(this.loadTranslation(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLanguage(lang: string): SupportedLang {
|
||||||
|
const normalized = String(lang || FALLBACK_LANG).toLowerCase();
|
||||||
|
return normalized in translationLoaders
|
||||||
|
? (normalized as SupportedLang)
|
||||||
|
: FALLBACK_LANG;
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadTranslation(lang: SupportedLang): Promise<TranslationObject> {
|
||||||
|
const transferStateKey = makeStateKey<TranslationObject>(
|
||||||
|
`i18n:${lang.toLowerCase()}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
isPlatformBrowser(this.platformId) &&
|
||||||
|
this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
const transferred = this.transferState.get(transferStateKey, {});
|
||||||
|
this.transferState.remove(transferStateKey);
|
||||||
|
return Promise.resolve(transferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = translationCache.get(lang);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = translationLoaders[lang]()
|
||||||
|
.then((translation) => {
|
||||||
|
if (
|
||||||
|
isPlatformServer(this.platformId) &&
|
||||||
|
!this.transferState.hasKey(transferStateKey)
|
||||||
|
) {
|
||||||
|
this.transferState.set(transferStateKey, translation);
|
||||||
|
}
|
||||||
|
return translation;
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
lang === FALLBACK_LANG
|
||||||
|
? Promise.resolve({})
|
||||||
|
: this.loadTranslation(FALLBACK_LANG),
|
||||||
|
);
|
||||||
|
|
||||||
|
translationCache.set(lang, pending);
|
||||||
|
return pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { HttpClient } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
HttpTestingController,
|
||||||
|
provideHttpClientTesting,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import { REQUEST } from '@angular/core';
|
||||||
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
|
import { serverOriginInterceptor } from './server-origin.interceptor';
|
||||||
|
|
||||||
|
type TestGlobal = typeof globalThis & {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('serverOriginInterceptor', () => {
|
||||||
|
let http: HttpClient;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
const testGlobal = globalThis as TestGlobal;
|
||||||
|
const originalInternalApiOrigin = testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
authorization: 'Basic dGVzdDp0ZXN0',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
if (originalInternalApiOrigin) {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = originalInternalApiOrigin;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
delete testGlobal.__SSR_INTERNAL_API_ORIGIN__;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => {
|
||||||
|
http.get('/api/shop/products/by-path/example?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite explicit request headers', () => {
|
||||||
|
http
|
||||||
|
.get('/api/shop/products', {
|
||||||
|
headers: {
|
||||||
|
authorization: 'Bearer explicit-token',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Bearer explicit-token',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the internal SSR API origin for public shop discovery calls', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bypasses the public origin even when the proxy strips authorization on shop SSR requests', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://backend:8000/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
expect(request.request.headers.get('accept-language')).toBe(
|
||||||
|
'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps transactional shop API calls on the public origin', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
http.get('/api/shop/cart').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne('https://dev.3d-fab.ch/api/shop/cart');
|
||||||
|
expect(request.request.headers.get('authorization')).toBe(
|
||||||
|
'Basic dGVzdDp0ZXN0',
|
||||||
|
);
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps non-shop pages on the public origin even for public shop APIs', () => {
|
||||||
|
testGlobal.__SSR_INTERNAL_API_ORIGIN__ = 'http://backend:8000/';
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptors([serverOriginInterceptor])),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
{
|
||||||
|
provide: REQUEST,
|
||||||
|
useValue: {
|
||||||
|
protocol: 'https',
|
||||||
|
url: '/de/checkout?session=abc',
|
||||||
|
headers: {
|
||||||
|
host: 'dev.3d-fab.ch',
|
||||||
|
cookie: 'session=abc123',
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
http = TestBed.inject(HttpClient);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
|
||||||
|
http.get('/api/shop/products/by-id-prefix/91823f84?lang=de').subscribe();
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'https://dev.3d-fab.ch/api/shop/products/by-id-prefix/91823f84?lang=de',
|
||||||
|
);
|
||||||
|
expect(request.request.headers.get('authorization')).toBeNull();
|
||||||
|
expect(request.request.headers.get('cookie')).toBe('session=abc123');
|
||||||
|
request.flush({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,27 @@ import {
|
|||||||
resolveRequestOrigin,
|
resolveRequestOrigin,
|
||||||
} from '../../../core/request-origin';
|
} from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type ServerRequestLike = RequestLike & {
|
||||||
|
originalUrl?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FORWARDED_REQUEST_HEADERS = [
|
||||||
|
'authorization',
|
||||||
|
'cookie',
|
||||||
|
'accept-language',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SHOP_DISCOVERY_API_PATTERNS = [
|
||||||
|
/^\/api\/shop\/categories(?:\/[^/?#]+)?$/i,
|
||||||
|
/^\/api\/shop\/products$/i,
|
||||||
|
/^\/api\/shop\/products\/by-id-prefix\/[^/?#]+$/i,
|
||||||
|
/^\/api\/shop\/products\/by-path\/[^/?#]+$/i,
|
||||||
|
/^\/api\/shop\/products\/[^/?#]+$/i,
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SHOP_PAGE_PATH_PATTERN = /^\/(?:it|en|de|fr)\/shop(?:\/.*)?$/i;
|
||||||
|
|
||||||
function isAbsoluteUrl(url: string): boolean {
|
function isAbsoluteUrl(url: string): boolean {
|
||||||
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//');
|
||||||
}
|
}
|
||||||
@@ -14,17 +35,135 @@ function normalizeRelativePath(url: string): string {
|
|||||||
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripQueryAndHash(url: string): string {
|
||||||
|
return String(url ?? '').split(/[?#]/, 1)[0] || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(origin: string): string {
|
||||||
|
return origin.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestHeader(
|
||||||
|
request: RequestLike | null,
|
||||||
|
name: (typeof FORWARDED_REQUEST_HEADERS)[number],
|
||||||
|
): string | null {
|
||||||
|
const normalizedName = name.toLowerCase();
|
||||||
|
const headerValue =
|
||||||
|
request?.headers?.[normalizedName] ?? request?.get?.(normalizedName);
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRequestPath(request: ServerRequestLike | null): string | null {
|
||||||
|
const rawPath =
|
||||||
|
(typeof request?.originalUrl === 'string' && request.originalUrl) ||
|
||||||
|
(typeof request?.url === 'string' && request.url) ||
|
||||||
|
null;
|
||||||
|
if (!rawPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAbsoluteUrl(rawPath)) {
|
||||||
|
try {
|
||||||
|
return stripQueryAndHash(new URL(rawPath).pathname || '/');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stripQueryAndHash(rawPath.startsWith('/') ? rawPath : `/${rawPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicShopPageRequest(request: ServerRequestLike | null): boolean {
|
||||||
|
const requestPath = readRequestPath(request);
|
||||||
|
return !!requestPath && SHOP_PAGE_PATH_PATTERN.test(requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPublicShopDiscoveryApi(url: string): boolean {
|
||||||
|
const normalizedPath = stripQueryAndHash(normalizeRelativePath(url));
|
||||||
|
return SHOP_DISCOVERY_API_PATTERNS.some((pattern) =>
|
||||||
|
pattern.test(normalizedPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readInternalApiOrigin(): string | null {
|
||||||
|
const globalObject = globalThis as {
|
||||||
|
__SSR_INTERNAL_API_ORIGIN__?: string;
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const explicitOverride =
|
||||||
|
typeof globalObject.__SSR_INTERNAL_API_ORIGIN__ === 'string'
|
||||||
|
? globalObject.__SSR_INTERNAL_API_ORIGIN__
|
||||||
|
: null;
|
||||||
|
const env = (
|
||||||
|
globalObject as {
|
||||||
|
process?: {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
).process?.env;
|
||||||
|
const rawValue = explicitOverride ?? env?.['SSR_INTERNAL_API_ORIGIN'];
|
||||||
|
if (typeof rawValue !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = rawValue.trim();
|
||||||
|
return normalized ? normalizeOrigin(normalized) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveApiOrigin(
|
||||||
|
request: ServerRequestLike | null,
|
||||||
|
relativeUrl: string,
|
||||||
|
): string | null {
|
||||||
|
const internalOrigin = readInternalApiOrigin();
|
||||||
|
if (
|
||||||
|
internalOrigin &&
|
||||||
|
isPublicShopPageRequest(request) &&
|
||||||
|
isPublicShopDiscoveryApi(relativeUrl)
|
||||||
|
) {
|
||||||
|
return internalOrigin;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveRequestOrigin(request);
|
||||||
|
}
|
||||||
|
|
||||||
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => {
|
||||||
if (isAbsoluteUrl(req.url)) {
|
if (isAbsoluteUrl(req.url)) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const request = inject(REQUEST, { optional: true }) as RequestLike | null;
|
const request = inject(REQUEST, {
|
||||||
const origin = resolveRequestOrigin(request);
|
optional: true,
|
||||||
|
}) as ServerRequestLike | null;
|
||||||
|
const origin = resolveApiOrigin(request, req.url);
|
||||||
if (!origin) {
|
if (!origin) {
|
||||||
return next(req);
|
return next(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`;
|
const absoluteUrl = `${normalizeOrigin(origin)}${normalizeRelativePath(req.url)}`;
|
||||||
return next(req.clone({ url: absoluteUrl }));
|
const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce<
|
||||||
|
Record<string, string>
|
||||||
|
>((headers, name) => {
|
||||||
|
if (req.headers.has(name)) {
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = readRequestHeader(request, name);
|
||||||
|
if (value) {
|
||||||
|
headers[name] = value;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return next(
|
||||||
|
req.clone({
|
||||||
|
url: absoluteUrl,
|
||||||
|
setHeaders: forwardedHeaders,
|
||||||
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-inner">
|
<div class="container footer-inner">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<span class="brand">3D fab</span>
|
<img
|
||||||
|
class="brand"
|
||||||
|
src="/assets/images/brand-logo-white.svg"
|
||||||
|
alt="3D Fab"
|
||||||
|
/>
|
||||||
<p class="copyright">© 2026 3D fab.</p>
|
<p class="copyright">© 2026 3D fab.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,10 +22,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col social">
|
<div class="col social">
|
||||||
<!-- Social Placeholders -->
|
<div class="social-link-row">
|
||||||
<div class="social-icon"></div>
|
<span class="social-name">Joe Küng:</span>
|
||||||
<div class="social-icon"></div>
|
<a
|
||||||
<div class="social-icon"></div>
|
class="social-icon-link"
|
||||||
|
href="https://www.linkedin.com/in/joe-k%C3%BCng-31831828b/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Joe Küng LinkedIn"
|
||||||
|
>
|
||||||
|
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="social-link-row">
|
||||||
|
<span class="social-name">Matteo Caletti:</span>
|
||||||
|
<a
|
||||||
|
class="social-icon-link"
|
||||||
|
href="https://www.linkedin.com/in/matteo-caletti-94291a3b6/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="Matteo Caletti LinkedIn"
|
||||||
|
>
|
||||||
|
<span class="social-icon-linkedin" aria-hidden="true"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brand {
|
.brand {
|
||||||
font-weight: 700;
|
|
||||||
color: white;
|
|
||||||
display: block;
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 1.85rem;
|
||||||
|
max-width: min(9.25rem, 46vw);
|
||||||
margin-bottom: var(--space-2);
|
margin-bottom: var(--space-2);
|
||||||
}
|
}
|
||||||
.copyright {
|
.copyright {
|
||||||
@@ -65,11 +66,69 @@
|
|||||||
|
|
||||||
.social {
|
.social {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
.social-icon {
|
|
||||||
width: 24px;
|
.social-link-row {
|
||||||
height: 24px;
|
display: flex;
|
||||||
background-color: var(--color-neutral-800);
|
align-items: center;
|
||||||
border-radius: 50%;
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-name {
|
||||||
|
color: var(--color-neutral-200);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icon-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--color-neutral-50);
|
||||||
|
color: #0a66c2;
|
||||||
|
transition:
|
||||||
|
transform 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #0a66c2;
|
||||||
|
color: var(--color-neutral-50);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--color-secondary-500);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-icon-linkedin {
|
||||||
|
display: block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-image: url("/assets/images/SVG/linkedin-svgrepo-com.svg");
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.social {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.social-link-row {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
<header class="navbar">
|
<header class="navbar">
|
||||||
<div class="container navbar-inner">
|
<div class="container navbar-inner">
|
||||||
<a [routerLink]="langService.localizedPath('/')" class="brand"
|
<a [routerLink]="langService.localizedPath('/')" class="brand">
|
||||||
>3D <span class="highlight">fab</span></a
|
<img
|
||||||
>
|
class="brand-logo"
|
||||||
|
ngSrc="/assets/images/Asset%202.svg"
|
||||||
|
alt="3D Fab"
|
||||||
|
width="380"
|
||||||
|
height="86"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mobile-toggle"
|
class="mobile-toggle"
|
||||||
@@ -23,7 +30,7 @@
|
|||||||
>{{ "NAV.HOME" | translate }}</a
|
>{{ "NAV.HOME" | translate }}</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
[routerLink]="langService.localizedPath('/calculator/basic')"
|
[routerLink]="langService.localizedPath('/calculator')"
|
||||||
routerLinkActive="active"
|
routerLinkActive="active"
|
||||||
[routerLinkActiveOptions]="{ exact: false }"
|
[routerLinkActiveOptions]="{ exact: false }"
|
||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
|
|||||||
@@ -14,13 +14,16 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.brand {
|
.brand {
|
||||||
font-size: 1.25rem;
|
display: inline-flex;
|
||||||
font-weight: 700;
|
align-items: center;
|
||||||
color: var(--color-text);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.highlight {
|
|
||||||
color: var(--color-brand);
|
.brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: 2.1rem;
|
||||||
|
max-width: min(11rem, 40vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, NgOptimizedImage } from '@angular/common';
|
||||||
import { Component, DestroyRef, computed, inject, signal } from '@angular/core';
|
import {
|
||||||
|
afterNextRender,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import {
|
import {
|
||||||
NavigationStart,
|
NavigationStart,
|
||||||
@@ -23,7 +30,13 @@ import {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-navbar',
|
selector: 'app-navbar',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, RouterLinkActive, TranslateModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterLinkActive,
|
||||||
|
TranslateModule,
|
||||||
|
NgOptimizedImage,
|
||||||
|
],
|
||||||
templateUrl: './navbar.component.html',
|
templateUrl: './navbar.component.html',
|
||||||
styleUrls: ['./navbar.component.scss'],
|
styleUrls: ['./navbar.component.scss'],
|
||||||
})
|
})
|
||||||
@@ -58,16 +71,9 @@ export class NavbarComponent {
|
|||||||
];
|
];
|
||||||
|
|
||||||
constructor(public langService: LanguageService) {
|
constructor(public langService: LanguageService) {
|
||||||
if (!this.shopService.cartLoaded()) {
|
afterNextRender(() => {
|
||||||
this.shopService
|
this.scheduleCartWarmup();
|
||||||
.loadCart()
|
});
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
||||||
.subscribe({
|
|
||||||
error: () => {
|
|
||||||
this.shopService.cart.set(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
@@ -96,6 +102,9 @@ export class NavbarComponent {
|
|||||||
toggleCart(): void {
|
toggleCart(): void {
|
||||||
this.closeMenu();
|
this.closeMenu();
|
||||||
this.isCartOpen.update((open) => !open);
|
this.isCartOpen.update((open) => !open);
|
||||||
|
if (this.isCartOpen()) {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
closeCart(): void {
|
closeCart(): void {
|
||||||
@@ -192,5 +201,44 @@ export class NavbarComponent {
|
|||||||
.subscribe();
|
.subscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleCartWarmup(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmup = () => this.loadCartIfNeeded();
|
||||||
|
const idleCallback = (
|
||||||
|
window as Window & {
|
||||||
|
requestIdleCallback?: (
|
||||||
|
callback: IdleRequestCallback,
|
||||||
|
options?: IdleRequestOptions,
|
||||||
|
) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
|
|
||||||
|
if (typeof idleCallback === 'function') {
|
||||||
|
idleCallback(() => warmup(), { timeout: 1500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(warmup, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCartIfNeeded(): void {
|
||||||
|
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shopService
|
||||||
|
.loadCart()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.shopService.cart.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected readonly routes = routes;
|
protected readonly routes = routes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { DefaultUrlSerializer, Router, UrlTree } from '@angular/router';
|
import {
|
||||||
|
DefaultUrlSerializer,
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
UrlTree,
|
||||||
|
} from '@angular/router';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import { LanguageService } from './language.service';
|
import { LanguageService } from './language.service';
|
||||||
|
import { RequestLike } from '../../../core/request-origin';
|
||||||
|
|
||||||
describe('LanguageService', () => {
|
describe('LanguageService', () => {
|
||||||
function createTranslateMock() {
|
function createTranslateMock() {
|
||||||
@@ -9,7 +15,7 @@ describe('LanguageService', () => {
|
|||||||
const translate = {
|
const translate = {
|
||||||
currentLang: '',
|
currentLang: '',
|
||||||
addLangs: jasmine.createSpy('addLangs'),
|
addLangs: jasmine.createSpy('addLangs'),
|
||||||
setDefaultLang: jasmine.createSpy('setDefaultLang'),
|
setFallbackLang: jasmine.createSpy('setFallbackLang'),
|
||||||
use: jasmine.createSpy('use').and.callFake((lang: string) => {
|
use: jasmine.createSpy('use').and.callFake((lang: string) => {
|
||||||
translate.currentLang = lang;
|
translate.currentLang = lang;
|
||||||
onLangChange.next({ lang });
|
onLangChange.next({ lang });
|
||||||
@@ -60,7 +66,14 @@ describe('LanguageService', () => {
|
|||||||
parseUrl: (url: string) => serializer.parse(url),
|
parseUrl: (url: string) => serializer.parse(url),
|
||||||
createUrlTree,
|
createUrlTree,
|
||||||
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
|
serializeUrl: (tree: UrlTree) => serializer.serialize(tree),
|
||||||
navigateByUrl: jasmine.createSpy('navigateByUrl'),
|
navigateByUrl: jasmine
|
||||||
|
.createSpy('navigateByUrl')
|
||||||
|
.and.callFake((tree: UrlTree) => {
|
||||||
|
const nextUrl = serializer.serialize(tree);
|
||||||
|
router.url = nextUrl;
|
||||||
|
events$.next(new NavigationEnd(1, nextUrl, nextUrl));
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
return router as unknown as Router;
|
return router as unknown as Router;
|
||||||
@@ -70,11 +83,17 @@ describe('LanguageService', () => {
|
|||||||
const translate = createTranslateMock();
|
const translate = createTranslateMock();
|
||||||
const router = createRouterMock('/calculator?session=abc');
|
const router = createRouterMock('/calculator?session=abc');
|
||||||
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
|
const request: RequestLike = {
|
||||||
|
headers: {
|
||||||
|
'accept-language': 'it-CH,it;q=0.9,en;q=0.8',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new LanguageService(translate, router);
|
const service = new LanguageService(translate, router, request);
|
||||||
|
|
||||||
expect(translate.use).toHaveBeenCalledWith('it');
|
expect(translate.use).toHaveBeenCalledWith('it');
|
||||||
|
expect((translate as any).setFallbackLang).toHaveBeenCalledWith('it');
|
||||||
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const firstCall = navigateSpy.calls.mostRecent();
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
@@ -84,6 +103,48 @@ describe('LanguageService', () => {
|
|||||||
expect(navOptions.replaceUrl).toBeTrue();
|
expect(navOptions.replaceUrl).toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the preferred browser language on the root URL', () => {
|
||||||
|
const translate = createTranslateMock();
|
||||||
|
const router = createRouterMock('/');
|
||||||
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
|
const request: RequestLike = {
|
||||||
|
headers: {
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const service = new LanguageService(translate, router, request);
|
||||||
|
|
||||||
|
expect(translate.use).toHaveBeenCalledWith('de');
|
||||||
|
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
|
const tree = firstCall.args[0] as UrlTree;
|
||||||
|
expect(router.serializeUrl(tree)).toBe('/de');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default language for non-root URLs without a language prefix', () => {
|
||||||
|
const translate = createTranslateMock();
|
||||||
|
const router = createRouterMock('/calculator?session=abc');
|
||||||
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
|
const request: RequestLike = {
|
||||||
|
headers: {
|
||||||
|
'accept-language': 'de-CH,de;q=0.9,en;q=0.8,it;q=0.7',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const service = new LanguageService(translate, router, request);
|
||||||
|
|
||||||
|
expect(translate.use).toHaveBeenCalledWith('de');
|
||||||
|
expect(navigateSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const firstCall = navigateSpy.calls.mostRecent();
|
||||||
|
const tree = firstCall.args[0] as UrlTree;
|
||||||
|
expect(router.serializeUrl(tree)).toBe('/it/calculator?session=abc');
|
||||||
|
});
|
||||||
|
|
||||||
it('switches language while preserving path and query params', () => {
|
it('switches language while preserving path and query params', () => {
|
||||||
const translate = createTranslateMock();
|
const translate = createTranslateMock();
|
||||||
const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
|
const router = createRouterMock('/it/calculator?session=abc&mode=advanced');
|
||||||
@@ -114,4 +175,23 @@ describe('LanguageService', () => {
|
|||||||
'/de/contact?topic=seo#form',
|
'/de/contact?topic=seo#form',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('switches product pages using the resolved localized route overrides', () => {
|
||||||
|
const translate = createTranslateMock();
|
||||||
|
const router = createRouterMock('/it/shop/p/12345678-supporto-cavo');
|
||||||
|
const navigateSpy = router.navigateByUrl as unknown as jasmine.Spy;
|
||||||
|
const service = new LanguageService(translate, router);
|
||||||
|
|
||||||
|
service.setLocalizedRouteOverrides({
|
||||||
|
it: '/it/shop/p/12345678-supporto-cavo',
|
||||||
|
de: '/de/shop/p/12345678-kabelhalter',
|
||||||
|
});
|
||||||
|
navigateSpy.calls.reset();
|
||||||
|
|
||||||
|
service.switchLang('de');
|
||||||
|
|
||||||
|
const call = navigateSpy.calls.mostRecent();
|
||||||
|
const tree = call.args[0] as UrlTree;
|
||||||
|
expect(router.serializeUrl(tree)).toBe('/de/shop/p/12345678-kabelhalter');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Inject, Injectable, Optional, REQUEST, signal } from '@angular/core';
|
||||||
import { TranslateService } from '@ngx-translate/core';
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
NavigationEnd,
|
NavigationEnd,
|
||||||
@@ -6,25 +6,32 @@ import {
|
|||||||
Router,
|
Router,
|
||||||
UrlTree,
|
UrlTree,
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
import {
|
||||||
|
getNavigatorLanguagePreferences,
|
||||||
|
parseAcceptLanguage,
|
||||||
|
resolveInitialLanguage,
|
||||||
|
} from '../i18n/language-resolution';
|
||||||
|
import { RequestLike } from '../../../core/request-origin';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
type LocalizedRouteOverrides = Partial<Record<SupportedLang, string>>;
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class LanguageService {
|
export class LanguageService {
|
||||||
currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
currentLang = signal<SupportedLang>('it');
|
||||||
private readonly supportedLangs: Array<'it' | 'en' | 'de' | 'fr'> = [
|
private readonly defaultLang: SupportedLang = 'it';
|
||||||
'it',
|
private readonly supportedLangs: SupportedLang[] = ['it', 'en', 'de', 'fr'];
|
||||||
'en',
|
private localizedRouteOverrides: LocalizedRouteOverrides | null = null;
|
||||||
'de',
|
|
||||||
'fr',
|
|
||||||
];
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private translate: TranslateService,
|
private translate: TranslateService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
|
@Optional() @Inject(REQUEST) private request: RequestLike | null = null,
|
||||||
) {
|
) {
|
||||||
this.translate.addLangs(this.supportedLangs);
|
this.translate.addLangs(this.supportedLangs);
|
||||||
this.translate.setDefaultLang('it');
|
this.translate.setFallbackLang('it');
|
||||||
this.translate.onLangChange.subscribe((event) => {
|
this.translate.onLangChange.subscribe((event) => {
|
||||||
const lang =
|
const lang =
|
||||||
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
|
typeof event.lang === 'string' ? event.lang.toLowerCase() : null;
|
||||||
@@ -34,13 +41,14 @@ export class LanguageService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const initialTree = this.router.parseUrl(this.router.url);
|
const initialTree = this.router.parseUrl(this.router.url);
|
||||||
const initialSegments = this.getPrimarySegments(initialTree);
|
const initialLang = resolveInitialLanguage({
|
||||||
const queryLang = this.getQueryLang(initialTree);
|
url: this.router.url,
|
||||||
const initialLang = this.isSupportedLang(initialSegments[0])
|
preferredLanguages: this.request
|
||||||
? initialSegments[0]
|
? parseAcceptLanguage(this.readRequestHeader('accept-language'))
|
||||||
: this.isSupportedLang(queryLang)
|
: getNavigatorLanguagePreferences(
|
||||||
? queryLang
|
typeof navigator === 'undefined' ? null : navigator,
|
||||||
: 'it';
|
),
|
||||||
|
});
|
||||||
this.applyLanguage(initialLang);
|
this.applyLanguage(initialLang);
|
||||||
this.ensureLanguageInPath(initialTree);
|
this.ensureLanguageInPath(initialTree);
|
||||||
|
|
||||||
@@ -53,13 +61,21 @@ export class LanguageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
switchLang(lang: 'it' | 'en' | 'de' | 'fr') {
|
switchLang(lang: SupportedLang) {
|
||||||
if (!this.isSupportedLang(lang)) {
|
if (!this.isSupportedLang(lang)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.applyLanguage(lang);
|
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
const currentTree = this.router.parseUrl(this.router.url);
|
||||||
|
const localizedRoute = this.resolveLocalizedRouteOverride(
|
||||||
|
currentTree,
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
if (localizedRoute) {
|
||||||
|
this.navigateToLocalizedRoute(currentTree, localizedRoute);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const segments = this.getPrimarySegments(currentTree);
|
const segments = this.getPrimarySegments(currentTree);
|
||||||
|
|
||||||
let targetSegments: string[];
|
let targetSegments: string[];
|
||||||
@@ -77,7 +93,7 @@ export class LanguageService {
|
|||||||
this.navigateIfChanged(currentTree, targetSegments);
|
this.navigateIfChanged(currentTree, targetSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedLang(): 'it' | 'en' | 'de' | 'fr' {
|
selectedLang(): SupportedLang {
|
||||||
const activeLang =
|
const activeLang =
|
||||||
typeof this.translate.currentLang === 'string'
|
typeof this.translate.currentLang === 'string'
|
||||||
? this.translate.currentLang.toLowerCase()
|
? this.translate.currentLang.toLowerCase()
|
||||||
@@ -110,6 +126,16 @@ export class LanguageService {
|
|||||||
return `/${[lang, ...segments].join('/')}${suffix}`;
|
return `/${[lang, ...segments].join('/')}${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLocalizedRouteOverrides(
|
||||||
|
paths: LocalizedRouteOverrides | null | undefined,
|
||||||
|
): void {
|
||||||
|
this.localizedRouteOverrides = this.normalizeLocalizedRouteOverrides(paths);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearLocalizedRouteOverrides(): void {
|
||||||
|
this.localizedRouteOverrides = null;
|
||||||
|
}
|
||||||
|
|
||||||
private ensureLanguageInPath(urlTree: UrlTree): void {
|
private ensureLanguageInPath(urlTree: UrlTree): void {
|
||||||
const segments = this.getPrimarySegments(urlTree);
|
const segments = this.getPrimarySegments(urlTree);
|
||||||
|
|
||||||
@@ -118,23 +144,26 @@ export class LanguageService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryLang = this.getQueryLang(urlTree);
|
|
||||||
const activeLang = this.isSupportedLang(queryLang)
|
|
||||||
? queryLang
|
|
||||||
: this.currentLang();
|
|
||||||
if (activeLang !== this.currentLang()) {
|
|
||||||
this.applyLanguage(activeLang);
|
|
||||||
}
|
|
||||||
let targetSegments: string[];
|
|
||||||
|
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
targetSegments = [activeLang];
|
const queryLang = this.getQueryLang(urlTree);
|
||||||
} else if (this.looksLikeLangToken(segments[0])) {
|
const rootLang = this.isSupportedLang(queryLang)
|
||||||
targetSegments = [activeLang, ...segments.slice(1)];
|
? queryLang
|
||||||
} else {
|
: this.currentLang();
|
||||||
targetSegments = [activeLang, ...segments];
|
if (rootLang !== this.currentLang()) {
|
||||||
|
this.applyLanguage(rootLang);
|
||||||
|
}
|
||||||
|
this.navigateIfChanged(urlTree, [rootLang]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.currentLang() !== this.defaultLang) {
|
||||||
|
this.applyLanguage(this.defaultLang);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetSegments = this.looksLikeLangToken(segments[0])
|
||||||
|
? [this.defaultLang, ...segments.slice(1)]
|
||||||
|
: [this.defaultLang, ...segments];
|
||||||
|
|
||||||
this.navigateIfChanged(urlTree, targetSegments);
|
this.navigateIfChanged(urlTree, targetSegments);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,12 +180,23 @@ export class LanguageService {
|
|||||||
return typeof lang === 'string' ? lang.toLowerCase() : null;
|
return typeof lang === 'string' ? lang.toLowerCase() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readRequestHeader(headerName: string): string | null {
|
||||||
|
const headerValue =
|
||||||
|
this.request?.headers?.[headerName.toLowerCase()] ??
|
||||||
|
this.request?.get?.(headerName.toLowerCase());
|
||||||
|
if (Array.isArray(headerValue)) {
|
||||||
|
return headerValue[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof headerValue === 'string' ? headerValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
private isSupportedLang(
|
private isSupportedLang(
|
||||||
lang: string | null | undefined,
|
lang: string | null | undefined,
|
||||||
): lang is 'it' | 'en' | 'de' | 'fr' {
|
): lang is SupportedLang {
|
||||||
return (
|
return (
|
||||||
typeof lang === 'string' &&
|
typeof lang === 'string' &&
|
||||||
this.supportedLangs.includes(lang as 'it' | 'en' | 'de' | 'fr')
|
this.supportedLangs.includes(lang as SupportedLang)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +206,7 @@ export class LanguageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyLanguage(lang: 'it' | 'en' | 'de' | 'fr'): void {
|
private applyLanguage(lang: SupportedLang): void {
|
||||||
if (this.currentLang() === lang && this.translate.currentLang === lang) {
|
if (this.currentLang() === lang && this.translate.currentLang === lang) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -174,6 +214,88 @@ export class LanguageService {
|
|||||||
this.currentLang.set(lang);
|
this.currentLang.set(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveLocalizedRouteOverride(
|
||||||
|
currentTree: UrlTree,
|
||||||
|
lang: SupportedLang,
|
||||||
|
): string | null {
|
||||||
|
const overrides = this.localizedRouteOverrides;
|
||||||
|
if (!overrides) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPath = this.getCleanPath(
|
||||||
|
this.router.serializeUrl(currentTree),
|
||||||
|
);
|
||||||
|
const paths = Object.values(overrides)
|
||||||
|
.map((path) => this.normalizeLocalizedRoutePath(path))
|
||||||
|
.filter((path): path is string => !!path);
|
||||||
|
if (!paths.includes(currentPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.normalizeLocalizedRoutePath(overrides[lang]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLocalizedRouteOverrides(
|
||||||
|
paths: LocalizedRouteOverrides | null | undefined,
|
||||||
|
): LocalizedRouteOverrides | null {
|
||||||
|
if (!paths) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.supportedLangs.reduce<LocalizedRouteOverrides>(
|
||||||
|
(accumulator, lang) => {
|
||||||
|
const path = this.normalizeLocalizedRoutePath(paths[lang]);
|
||||||
|
if (path) {
|
||||||
|
accumulator[lang] = path;
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLocalizedRoutePath(
|
||||||
|
path: string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
const rawPath = String(path ?? '').trim();
|
||||||
|
if (!rawPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const cleanPath = this.getCleanPath(rawPath);
|
||||||
|
return cleanPath.startsWith('/') ? cleanPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private navigateToLocalizedRoute(
|
||||||
|
currentTree: UrlTree,
|
||||||
|
localizedPath: string,
|
||||||
|
): void {
|
||||||
|
const { lang: _unusedLang, ...queryParams } = currentTree.queryParams;
|
||||||
|
const targetTree = this.router.createUrlTree(
|
||||||
|
['/', ...localizedPath.split('/').filter(Boolean)],
|
||||||
|
{
|
||||||
|
queryParams,
|
||||||
|
fragment: currentTree.fragment ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.router.serializeUrl(targetTree) ===
|
||||||
|
this.router.serializeUrl(currentTree)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigateByUrl(targetTree, { replaceUrl: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
private getCleanPath(url: string): string {
|
||||||
|
const path = (url || '/').split('?')[0].split('#')[0];
|
||||||
|
return path || '/';
|
||||||
|
}
|
||||||
|
|
||||||
private navigateIfChanged(
|
private navigateIfChanged(
|
||||||
currentTree: UrlTree,
|
currentTree: UrlTree,
|
||||||
targetSegments: string[],
|
targetSegments: string[],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ describe('SeoService', () => {
|
|||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
translations: Record<string, string>;
|
translations: Record<string, string>;
|
||||||
}): {
|
}): {
|
||||||
|
service: SeoService;
|
||||||
meta: jasmine.SpyObj<Meta>;
|
meta: jasmine.SpyObj<Meta>;
|
||||||
title: jasmine.SpyObj<Title>;
|
title: jasmine.SpyObj<Title>;
|
||||||
} {
|
} {
|
||||||
@@ -51,7 +52,7 @@ describe('SeoService', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const service = new SeoService(router, title, meta, translate, document);
|
const service = new SeoService(router, title, meta, translate, document);
|
||||||
|
|
||||||
return { meta, title };
|
return { service, meta, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -94,14 +95,14 @@ describe('SeoService', () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
expect(alternates).toContain({
|
expect(alternates).toContain({
|
||||||
hreflang: 'en',
|
hreflang: 'en-CH',
|
||||||
href: `${document.location.origin}/en/privacy`,
|
href: `${document.location.origin}/en/privacy`,
|
||||||
});
|
});
|
||||||
expect(alternates).toContain({
|
expect(alternates).toContain({
|
||||||
hreflang: 'x-default',
|
hreflang: 'x-default',
|
||||||
href: `${document.location.origin}/it/privacy`,
|
href: `${document.location.origin}/it/privacy`,
|
||||||
});
|
});
|
||||||
expect(document.documentElement.lang).toBe('it');
|
expect(document.documentElement.lang).toBe('it-CH');
|
||||||
|
|
||||||
const ogUrlCall = meta.updateTag.calls
|
const ogUrlCall = meta.updateTag.calls
|
||||||
.allArgs()
|
.allArgs()
|
||||||
@@ -109,6 +110,39 @@ describe('SeoService', () => {
|
|||||||
expect(ogUrlCall?.[0].content).toBe(
|
expect(ogUrlCall?.[0].content).toBe(
|
||||||
`${document.location.origin}/it/privacy`,
|
`${document.location.origin}/it/privacy`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ogLocaleCall = meta.updateTag.calls
|
||||||
|
.allArgs()
|
||||||
|
.find(([tag]) => tag.property === 'og:locale');
|
||||||
|
expect(ogLocaleCall?.[0].content).toBe('it_CH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the locale-adaptive root as x-default for home pages', () => {
|
||||||
|
createService({
|
||||||
|
url: '/de',
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.HOME.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION',
|
||||||
|
},
|
||||||
|
translations: {
|
||||||
|
'SEO.ROUTES.HOME.TITLE': '3D-Druck in Zürich | 3D fab',
|
||||||
|
'SEO.ROUTES.HOME.DESCRIPTION': '3D-Druckservice in Zürich',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alternates = Array.from(
|
||||||
|
document.head.querySelectorAll(
|
||||||
|
'link[rel="alternate"][data-seo-managed="true"]',
|
||||||
|
),
|
||||||
|
).map((node) => ({
|
||||||
|
hreflang: node.getAttribute('hreflang'),
|
||||||
|
href: node.getAttribute('href'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(alternates).toContain({
|
||||||
|
hreflang: 'x-default',
|
||||||
|
href: `${document.location.origin}/`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves translated route metadata for the active language', () => {
|
it('resolves translated route metadata for the active language', () => {
|
||||||
@@ -130,6 +164,54 @@ describe('SeoService', () => {
|
|||||||
.allArgs()
|
.allArgs()
|
||||||
.find(([tag]) => tag.name === 'description');
|
.find(([tag]) => tag.name === 'description');
|
||||||
expect(descriptionCall?.[0].content).toBe('About description');
|
expect(descriptionCall?.[0].content).toBe('About description');
|
||||||
expect(document.documentElement.lang).toBe('en');
|
expect(document.documentElement.lang).toBe('en-CH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies canonical and hreflang values resolved from localized paths', () => {
|
||||||
|
const { service } = createService({
|
||||||
|
url: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
||||||
|
data: {},
|
||||||
|
translations: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
service.applyResolvedSeo({
|
||||||
|
title: 'Supporto cavo scrivania | 3D fab',
|
||||||
|
description: 'Accessorio tecnico',
|
||||||
|
robots: 'index, follow',
|
||||||
|
ogTitle: 'Supporto cavo scrivania | 3D fab',
|
||||||
|
ogDescription: 'Accessorio tecnico',
|
||||||
|
canonicalPath: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
||||||
|
alternates: {
|
||||||
|
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
||||||
|
en: '/en/shop/p/12345678-desk-cable-clip',
|
||||||
|
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
|
||||||
|
},
|
||||||
|
xDefault: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
||||||
|
});
|
||||||
|
|
||||||
|
const canonical = document.head.querySelector(
|
||||||
|
'link[rel="canonical"]',
|
||||||
|
) as HTMLLinkElement | null;
|
||||||
|
expect(canonical?.getAttribute('href')).toBe(
|
||||||
|
`${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const alternates = Array.from(
|
||||||
|
document.head.querySelectorAll(
|
||||||
|
'link[rel="alternate"][data-seo-managed="true"]',
|
||||||
|
),
|
||||||
|
).map((node) => ({
|
||||||
|
hreflang: node.getAttribute('hreflang'),
|
||||||
|
href: node.getAttribute('href'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(alternates).toContain({
|
||||||
|
hreflang: 'de-CH',
|
||||||
|
href: `${document.location.origin}/de/shop/p/12345678-schreibtisch-kabelhalter`,
|
||||||
|
});
|
||||||
|
expect(alternates).toContain({
|
||||||
|
hreflang: 'x-default',
|
||||||
|
href: `${document.location.origin}/it/shop/p/12345678-supporto-cavo-scrivania`,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ export interface PageSeoOverride {
|
|||||||
ogDescriptionKey?: string | null;
|
ogDescriptionKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
export interface ResolvedPageSeo extends PageSeoOverride {
|
||||||
|
canonicalPath: string | null;
|
||||||
|
alternates?: SeoMap | null;
|
||||||
|
xDefault?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
type SeoMap = Partial<Record<SupportedLang, string>>;
|
type SeoMap = Partial<Record<SupportedLang, string>>;
|
||||||
type SeoTextDataKey =
|
type SeoTextDataKey =
|
||||||
| 'seoTitle'
|
| 'seoTitle'
|
||||||
@@ -51,10 +57,16 @@ export class SeoService {
|
|||||||
this.supportedLangs,
|
this.supportedLangs,
|
||||||
);
|
);
|
||||||
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
|
private readonly ogLocaleByLang: Record<SupportedLang, string> = {
|
||||||
it: 'it_IT',
|
it: 'it_CH',
|
||||||
en: 'en_US',
|
en: 'en_CH',
|
||||||
de: 'de_DE',
|
de: 'de_CH',
|
||||||
fr: 'fr_FR',
|
fr: 'fr_CH',
|
||||||
|
};
|
||||||
|
private readonly seoLocaleByLang: Record<SupportedLang, string> = {
|
||||||
|
it: 'it-CH',
|
||||||
|
en: 'en-CH',
|
||||||
|
de: 'de-CH',
|
||||||
|
fr: 'fr-CH',
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -79,23 +91,10 @@ export class SeoService {
|
|||||||
applyPageSeo(override: PageSeoOverride): void {
|
applyPageSeo(override: PageSeoOverride): void {
|
||||||
const cleanPath = this.getCleanPath(this.router.url);
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
const lang = this.resolveLangFromPath(cleanPath);
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
const title =
|
const { title, description, robots, ogTitle, ogDescription } =
|
||||||
this.resolveOverrideSeoText(override.title, override.titleKey) ??
|
this.resolvePageSeoOverride(override, lang);
|
||||||
this.defaultTitle(lang);
|
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||||
const description =
|
const alternates = this.buildAlternatePaths(canonicalPath);
|
||||||
this.resolveOverrideSeoText(
|
|
||||||
override.description,
|
|
||||||
override.descriptionKey,
|
|
||||||
) ?? this.defaultDescription(lang);
|
|
||||||
const robots = this.asString(override.robots) ?? 'index, follow';
|
|
||||||
const ogTitle =
|
|
||||||
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
|
|
||||||
title;
|
|
||||||
const ogDescription =
|
|
||||||
this.resolveOverrideSeoText(
|
|
||||||
override.ogDescription,
|
|
||||||
override.ogDescriptionKey,
|
|
||||||
) ?? description;
|
|
||||||
|
|
||||||
this.applySeoValues(
|
this.applySeoValues(
|
||||||
title,
|
title,
|
||||||
@@ -104,6 +103,34 @@ export class SeoService {
|
|||||||
ogTitle,
|
ogTitle,
|
||||||
ogDescription,
|
ogDescription,
|
||||||
cleanPath,
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates),
|
||||||
|
lang,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyResolvedSeo(override: ResolvedPageSeo): void {
|
||||||
|
const cleanPath = this.getCleanPath(this.router.url);
|
||||||
|
const lang = this.resolveLangFromPath(cleanPath);
|
||||||
|
const { title, description, robots, ogTitle, ogDescription } =
|
||||||
|
this.resolvePageSeoOverride(override, lang);
|
||||||
|
const canonicalPath = this.normalizeSeoPath(override.canonicalPath);
|
||||||
|
const alternates = this.normalizeAlternatePaths(override.alternates);
|
||||||
|
const xDefault =
|
||||||
|
this.normalizeSeoPath(override.xDefault) ??
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates);
|
||||||
|
|
||||||
|
this.applySeoValues(
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots,
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
xDefault,
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -122,6 +149,8 @@ export class SeoService {
|
|||||||
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
|
||||||
const ogDescription =
|
const ogDescription =
|
||||||
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
|
||||||
|
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
||||||
|
const alternates = this.buildAlternatePaths(canonicalPath);
|
||||||
|
|
||||||
this.applySeoValues(
|
this.applySeoValues(
|
||||||
title,
|
title,
|
||||||
@@ -130,6 +159,9 @@ export class SeoService {
|
|||||||
ogTitle,
|
ogTitle,
|
||||||
ogDescription,
|
ogDescription,
|
||||||
cleanPath,
|
cleanPath,
|
||||||
|
canonicalPath,
|
||||||
|
alternates,
|
||||||
|
this.buildXDefaultPath(canonicalPath, alternates),
|
||||||
lang,
|
lang,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -141,6 +173,9 @@ export class SeoService {
|
|||||||
ogTitle: string,
|
ogTitle: string,
|
||||||
ogDescription: string,
|
ogDescription: string,
|
||||||
cleanPath: string,
|
cleanPath: string,
|
||||||
|
canonicalPath: string | null,
|
||||||
|
alternates: SeoMap | null,
|
||||||
|
xDefaultPath: string | null,
|
||||||
lang: SupportedLang,
|
lang: SupportedLang,
|
||||||
): void {
|
): void {
|
||||||
this.titleService.setTitle(title);
|
this.titleService.setTitle(title);
|
||||||
@@ -160,12 +195,13 @@ export class SeoService {
|
|||||||
content: ogDescription,
|
content: ogDescription,
|
||||||
});
|
});
|
||||||
|
|
||||||
const canonicalPath = this.buildLocalizedPath(cleanPath, lang);
|
const ogUrl = this.toAbsoluteUrl(canonicalPath ?? cleanPath);
|
||||||
const canonical = `${this.document.location.origin}${canonicalPath}`;
|
this.metaService.updateTag({ property: 'og:url', content: ogUrl });
|
||||||
this.metaService.updateTag({ property: 'og:url', content: canonical });
|
this.updateCanonicalTag(
|
||||||
this.updateCanonicalTag(canonical);
|
canonicalPath ? this.toAbsoluteUrl(canonicalPath) : null,
|
||||||
|
);
|
||||||
this.updateOpenGraphLocales(lang);
|
this.updateOpenGraphLocales(lang);
|
||||||
this.updateLangAndAlternates(canonicalPath, lang);
|
this.updateLangAndAlternates(alternates, xDefaultPath, lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getMergedRouteData(
|
private getMergedRouteData(
|
||||||
@@ -191,6 +227,43 @@ export class SeoService {
|
|||||||
return this.asString(value) ?? this.resolveTranslation(key);
|
return this.asString(value) ?? this.resolveTranslation(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolvePageSeoOverride(
|
||||||
|
override: PageSeoOverride,
|
||||||
|
lang: SupportedLang,
|
||||||
|
): {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
robots: string;
|
||||||
|
ogTitle: string;
|
||||||
|
ogDescription: string;
|
||||||
|
} {
|
||||||
|
const title =
|
||||||
|
this.resolveOverrideSeoText(override.title, override.titleKey) ??
|
||||||
|
this.defaultTitle(lang);
|
||||||
|
const description =
|
||||||
|
this.resolveOverrideSeoText(
|
||||||
|
override.description,
|
||||||
|
override.descriptionKey,
|
||||||
|
) ?? this.defaultDescription(lang);
|
||||||
|
const robots = this.asString(override.robots) ?? 'index, follow';
|
||||||
|
const ogTitle =
|
||||||
|
this.resolveOverrideSeoText(override.ogTitle, override.ogTitleKey) ??
|
||||||
|
title;
|
||||||
|
const ogDescription =
|
||||||
|
this.resolveOverrideSeoText(
|
||||||
|
override.ogDescription,
|
||||||
|
override.ogDescriptionKey,
|
||||||
|
) ?? description;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots,
|
||||||
|
ogTitle,
|
||||||
|
ogDescription,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private resolveSeoText(
|
private resolveSeoText(
|
||||||
routeData: Record<string, unknown>,
|
routeData: Record<string, unknown>,
|
||||||
key: SeoTextDataKey,
|
key: SeoTextDataKey,
|
||||||
@@ -275,10 +348,78 @@ export class SeoService {
|
|||||||
return `/${[lang, ...segments].join('/')}`;
|
return `/${[lang, ...segments].join('/')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateCanonicalTag(url: string): void {
|
private buildAlternatePaths(canonicalPath: string): SeoMap {
|
||||||
|
const suffixSegments = canonicalPath.split('/').filter(Boolean).slice(1);
|
||||||
|
const suffix =
|
||||||
|
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
||||||
|
|
||||||
|
return this.supportedLangs.reduce<SeoMap>((accumulator, alt) => {
|
||||||
|
accumulator[alt] = `/${alt}${suffix}`;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildXDefaultPath(
|
||||||
|
canonicalPath: string | null,
|
||||||
|
alternates: SeoMap | null,
|
||||||
|
): string | null {
|
||||||
|
if (canonicalPath && this.isLocalizedHomePath(canonicalPath)) {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
return alternates?.it ?? canonicalPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isLocalizedHomePath(path: string): boolean {
|
||||||
|
const segments = path.split('/').filter(Boolean);
|
||||||
|
return (
|
||||||
|
segments.length === 1 &&
|
||||||
|
this.supportedLangSet.has(segments[0] as SupportedLang)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeAlternatePaths(
|
||||||
|
paths: SeoMap | null | undefined,
|
||||||
|
): SeoMap | null {
|
||||||
|
if (!paths) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.supportedLangs.reduce<SeoMap>(
|
||||||
|
(accumulator, lang) => {
|
||||||
|
const path = this.normalizeSeoPath(paths[lang]);
|
||||||
|
if (path) {
|
||||||
|
accumulator[lang] = path;
|
||||||
|
}
|
||||||
|
return accumulator;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.keys(normalized).length > 0 ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeSeoPath(path: string | null | undefined): string | null {
|
||||||
|
const rawPath = String(path ?? '').trim();
|
||||||
|
if (!rawPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = this.getCleanPath(rawPath);
|
||||||
|
return normalized.startsWith('/') ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAbsoluteUrl(path: string): string {
|
||||||
|
return `${this.document.location.origin}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateCanonicalTag(url: string | null): void {
|
||||||
let link = this.document.head.querySelector(
|
let link = this.document.head.querySelector(
|
||||||
'link[rel="canonical"]',
|
'link[rel="canonical"]',
|
||||||
) as HTMLLinkElement | null;
|
) as HTMLLinkElement | null;
|
||||||
|
if (!url) {
|
||||||
|
link?.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!link) {
|
if (!link) {
|
||||||
link = this.document.createElement('link');
|
link = this.document.createElement('link');
|
||||||
link.setAttribute('rel', 'canonical');
|
link.setAttribute('rel', 'canonical');
|
||||||
@@ -308,29 +449,33 @@ export class SeoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateLangAndAlternates(
|
private updateLangAndAlternates(
|
||||||
localizedPath: string,
|
alternates: SeoMap | null,
|
||||||
|
xDefaultPath: string | null,
|
||||||
lang: SupportedLang,
|
lang: SupportedLang,
|
||||||
): void {
|
): void {
|
||||||
const suffixSegments = localizedPath.split('/').filter(Boolean).slice(1);
|
this.document.documentElement.lang = this.seoLocaleByLang[lang];
|
||||||
const suffix =
|
|
||||||
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';
|
|
||||||
|
|
||||||
this.document.documentElement.lang = lang;
|
|
||||||
|
|
||||||
this.document.head
|
this.document.head
|
||||||
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
.querySelectorAll('link[rel="alternate"][data-seo-managed="true"]')
|
||||||
.forEach((node) => node.remove());
|
.forEach((node) => node.remove());
|
||||||
|
|
||||||
|
if (!alternates) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const alt of this.supportedLangs) {
|
for (const alt of this.supportedLangs) {
|
||||||
|
const path = alternates[alt];
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
this.appendAlternateLink(
|
this.appendAlternateLink(
|
||||||
alt,
|
this.seoLocaleByLang[alt],
|
||||||
`${this.document.location.origin}/${alt}${suffix}`,
|
this.toAbsoluteUrl(path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.appendAlternateLink(
|
if (xDefaultPath) {
|
||||||
'x-default',
|
this.appendAlternateLink('x-default', this.toAbsoluteUrl(xDefaultPath));
|
||||||
`${this.document.location.origin}/it${suffix}`,
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private appendAlternateLink(hreflang: string, href: string): void {
|
private appendAlternateLink(hreflang: string, href: string): void {
|
||||||
|
|||||||
@@ -668,9 +668,31 @@
|
|||||||
<div>
|
<div>
|
||||||
<h3>Contenuti localizzati</h3>
|
<h3>Contenuti localizzati</h3>
|
||||||
<p>
|
<p>
|
||||||
Nome obbligatorio in tutte le lingue. Descrizioni opzionali.
|
Nome obbligatorio in tutte le lingue. Descrizioni opzionali. La
|
||||||
|
traduzione usa la lingua editor come sorgente e compila il form
|
||||||
|
senza salvare.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ui-button ui-button--ghost"
|
||||||
|
(click)="translateProductFromCurrentLanguage()"
|
||||||
|
[disabled]="!canTranslateProductFromCurrentLanguage()"
|
||||||
|
>
|
||||||
|
{{ translatingProduct ? "Traduco..." : "Traduci" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toggle-row toggle-row--compact">
|
||||||
|
<label class="ui-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[(ngModel)]="overwriteExistingTranslations"
|
||||||
|
name="productOverwriteExistingTranslations"
|
||||||
|
/>
|
||||||
|
<span class="ui-checkbox__mark" aria-hidden="true"></span>
|
||||||
|
<span>Sovrascrivi traduzioni esistenti</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui-language-toolbar">
|
<div class="ui-language-toolbar">
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import {
|
|||||||
AdminShopProductModel,
|
AdminShopProductModel,
|
||||||
AdminShopProductVariant,
|
AdminShopProductVariant,
|
||||||
AdminShopService,
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
AdminTranslateShopProductResponse,
|
||||||
AdminUpsertShopCategoryPayload,
|
AdminUpsertShopCategoryPayload,
|
||||||
AdminUpsertShopProductPayload,
|
AdminUpsertShopProductPayload,
|
||||||
AdminUpsertShopProductVariantPayload,
|
AdminUpsertShopProductVariantPayload,
|
||||||
@@ -174,6 +176,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
loading = false;
|
loading = false;
|
||||||
detailLoading = false;
|
detailLoading = false;
|
||||||
savingProduct = false;
|
savingProduct = false;
|
||||||
|
translatingProduct = false;
|
||||||
deletingProduct = false;
|
deletingProduct = false;
|
||||||
savingCategory = false;
|
savingCategory = false;
|
||||||
deletingCategory = false;
|
deletingCategory = false;
|
||||||
@@ -188,6 +191,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
productStatusFilter: ProductStatusFilter = 'ALL';
|
productStatusFilter: ProductStatusFilter = 'ALL';
|
||||||
showCategoryManager = false;
|
showCategoryManager = false;
|
||||||
activeContentLanguage: ShopLanguage = 'it';
|
activeContentLanguage: ShopLanguage = 'it';
|
||||||
|
overwriteExistingTranslations = false;
|
||||||
|
|
||||||
errorMessage: string | null = null;
|
errorMessage: string | null = null;
|
||||||
successMessage: string | null = null;
|
successMessage: string | null = null;
|
||||||
@@ -560,6 +564,52 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
this.categoryForm.slug = this.slugify(source);
|
this.categoryForm.slug = this.slugify(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateProductFromCurrentLanguage(): void {
|
||||||
|
if (this.translatingProduct) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||||
|
|
||||||
|
const sourceLanguage = this.activeContentLanguage;
|
||||||
|
if (!this.productForm.names[sourceLanguage].trim()) {
|
||||||
|
this.errorMessage = `Il nome prodotto ${this.languageLabels[sourceLanguage]} e obbligatorio per avviare la traduzione.`;
|
||||||
|
this.successMessage = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = this.buildProductTranslationPayload(sourceLanguage);
|
||||||
|
this.translatingProduct = true;
|
||||||
|
this.errorMessage = null;
|
||||||
|
this.successMessage = null;
|
||||||
|
|
||||||
|
this.adminShopService.translateProduct(payload).subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
this.translatingProduct = false;
|
||||||
|
this.applyProductTranslation(response, payload.overwriteExisting);
|
||||||
|
this.successMessage = response.targetLanguages.length
|
||||||
|
? `Traduzioni ${response.targetLanguages
|
||||||
|
.map((language) => this.languageLabels[language])
|
||||||
|
.join(' / ')} aggiornate nel form.`
|
||||||
|
: 'Nessun campo da tradurre.';
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.translatingProduct = false;
|
||||||
|
this.errorMessage = this.extractErrorMessage(
|
||||||
|
error,
|
||||||
|
'Traduzione prodotto non riuscita.',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canTranslateProductFromCurrentLanguage(): boolean {
|
||||||
|
return (
|
||||||
|
!this.translatingProduct &&
|
||||||
|
!!this.productForm.names[this.activeContentLanguage].trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setActiveContentLanguage(language: ShopLanguage): void {
|
setActiveContentLanguage(language: ShopLanguage): void {
|
||||||
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
this.syncDescriptionFromEditor(this.descriptionEditorElement, true);
|
||||||
this.activeContentLanguage = language;
|
this.activeContentLanguage = language;
|
||||||
@@ -1669,6 +1719,98 @@ export class AdminShopComponent implements OnInit, OnDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildProductTranslationPayload(
|
||||||
|
sourceLanguage: ShopLanguage,
|
||||||
|
): AdminTranslateShopProductPayload {
|
||||||
|
const materialCodes = Array.from(
|
||||||
|
new Set(
|
||||||
|
this.productForm.materials
|
||||||
|
.map((material) => material.materialCode.trim().toUpperCase())
|
||||||
|
.filter((materialCode) => !!materialCode),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId: this.productForm.categoryId || undefined,
|
||||||
|
sourceLanguage,
|
||||||
|
overwriteExisting: this.overwriteExistingTranslations,
|
||||||
|
materialCodes,
|
||||||
|
names: { ...this.productForm.names },
|
||||||
|
excerpts: { ...this.productForm.excerpts },
|
||||||
|
descriptions: { ...this.productForm.descriptions },
|
||||||
|
seoTitles: { ...this.productForm.seoTitles },
|
||||||
|
seoDescriptions: { ...this.productForm.seoDescriptions },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyProductTranslation(
|
||||||
|
response: AdminTranslateShopProductResponse,
|
||||||
|
overwriteExisting: boolean,
|
||||||
|
): void {
|
||||||
|
for (const language of response.targetLanguages) {
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.names,
|
||||||
|
response.names,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.excerpts,
|
||||||
|
response.excerpts,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.descriptions,
|
||||||
|
response.descriptions,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.seoTitles,
|
||||||
|
response.seoTitles,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
this.mergeLocalizedText(
|
||||||
|
this.productForm.seoDescriptions,
|
||||||
|
response.seoDescriptions,
|
||||||
|
language,
|
||||||
|
overwriteExisting,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderActiveDescriptionInEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeLocalizedText(
|
||||||
|
target: Record<ShopLanguage, string>,
|
||||||
|
translated:
|
||||||
|
| Partial<Record<ShopLanguage, string>>
|
||||||
|
| Record<ShopLanguage, string>
|
||||||
|
| undefined,
|
||||||
|
language: ShopLanguage,
|
||||||
|
overwriteExisting: boolean,
|
||||||
|
richText = false,
|
||||||
|
): void {
|
||||||
|
const incoming = translated?.[language];
|
||||||
|
if (incoming === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCurrentValue = richText
|
||||||
|
? this.hasMeaningfulRichText(target[language] ?? '')
|
||||||
|
: !!target[language]?.trim();
|
||||||
|
if (hasCurrentValue && !overwriteExisting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target[language] = richText
|
||||||
|
? this.normalizeDescriptionForEditor(incoming)
|
||||||
|
: incoming.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
|
private buildVariantsFromMaterials(): AdminUpsertShopProductVariantPayload[] {
|
||||||
const existingVariantsByKey = new Map(
|
const existingVariantsByKey = new Map(
|
||||||
(this.selectedProduct?.variants ?? []).map((variant) => [
|
(this.selectedProduct?.variants ?? []).map((variant) => [
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing';
|
||||||
|
import {
|
||||||
|
AdminShopService,
|
||||||
|
AdminTranslateShopProductPayload,
|
||||||
|
} from './admin-shop.service';
|
||||||
|
|
||||||
|
describe('AdminShopService', () => {
|
||||||
|
let service: AdminShopService;
|
||||||
|
let httpMock: HttpTestingController;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [HttpClientTestingModule],
|
||||||
|
providers: [AdminShopService],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(AdminShopService);
|
||||||
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
httpMock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts product translation requests with credentials', () => {
|
||||||
|
const payload: AdminTranslateShopProductPayload = {
|
||||||
|
categoryId: 'category-1',
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
overwriteExisting: false,
|
||||||
|
materialCodes: ['PLA', 'PETG'],
|
||||||
|
names: {
|
||||||
|
it: 'Supporto cavo scrivania',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
excerpts: {
|
||||||
|
it: 'Accessorio tecnico',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
descriptions: {
|
||||||
|
it: '<p>Descrizione prodotto</p>',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoTitles: {
|
||||||
|
it: 'Supporto cavo scrivania | 3D fab',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
seoDescriptions: {
|
||||||
|
it: 'Supporto tecnico stampato in 3D per scrivania.',
|
||||||
|
en: '',
|
||||||
|
de: '',
|
||||||
|
fr: '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
service.translateProduct(payload).subscribe((response) => {
|
||||||
|
expect(response.targetLanguages).toEqual(['en', 'de', 'fr']);
|
||||||
|
expect(response.names.en).toBe('Desk cable clip');
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne(
|
||||||
|
'http://localhost:8000/api/admin/shop/products/translate',
|
||||||
|
);
|
||||||
|
expect(request.request.method).toBe('POST');
|
||||||
|
expect(request.request.withCredentials).toBeTrue();
|
||||||
|
expect(request.request.body).toEqual(payload);
|
||||||
|
|
||||||
|
request.flush({
|
||||||
|
sourceLanguage: 'it',
|
||||||
|
targetLanguages: ['en', 'de', 'fr'],
|
||||||
|
names: {
|
||||||
|
en: 'Desk cable clip',
|
||||||
|
de: 'Schreibtisch-Kabelhalter',
|
||||||
|
fr: 'Support de cable de bureau',
|
||||||
|
},
|
||||||
|
excerpts: {},
|
||||||
|
descriptions: {},
|
||||||
|
seoTitles: {},
|
||||||
|
seoDescriptions: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,6 +18,8 @@ export interface AdminMediaTextTranslation {
|
|||||||
altText: string;
|
altText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AdminShopLanguage = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
export interface AdminShopCategoryRef {
|
export interface AdminShopCategoryRef {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -255,6 +257,28 @@ export interface AdminUpsertShopProductPayload {
|
|||||||
variants: AdminUpsertShopProductVariantPayload[];
|
variants: AdminUpsertShopProductVariantPayload[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminTranslateShopProductPayload {
|
||||||
|
categoryId?: string;
|
||||||
|
sourceLanguage: AdminShopLanguage;
|
||||||
|
overwriteExisting: boolean;
|
||||||
|
materialCodes: string[];
|
||||||
|
names: Record<AdminShopLanguage, string>;
|
||||||
|
excerpts: Record<AdminShopLanguage, string>;
|
||||||
|
descriptions: Record<AdminShopLanguage, string>;
|
||||||
|
seoTitles: Record<AdminShopLanguage, string>;
|
||||||
|
seoDescriptions: Record<AdminShopLanguage, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminTranslateShopProductResponse {
|
||||||
|
sourceLanguage: AdminShopLanguage;
|
||||||
|
targetLanguages: AdminShopLanguage[];
|
||||||
|
names: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
excerpts: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
descriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
seoTitles: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
seoDescriptions: Partial<Record<AdminShopLanguage, string>>;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
@@ -351,6 +375,18 @@ export class AdminShopService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
translateProduct(
|
||||||
|
payload: AdminTranslateShopProductPayload,
|
||||||
|
): Observable<AdminTranslateShopProductResponse> {
|
||||||
|
return this.http.post<AdminTranslateShopProductResponse>(
|
||||||
|
`${this.productsBaseUrl}/translate`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
withCredentials: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
uploadProductModel(
|
uploadProductModel(
|
||||||
productId: string,
|
productId: string,
|
||||||
file: File,
|
file: File,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<section class="animation-test-page">
|
||||||
|
<div class="animation-toolbar" role="group" aria-label="Animation variants">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="variant-toggle"
|
||||||
|
[class.active]="variant() === 'site-intro'"
|
||||||
|
(click)="setVariant('site-intro')"
|
||||||
|
>
|
||||||
|
Site intro
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="variant-toggle"
|
||||||
|
[class.active]="variant() === 'calculator-loader'"
|
||||||
|
(click)="setVariant('calculator-loader')"
|
||||||
|
>
|
||||||
|
Calculator loader
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="animation-stage" [attr.data-variant]="variant()">
|
||||||
|
<app-brand-animation-logo
|
||||||
|
[variant]="variant()"
|
||||||
|
[decorative]="false"
|
||||||
|
ariaLabel="3D fab animation test"
|
||||||
|
></app-brand-animation-logo>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-test-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem 1.5rem 3rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-toolbar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.35rem;
|
||||||
|
border: 1px solid rgba(16, 24, 32, 0.12);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f7f5ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-toggle {
|
||||||
|
min-height: 2.4rem;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.18s ease,
|
||||||
|
color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variant-toggle.active {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: 0 6px 16px rgba(16, 24, 32, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-stage {
|
||||||
|
width: min(100%, 26rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.animation-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-stage {
|
||||||
|
width: min(100%, 19rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { Component, signal } from '@angular/core';
|
||||||
|
import {
|
||||||
|
BrandAnimationLogoComponent,
|
||||||
|
BrandAnimationVariant,
|
||||||
|
} from '../../shared/components/brand-animation-logo/brand-animation-logo.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-calculator-animation-test',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, BrandAnimationLogoComponent],
|
||||||
|
templateUrl: './calculator-animation-test.component.html',
|
||||||
|
styleUrl: './calculator-animation-test.component.scss',
|
||||||
|
})
|
||||||
|
export class CalculatorAnimationTestComponent {
|
||||||
|
readonly variant = signal<BrandAnimationVariant>('site-intro');
|
||||||
|
|
||||||
|
setVariant(variant: BrandAnimationVariant): void {
|
||||||
|
this.variant.set(variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<div class="container ui-simple-hero">
|
<div class="container ui-simple-hero">
|
||||||
<h1 class="ui-simple-hero__title">{{ "CALC.TITLE" | translate }}</h1>
|
<h1 class="ui-simple-hero__title">
|
||||||
<p class="ui-simple-hero__subtitle">{{ "CALC.SUBTITLE" | translate }}</p>
|
{{ modeContentKey("TITLE") | translate }}
|
||||||
|
</h1>
|
||||||
|
<p class="ui-simple-hero__subtitle">
|
||||||
|
{{ modeContentKey("SUBTITLE") | translate }}
|
||||||
|
</p>
|
||||||
|
|
||||||
@if (error()) {
|
@if (error()) {
|
||||||
<app-alert type="error">{{ errorKey() | translate }}</app-alert>
|
<app-alert type="error">{{ errorKey() | translate }}</app-alert>
|
||||||
@@ -57,7 +61,10 @@
|
|||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<app-card class="loading-state">
|
<app-card class="loading-state">
|
||||||
<div class="loader-content">
|
<div class="loader-content">
|
||||||
<div class="spinner"></div>
|
<app-brand-animation-logo
|
||||||
|
class="loader-logo"
|
||||||
|
variant="calculator-loader"
|
||||||
|
></app-brand-animation-logo>
|
||||||
<h3 class="loading-title">
|
<h3 class="loading-title">
|
||||||
{{ "CALC.ANALYZING_TITLE" | translate }}
|
{{ "CALC.ANALYZING_TITLE" | translate }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -96,4 +103,84 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="container calculator-guides">
|
||||||
|
<section class="calculator-guide">
|
||||||
|
<div class="model-source-section">
|
||||||
|
<div class="model-source-intro">
|
||||||
|
<p class="guide-kicker">
|
||||||
|
{{ "CALC.MODEL_SOURCES.KICKER" | translate }}
|
||||||
|
</p>
|
||||||
|
<h2>{{ "CALC.MODEL_SOURCES.TITLE" | translate }}</h2>
|
||||||
|
<p>{{ "CALC.MODEL_SOURCES.TEXT" | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="model-source-links">
|
||||||
|
<div class="model-source-group">
|
||||||
|
<p class="model-source-group__label">
|
||||||
|
{{ "CALC.MODEL_SOURCES.FAVORITES_TITLE" | translate }}
|
||||||
|
</p>
|
||||||
|
@for (source of favoriteModelSources; track source.id) {
|
||||||
|
<a
|
||||||
|
class="model-source-link model-source-link--favorite"
|
||||||
|
[class.model-source-link--printables]="
|
||||||
|
source.id === 'PRINTABLES'
|
||||||
|
"
|
||||||
|
[class.model-source-link--makerworld]="
|
||||||
|
source.id === 'MAKERWORLD'
|
||||||
|
"
|
||||||
|
[href]="source.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="model-source-link__name">{{ source.label }}</span>
|
||||||
|
<span class="model-source-link__description">{{
|
||||||
|
modelSourceDescriptionKey(source.id) | translate
|
||||||
|
}}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="model-source-group">
|
||||||
|
<p class="model-source-group__label">
|
||||||
|
{{ "CALC.MODEL_SOURCES.OTHERS_TITLE" | translate }}
|
||||||
|
</p>
|
||||||
|
<div class="model-source-compact-list">
|
||||||
|
@for (source of otherModelSources; track source.id) {
|
||||||
|
<a
|
||||||
|
class="model-source-link model-source-link--compact"
|
||||||
|
[href]="source.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<span class="model-source-link__name">{{
|
||||||
|
source.label
|
||||||
|
}}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="calculator-guide">
|
||||||
|
<app-card class="guide-card">
|
||||||
|
<div class="guide-header guide-header--compact">
|
||||||
|
<p class="guide-kicker">{{ "CALC.FAQ.KICKER" | translate }}</p>
|
||||||
|
<h2>{{ "CALC.FAQ.TITLE" | translate }}</h2>
|
||||||
|
<p>{{ "CALC.FAQ.SUBTITLE" | translate }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="faq-list">
|
||||||
|
@for (faqId of faqIds; track faqId) {
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>{{ faqKey(faqId, "Q") | translate }}</summary>
|
||||||
|
<p>{{ faqKey(faqId, "A") | translate }}</p>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</app-card>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,8 +53,8 @@
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.92rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
|
|
||||||
.loader-content {
|
.loader-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 300px;
|
max-width: 22rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
/* Center content vertically within the stretched card */
|
/* Center content vertically within the stretched card */
|
||||||
@@ -101,12 +101,14 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-title {
|
.loading-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: var(--space-4) 0 var(--space-2);
|
margin: 0;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,23 +116,275 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.loader-logo {
|
||||||
border: 3px solid var(--color-neutral-200);
|
display: block;
|
||||||
border-left-color: var(--color-brand);
|
width: min(100%, 16rem);
|
||||||
border-radius: 50%;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
--brand-animation-width: 16rem;
|
||||||
|
--brand-animation-height: 4.8rem;
|
||||||
|
--brand-animation-letter-width: 2.85rem;
|
||||||
|
--brand-animation-scale: 0.84;
|
||||||
|
--brand-animation-word-spacing: 0.97;
|
||||||
|
--brand-animation-width-mobile: 14rem;
|
||||||
|
--brand-animation-height-mobile: 4.1rem;
|
||||||
|
--brand-animation-letter-width-mobile: 2.45rem;
|
||||||
|
--brand-animation-scale-mobile: 0.84;
|
||||||
|
--brand-animation-loader-loop-duration: 2.65s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.calculator-guides {
|
||||||
0% {
|
display: flex;
|
||||||
transform: rotate(0deg);
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin-top: var(--space-8);
|
||||||
|
padding-bottom: var(--space-10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calculator-guide {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-header {
|
||||||
|
max-width: 48rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: clamp(1.4rem, 2vw, 1.9rem);
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-header--compact {
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-kicker {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card {
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: var(--space-6);
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
|
||||||
|
@media (min-width: 960px) {
|
||||||
|
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-intro {
|
||||||
|
max-width: 34rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: clamp(1.35rem, 2vw, 1.8rem);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-links {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-group__label {
|
||||||
|
margin: 0 0 var(--space-1);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
transform 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
border-color: rgba(250, 207, 10, 0.7);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--favorite {
|
||||||
|
--favorite-accent: var(--color-brand);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
border: 1px solid
|
||||||
|
color-mix(in srgb, var(--favorite-accent) 38%, var(--color-border));
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--favorite-accent) 12%,
|
||||||
|
var(--color-bg-card)
|
||||||
|
);
|
||||||
|
box-shadow: inset 0 0 0 1px
|
||||||
|
color-mix(in srgb, var(--favorite-accent) 16%, #fff);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--favorite-accent) 18%,
|
||||||
|
var(--color-bg-card)
|
||||||
|
);
|
||||||
|
border-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--favorite-accent) 70%,
|
||||||
|
var(--color-border)
|
||||||
|
);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--printables {
|
||||||
|
--favorite-accent: #f1872a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--makerworld {
|
||||||
|
--favorite-accent: #00b140;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link__name {
|
||||||
|
position: relative;
|
||||||
|
width: fit-content;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--favorite .model-source-link__name::after {
|
||||||
|
content: " ->";
|
||||||
|
position: static;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--favorite-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--favorite .model-source-link__name {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-decoration-color: var(--favorite-accent);
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link__description {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-compact-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2) var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--compact {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
transform: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--compact .model-source-link__name {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-source-link--compact .model-source-link__name::after {
|
||||||
|
content: " ->";
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-muted);
|
||||||
|
padding: var(--space-4);
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: var(--space-3) 0 0;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
line-height: 1.65;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { of } from 'rxjs';
|
|||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CalculatorPageComponent } from './calculator-page.component';
|
import { CalculatorPageComponent } from './calculator-page.component';
|
||||||
import {
|
import {
|
||||||
|
PendingCalculatorDraft,
|
||||||
QuoteEstimatorService,
|
QuoteEstimatorService,
|
||||||
|
QuoteRequest,
|
||||||
QuoteResult,
|
QuoteResult,
|
||||||
} from './services/quote-estimator.service';
|
} from './services/quote-estimator.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
@@ -31,15 +33,54 @@ describe('CalculatorPageComponent', () => {
|
|||||||
notes,
|
notes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createDraftRequest = (): QuoteRequest => ({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
file: new File(['mesh'], 'part-a.stl', { type: 'model/stl' }),
|
||||||
|
quantity: 2,
|
||||||
|
material: 'PLA',
|
||||||
|
quality: 'standard',
|
||||||
|
color: 'Black',
|
||||||
|
supportEnabled: true,
|
||||||
|
infillDensity: 15,
|
||||||
|
infillPattern: 'grid',
|
||||||
|
layerHeight: 0.2,
|
||||||
|
nozzleDiameter: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
material: 'PLA',
|
||||||
|
quality: 'standard',
|
||||||
|
notes: 'draft note',
|
||||||
|
infillDensity: 15,
|
||||||
|
infillPattern: 'grid',
|
||||||
|
supportEnabled: true,
|
||||||
|
layerHeight: 0.2,
|
||||||
|
nozzleDiameter: 0.4,
|
||||||
|
mode: 'easy',
|
||||||
|
});
|
||||||
|
|
||||||
function createComponent() {
|
function createComponent() {
|
||||||
const estimator = jasmine.createSpyObj<QuoteEstimatorService>(
|
const estimator = jasmine.createSpyObj<QuoteEstimatorService>(
|
||||||
'QuoteEstimatorService',
|
'QuoteEstimatorService',
|
||||||
['updateLineItem', 'getQuoteSession', 'mapSessionToQuoteResult'],
|
[
|
||||||
|
'updateLineItem',
|
||||||
|
'getQuoteSession',
|
||||||
|
'mapSessionToQuoteResult',
|
||||||
|
'setPendingCalculatorDraft',
|
||||||
|
'consumePendingCalculatorDraft',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
|
const router = jasmine.createSpyObj<Router>('Router', ['navigate']);
|
||||||
const route = {
|
const route = {
|
||||||
data: of({}),
|
data: of({}),
|
||||||
queryParams: of({}),
|
queryParams: of({}),
|
||||||
|
snapshot: {
|
||||||
|
routeConfig: { path: 'basic' },
|
||||||
|
queryParams: {},
|
||||||
|
queryParamMap: {
|
||||||
|
get: () => null,
|
||||||
|
},
|
||||||
|
},
|
||||||
} as unknown as ActivatedRoute;
|
} as unknown as ActivatedRoute;
|
||||||
const languageService = jasmine.createSpyObj<LanguageService>(
|
const languageService = jasmine.createSpyObj<LanguageService>(
|
||||||
'LanguageService',
|
'LanguageService',
|
||||||
@@ -55,13 +96,25 @@ describe('CalculatorPageComponent', () => {
|
|||||||
|
|
||||||
const uploadForm = jasmine.createSpyObj<UploadFormComponent>(
|
const uploadForm = jasmine.createSpyObj<UploadFormComponent>(
|
||||||
'UploadFormComponent',
|
'UploadFormComponent',
|
||||||
['updateItemQuantityByIndex', 'updateItemQuantityByName'],
|
[
|
||||||
|
'updateItemQuantityByIndex',
|
||||||
|
'updateItemQuantityByName',
|
||||||
|
'getCurrentRequestDraft',
|
||||||
|
'restoreRequestDraft',
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
uploadForm.sameSettingsForAll = jasmine
|
||||||
|
.createSpy('sameSettingsForAll')
|
||||||
|
.and.returnValue(true) as any;
|
||||||
|
uploadForm.selectedFile = jasmine
|
||||||
|
.createSpy('selectedFile')
|
||||||
|
.and.returnValue(null) as any;
|
||||||
component.uploadForm = uploadForm;
|
component.uploadForm = uploadForm;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
component,
|
component,
|
||||||
estimator,
|
estimator,
|
||||||
|
route,
|
||||||
uploadForm,
|
uploadForm,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -109,4 +162,80 @@ describe('CalculatorPageComponent', () => {
|
|||||||
expect(component.result()?.notes).toBe('persisted notes');
|
expect(component.result()?.notes).toBe('persisted notes');
|
||||||
expect(component.result()?.items[0].quantity).toBe(1);
|
expect(component.result()?.items[0].quantity).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds mode-specific content keys', () => {
|
||||||
|
const { component } = createComponent();
|
||||||
|
|
||||||
|
component.mode.set('easy');
|
||||||
|
expect(component.modeContentKey('TITLE')).toBe('CALC.MODES.BASIC.TITLE');
|
||||||
|
|
||||||
|
component.mode.set('advanced');
|
||||||
|
expect(component.modeContentKey('TITLE')).toBe('CALC.MODES.ADVANCED.TITLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes the expected external model sources and faq entries', () => {
|
||||||
|
const { component } = createComponent();
|
||||||
|
|
||||||
|
expect(component.favoriteModelSources.map((entry) => entry.id)).toEqual([
|
||||||
|
'PRINTABLES',
|
||||||
|
'MAKERWORLD',
|
||||||
|
]);
|
||||||
|
expect(component.otherModelSources.map((entry) => entry.id)).toEqual([
|
||||||
|
'THINGIVERSE',
|
||||||
|
'THANGS',
|
||||||
|
'CULTS3D',
|
||||||
|
'YEGGI',
|
||||||
|
]);
|
||||||
|
expect(component.modelSources.map((entry) => entry.id)).toEqual([
|
||||||
|
'PRINTABLES',
|
||||||
|
'MAKERWORLD',
|
||||||
|
'THINGIVERSE',
|
||||||
|
'THANGS',
|
||||||
|
'CULTS3D',
|
||||||
|
'YEGGI',
|
||||||
|
]);
|
||||||
|
expect(component.faqIds).toEqual([
|
||||||
|
'FILES',
|
||||||
|
'MODE',
|
||||||
|
'NO_MODEL',
|
||||||
|
'PRICE',
|
||||||
|
'BEFORE_UPLOAD',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the current draft before switching mode without a session', () => {
|
||||||
|
const { component, estimator, uploadForm } = createComponent();
|
||||||
|
const draftRequest = createDraftRequest();
|
||||||
|
uploadForm.getCurrentRequestDraft.and.returnValue(draftRequest);
|
||||||
|
(uploadForm.sameSettingsForAll as jasmine.Spy).and.returnValue(false);
|
||||||
|
(uploadForm.selectedFile as jasmine.Spy).and.returnValue(
|
||||||
|
draftRequest.items[0].file,
|
||||||
|
);
|
||||||
|
|
||||||
|
component.switchMode('advanced');
|
||||||
|
|
||||||
|
expect(estimator.setPendingCalculatorDraft).toHaveBeenCalledWith({
|
||||||
|
request: draftRequest,
|
||||||
|
sameSettingsForAll: false,
|
||||||
|
selectedFileName: 'part-a.stl',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores a pending draft after view init when there is no session', () => {
|
||||||
|
const { component, estimator, uploadForm } = createComponent();
|
||||||
|
const draftRequest = createDraftRequest();
|
||||||
|
const pendingDraft: PendingCalculatorDraft = {
|
||||||
|
request: draftRequest,
|
||||||
|
sameSettingsForAll: true,
|
||||||
|
selectedFileName: 'part-a.stl',
|
||||||
|
};
|
||||||
|
estimator.consumePendingCalculatorDraft.and.returnValue(pendingDraft);
|
||||||
|
|
||||||
|
component.ngAfterViewInit();
|
||||||
|
|
||||||
|
expect(uploadForm.restoreRequestDraft).toHaveBeenCalledWith(draftRequest, {
|
||||||
|
sameSettingsForAll: true,
|
||||||
|
selectedFileName: 'part-a.stl',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
computed,
|
computed,
|
||||||
signal,
|
signal,
|
||||||
@@ -17,9 +18,11 @@ import { catchError, map } from 'rxjs/operators';
|
|||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
import { AppAlertComponent } from '../../shared/components/app-alert/app-alert.component';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
|
import { BrandAnimationLogoComponent } from '../../shared/components/brand-animation-logo/brand-animation-logo.component';
|
||||||
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
import { UploadFormComponent } from './components/upload-form/upload-form.component';
|
||||||
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
import { QuoteResultComponent } from './components/quote-result/quote-result.component';
|
||||||
import {
|
import {
|
||||||
|
PendingCalculatorDraft,
|
||||||
QuoteRequest,
|
QuoteRequest,
|
||||||
QuoteResult,
|
QuoteResult,
|
||||||
QuoteEstimatorService,
|
QuoteEstimatorService,
|
||||||
@@ -48,6 +51,7 @@ type TrackedPrintSettings = {
|
|||||||
AppCardComponent,
|
AppCardComponent,
|
||||||
AppAlertComponent,
|
AppAlertComponent,
|
||||||
AppButtonComponent,
|
AppButtonComponent,
|
||||||
|
BrandAnimationLogoComponent,
|
||||||
UploadFormComponent,
|
UploadFormComponent,
|
||||||
QuoteResultComponent,
|
QuoteResultComponent,
|
||||||
SuccessStateComponent,
|
SuccessStateComponent,
|
||||||
@@ -55,7 +59,7 @@ type TrackedPrintSettings = {
|
|||||||
templateUrl: './calculator-page.component.html',
|
templateUrl: './calculator-page.component.html',
|
||||||
styleUrl: './calculator-page.component.scss',
|
styleUrl: './calculator-page.component.scss',
|
||||||
})
|
})
|
||||||
export class CalculatorPageComponent implements OnInit {
|
export class CalculatorPageComponent implements OnInit, AfterViewInit {
|
||||||
private readonly isBrowser: boolean;
|
private readonly isBrowser: boolean;
|
||||||
mode = signal<'easy' | 'advanced'>('easy');
|
mode = signal<'easy' | 'advanced'>('easy');
|
||||||
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
step = signal<'upload' | 'quote' | 'details' | 'success'>('upload');
|
||||||
@@ -69,6 +73,57 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
isZeroQuoteError = computed(
|
isZeroQuoteError = computed(
|
||||||
() => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE',
|
() => this.error() && this.errorKey() === 'CALC.ERROR_ZERO_PRICE',
|
||||||
);
|
);
|
||||||
|
readonly faqIds = [
|
||||||
|
'FILES',
|
||||||
|
'MODE',
|
||||||
|
'NO_MODEL',
|
||||||
|
'PRICE',
|
||||||
|
'BEFORE_UPLOAD',
|
||||||
|
] as const;
|
||||||
|
readonly modelSources = [
|
||||||
|
{
|
||||||
|
id: 'PRINTABLES',
|
||||||
|
label: 'Printables',
|
||||||
|
url: 'https://www.printables.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'MAKERWORLD',
|
||||||
|
label: 'MakerWorld',
|
||||||
|
url: 'https://makerworld.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'THINGIVERSE',
|
||||||
|
label: 'Thingiverse',
|
||||||
|
url: 'https://www.thingiverse.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'THANGS',
|
||||||
|
label: 'Thangs',
|
||||||
|
url: 'https://thangs.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'CULTS3D',
|
||||||
|
label: 'Cults3D',
|
||||||
|
url: 'https://cults3d.com',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'YEGGI',
|
||||||
|
label: 'Yeggi',
|
||||||
|
url: 'https://www.yeggi.com',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
readonly favoriteModelSourceIds = ['PRINTABLES', 'MAKERWORLD'] as const;
|
||||||
|
readonly favoriteModelSources = this.modelSources.filter((source) =>
|
||||||
|
this.favoriteModelSourceIds.includes(
|
||||||
|
source.id as (typeof this.favoriteModelSourceIds)[number],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
readonly otherModelSources = this.modelSources.filter(
|
||||||
|
(source) =>
|
||||||
|
!this.favoriteModelSourceIds.includes(
|
||||||
|
source.id as (typeof this.favoriteModelSourceIds)[number],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
orderSuccess = signal(false);
|
orderSuccess = signal(false);
|
||||||
requiresRecalculation = signal(false);
|
requiresRecalculation = signal(false);
|
||||||
@@ -113,6 +168,31 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
const pendingDraft = this.estimator.consumePendingCalculatorDraft();
|
||||||
|
if (!pendingDraft || this.currentSessionId()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.uploadForm?.restoreRequestDraft(pendingDraft.request, {
|
||||||
|
sameSettingsForAll: pendingDraft.sameSettingsForAll,
|
||||||
|
selectedFileName: pendingDraft.selectedFileName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
modeContentKey(field: string): string {
|
||||||
|
const modeKey = this.mode() === 'easy' ? 'BASIC' : 'ADVANCED';
|
||||||
|
return `CALC.MODES.${modeKey}.${field}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelSourceDescriptionKey(id: string): string {
|
||||||
|
return `CALC.MODEL_SOURCES.ITEMS.${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
faqKey(id: string, field: string): string {
|
||||||
|
return `CALC.FAQ.ITEMS.${id}.${field}`;
|
||||||
|
}
|
||||||
|
|
||||||
loadSession(sessionId: string) {
|
loadSession(sessionId: string) {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
this.estimator.getQuoteSession(sessionId).subscribe({
|
this.estimator.getQuoteSession(sessionId).subscribe({
|
||||||
@@ -531,7 +611,7 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
if (this.cadSessionLocked()) return;
|
if (this.cadSessionLocked()) return;
|
||||||
|
|
||||||
const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
|
const targetPath = nextMode === 'easy' ? 'basic' : 'advanced';
|
||||||
const currentPath = this.route.snapshot.routeConfig?.path;
|
const currentPath = this.route.snapshot?.routeConfig?.path;
|
||||||
|
|
||||||
this.mode.set(nextMode);
|
this.mode.set(nextMode);
|
||||||
|
|
||||||
@@ -539,12 +619,54 @@ export class CalculatorPageComponent implements OnInit {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.currentSessionId()) {
|
||||||
|
this.persistPendingDraftForModeSwitch();
|
||||||
|
}
|
||||||
|
|
||||||
this.router.navigate(['..', targetPath], {
|
this.router.navigate(['..', targetPath], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
queryParamsHandling: 'preserve',
|
queryParamsHandling: 'preserve',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentSessionId(): string | null {
|
||||||
|
const fromResult = this.result()?.sessionId;
|
||||||
|
if (fromResult) {
|
||||||
|
return fromResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = this.route.snapshot;
|
||||||
|
const fromQueryParamMap = snapshot?.queryParamMap?.get?.('session');
|
||||||
|
if (fromQueryParamMap) {
|
||||||
|
return fromQueryParamMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromQueryParams = snapshot?.queryParams?.['session'];
|
||||||
|
return typeof fromQueryParams === 'string' && fromQueryParams.length > 0
|
||||||
|
? fromQueryParams
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistPendingDraftForModeSwitch(): void {
|
||||||
|
if (!this.uploadForm) {
|
||||||
|
this.estimator.setPendingCalculatorDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = this.uploadForm.getCurrentRequestDraft();
|
||||||
|
if (!request.items.length) {
|
||||||
|
this.estimator.setPendingCalculatorDraft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft: PendingCalculatorDraft = {
|
||||||
|
request,
|
||||||
|
sameSettingsForAll: this.uploadForm.sameSettingsForAll(),
|
||||||
|
selectedFileName: this.uploadForm.selectedFile()?.name ?? null,
|
||||||
|
};
|
||||||
|
this.estimator.setPendingCalculatorDraft(draft);
|
||||||
|
}
|
||||||
|
|
||||||
private toTrackedSettingsFromRequest(
|
private toTrackedSettingsFromRequest(
|
||||||
req: QuoteRequest,
|
req: QuoteRequest,
|
||||||
): TrackedPrintSettings {
|
): TrackedPrintSettings {
|
||||||
|
|||||||
@@ -3,6 +3,18 @@ import { CalculatorPageComponent } from './calculator-page.component';
|
|||||||
|
|
||||||
export const CALCULATOR_ROUTES: Routes = [
|
export const CALCULATOR_ROUTES: Routes = [
|
||||||
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
{ path: '', redirectTo: 'basic', pathMatch: 'full' },
|
||||||
|
{
|
||||||
|
path: 'animation-test',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./calculator-animation-test.component').then(
|
||||||
|
(m) => m.CalculatorAnimationTestComponent,
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE',
|
||||||
|
seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION',
|
||||||
|
seoRobots: 'noindex, nofollow',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'basic',
|
path: 'basic',
|
||||||
component: CalculatorPageComponent,
|
component: CalculatorPageComponent,
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ export class UploadFormComponent implements OnInit {
|
|||||||
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
|
private layerHeightsByNozzle: Record<string, SimpleOption[]> = {};
|
||||||
private isPatchingSettings = false;
|
private isPatchingSettings = false;
|
||||||
|
|
||||||
acceptedFormats = '.stl,.3mf,.step,.stp';
|
acceptedFormats = '.stl,.3mf';
|
||||||
|
private readonly allowedExtensions = ['stl', '3mf'] as const;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.form = this.fb.group({
|
this.form = this.fb.group({
|
||||||
@@ -286,6 +287,13 @@ export class UploadFormComponent implements OnInit {
|
|||||||
return name.endsWith('.stl');
|
return name.endsWith('.stl');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSupportedFile(file: File | null): boolean {
|
||||||
|
if (!file) return false;
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase().trim();
|
||||||
|
return this.allowedExtensions.some((ext) => name.endsWith(`.${ext}`));
|
||||||
|
}
|
||||||
|
|
||||||
canPreviewSelectedFile(): boolean {
|
canPreviewSelectedFile(): boolean {
|
||||||
return this.isStlFile(this.getSelectedPreviewFile());
|
return this.isStlFile(this.getSelectedPreviewFile());
|
||||||
}
|
}
|
||||||
@@ -340,13 +348,19 @@ export class UploadFormComponent implements OnInit {
|
|||||||
onFilesDropped(newFiles: File[]) {
|
onFilesDropped(newFiles: File[]) {
|
||||||
const MAX_SIZE = 200 * 1024 * 1024;
|
const MAX_SIZE = 200 * 1024 * 1024;
|
||||||
const validItems: FormItem[] = [];
|
const validItems: FormItem[] = [];
|
||||||
let hasError = false;
|
let hasInvalidType = false;
|
||||||
|
let hasOversize = false;
|
||||||
|
|
||||||
const defaults = this.getCurrentGlobalItemDefaults();
|
const defaults = this.getCurrentGlobalItemDefaults();
|
||||||
|
|
||||||
for (const file of newFiles) {
|
for (const file of newFiles) {
|
||||||
|
if (!this.isSupportedFile(file)) {
|
||||||
|
hasInvalidType = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (file.size > MAX_SIZE) {
|
if (file.size > MAX_SIZE) {
|
||||||
hasError = true;
|
hasOversize = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +381,11 @@ export class UploadFormComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasError) {
|
if (hasInvalidType) {
|
||||||
|
alert(this.translate.instant('CALC.ERR_INVALID_FILE_TYPE'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasOversize) {
|
||||||
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
|
alert(this.translate.instant('CALC.ERR_FILE_TOO_LARGE'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,12 +573,17 @@ export class UploadFormComponent implements OnInit {
|
|||||||
|
|
||||||
const patch: any = {};
|
const patch: any = {};
|
||||||
if (settings.materialCode) patch.material = settings.materialCode;
|
if (settings.materialCode) patch.material = settings.materialCode;
|
||||||
|
if (settings.quality) {
|
||||||
|
patch.quality = this.normalizeQualityValue(settings.quality);
|
||||||
|
}
|
||||||
|
|
||||||
const layer = Number(settings.layerHeightMm);
|
const layer = Number(settings.layerHeightMm);
|
||||||
if (Number.isFinite(layer)) {
|
if (Number.isFinite(layer)) {
|
||||||
patch.layerHeight = layer;
|
patch.layerHeight = layer;
|
||||||
patch.quality =
|
if (!patch.quality) {
|
||||||
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard';
|
patch.quality =
|
||||||
|
layer >= 0.24 ? 'draft' : layer <= 0.12 ? 'extra_fine' : 'standard';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nozzle = Number(settings.nozzleDiameterMm);
|
const nozzle = Number(settings.nozzleDiameterMm);
|
||||||
@@ -688,6 +711,69 @@ export class UploadFormComponent implements OnInit {
|
|||||||
this.emitItemSettingsDiffChange();
|
this.emitItemSettingsDiffChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restoreRequestDraft(
|
||||||
|
request: QuoteRequest,
|
||||||
|
options?: {
|
||||||
|
sameSettingsForAll?: boolean;
|
||||||
|
selectedFileName?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!request?.items?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setFiles(request.items.map((item) => item.file));
|
||||||
|
this.patchSettings({
|
||||||
|
materialCode: request.material,
|
||||||
|
quality: request.quality,
|
||||||
|
layerHeightMm: request.layerHeight,
|
||||||
|
nozzleDiameterMm: request.nozzleDiameter,
|
||||||
|
infillPercent: request.infillDensity,
|
||||||
|
infillPattern: request.infillPattern,
|
||||||
|
supportsEnabled: request.supportEnabled,
|
||||||
|
notes: request.notes,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sameSettingsForAll =
|
||||||
|
this.mode() === 'advanced' ? (options?.sameSettingsForAll ?? true) : true;
|
||||||
|
this.onSameSettingsToggle(sameSettingsForAll);
|
||||||
|
|
||||||
|
request.items.forEach((item, index) => {
|
||||||
|
this.updateItemQuantityByIndex(index, Number(item.quantity || 1));
|
||||||
|
this.setItemPrintSettingsByIndex(index, {
|
||||||
|
material: item.material ?? request.material,
|
||||||
|
quality: item.quality ?? request.quality,
|
||||||
|
nozzleDiameter: item.nozzleDiameter ?? request.nozzleDiameter,
|
||||||
|
layerHeight: item.layerHeight ?? request.layerHeight,
|
||||||
|
infillDensity: item.infillDensity ?? request.infillDensity,
|
||||||
|
infillPattern: item.infillPattern ?? request.infillPattern,
|
||||||
|
supportEnabled: item.supportEnabled ?? request.supportEnabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item.color) {
|
||||||
|
this.updateItemColor(index, {
|
||||||
|
colorName: item.color,
|
||||||
|
filamentVariantId: item.filamentVariantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedFileName = this.normalizeFileName(
|
||||||
|
options?.selectedFileName ?? '',
|
||||||
|
);
|
||||||
|
const target =
|
||||||
|
this.items().find(
|
||||||
|
(item) => this.normalizeFileName(item.file.name) === selectedFileName,
|
||||||
|
) ?? this.items()[this.items().length - 1];
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
this.selectFile(target.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitPrintSettingsChange();
|
||||||
|
this.emitItemSettingsDiffChange();
|
||||||
|
}
|
||||||
|
|
||||||
getCurrentRequestDraft(): QuoteRequest {
|
getCurrentRequestDraft(): QuoteRequest {
|
||||||
const defaults = this.getCurrentGlobalItemDefaults();
|
const defaults = this.getCurrentGlobalItemDefaults();
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ export interface QuoteRequest {
|
|||||||
mode: 'easy' | 'advanced';
|
mode: 'easy' | 'advanced';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PendingCalculatorDraft {
|
||||||
|
request: QuoteRequest;
|
||||||
|
sameSettingsForAll: boolean;
|
||||||
|
selectedFileName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface QuoteItem {
|
export interface QuoteItem {
|
||||||
id?: string;
|
id?: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -130,6 +136,7 @@ export class QuoteEstimatorService {
|
|||||||
files: File[];
|
files: File[];
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
private pendingCalculatorDraft = signal<PendingCalculatorDraft | null>(null);
|
||||||
|
|
||||||
getOptions(): Observable<OptionsResponse> {
|
getOptions(): Observable<OptionsResponse> {
|
||||||
const headers: any = {};
|
const headers: any = {};
|
||||||
@@ -341,6 +348,16 @@ export class QuoteEstimatorService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPendingCalculatorDraft(data: PendingCalculatorDraft | null) {
|
||||||
|
this.pendingCalculatorDraft.set(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
consumePendingCalculatorDraft(): PendingCalculatorDraft | null {
|
||||||
|
const data = this.pendingCalculatorDraft();
|
||||||
|
this.pendingCalculatorDraft.set(null);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
getLineItemContent(
|
getLineItemContent(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
lineItemId: string,
|
lineItemId: string,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
width: min(100%, 340px);
|
width: min(100%, 340px);
|
||||||
padding: 1rem 1.1rem;
|
padding: 1rem 1.1rem;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-left: 4px solid var(--swiss-red);
|
border-left: 4px solid var(--color-brand);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
animation: fadeUp 0.85s ease both;
|
animation: fadeUp 0.85s ease both;
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
<article class="product-card">
|
<article class="product-card">
|
||||||
<a class="media" [routerLink]="productLink()" [state]="navigationState()">
|
<a
|
||||||
|
class="media"
|
||||||
|
[routerLink]="productLink()"
|
||||||
|
[state]="navigationState()"
|
||||||
|
(click)="rememberCatalogScroll()"
|
||||||
|
>
|
||||||
@if (imageUrl(); as imageUrl) {
|
@if (imageUrl(); as imageUrl) {
|
||||||
<img
|
<img
|
||||||
[src]="imageUrl"
|
[src]="imageUrl"
|
||||||
@@ -32,9 +37,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="name">
|
<h3 class="name">
|
||||||
<a [routerLink]="productLink()" [state]="navigationState()">{{
|
<a
|
||||||
product().name
|
[routerLink]="productLink()"
|
||||||
}}</a>
|
[state]="navigationState()"
|
||||||
|
(click)="rememberCatalogScroll()"
|
||||||
|
>{{ product().name }}</a
|
||||||
|
>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<p class="excerpt">
|
<p class="excerpt">
|
||||||
@@ -62,6 +70,7 @@
|
|||||||
<a
|
<a
|
||||||
[routerLink]="productLink()"
|
[routerLink]="productLink()"
|
||||||
[state]="navigationState()"
|
[state]="navigationState()"
|
||||||
|
(click)="rememberCatalogScroll()"
|
||||||
class="view-btn"
|
class="view-btn"
|
||||||
>{{ "SHOP.DETAILS" | translate }}</a
|
>{{ "SHOP.DETAILS" | translate }}</a
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -74,4 +74,16 @@ export class ProductCardComponent {
|
|||||||
shopReturnUrl: this.router.url,
|
shopReturnUrl: this.router.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rememberCatalogScroll(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextState = {
|
||||||
|
...(history.state ?? {}),
|
||||||
|
shopRestoreScrollY: Math.max(0, Math.round(window.scrollY)),
|
||||||
|
};
|
||||||
|
history.replaceState(nextState, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
← {{ "SHOP.BACK" | translate }}
|
← {{ "SHOP.BACK" | translate }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() || softFallbackActive()) {
|
||||||
<div class="detail-grid skeleton-grid">
|
<div class="detail-grid skeleton-grid">
|
||||||
<div class="skeleton-block"></div>
|
<div class="skeleton-block"></div>
|
||||||
<div class="skeleton-block"></div>
|
<div class="skeleton-block"></div>
|
||||||
|
|||||||
272
frontend/src/app/features/shop/product-detail.component.spec.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { ShopProductDetail, ShopService } from './services/shop.service';
|
||||||
|
import { ProductDetailComponent } from './product-detail.component';
|
||||||
|
|
||||||
|
describe('ProductDetailComponent', () => {
|
||||||
|
function buildProduct(
|
||||||
|
overrides: Partial<ShopProductDetail> = {},
|
||||||
|
): ShopProductDetail {
|
||||||
|
return {
|
||||||
|
id: '91823f84-1111-2222-3333-444444444444',
|
||||||
|
slug: 'bike-wall-hanger',
|
||||||
|
name: 'Bike Wall-Hanger',
|
||||||
|
excerpt: 'Wall mount for bicycles',
|
||||||
|
description: '<p>Wall mount for bicycles</p>',
|
||||||
|
seoTitle: null,
|
||||||
|
seoDescription: null,
|
||||||
|
ogTitle: null,
|
||||||
|
ogDescription: null,
|
||||||
|
indexable: true,
|
||||||
|
isFeatured: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
category: {
|
||||||
|
id: 'category-1',
|
||||||
|
slug: 'bike-accessories',
|
||||||
|
name: 'Bike Accessories',
|
||||||
|
},
|
||||||
|
breadcrumbs: [],
|
||||||
|
priceFromChf: 29.9,
|
||||||
|
priceToChf: 29.9,
|
||||||
|
defaultVariant: {
|
||||||
|
id: 'variant-1',
|
||||||
|
sku: 'BW-1',
|
||||||
|
variantLabel: 'PLA',
|
||||||
|
colorName: 'Black',
|
||||||
|
colorLabel: 'Black',
|
||||||
|
colorHex: '#111111',
|
||||||
|
priceChf: 29.9,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
variants: [
|
||||||
|
{
|
||||||
|
id: 'variant-1',
|
||||||
|
sku: 'BW-1',
|
||||||
|
variantLabel: 'PLA',
|
||||||
|
colorName: 'Black',
|
||||||
|
colorLabel: 'Black',
|
||||||
|
colorHex: '#111111',
|
||||||
|
priceChf: 29.9,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
primaryImage: null,
|
||||||
|
images: [],
|
||||||
|
model3d: null,
|
||||||
|
publicPath: '91823f84-bike-wall-hanger',
|
||||||
|
localizedPaths: {
|
||||||
|
it: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||||
|
en: '/en/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
de: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
fr: '/fr/shop/p/91823f84-support-mural-velo',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComponent(
|
||||||
|
routerUrl = '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
options?: {
|
||||||
|
currentLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
selectedLang?: 'it' | 'en' | 'de' | 'fr';
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const responseInit: { status?: number } = {};
|
||||||
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
|
'applyResolvedSeo',
|
||||||
|
'applyPageSeo',
|
||||||
|
]);
|
||||||
|
const translate = jasmine.createSpyObj<TranslateService>(
|
||||||
|
'TranslateService',
|
||||||
|
['instant'],
|
||||||
|
);
|
||||||
|
translate.instant.and.callFake((key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'SHOP.TITLE': 'Technische Lösungen',
|
||||||
|
'SHOP.CATALOG_META_DESCRIPTION':
|
||||||
|
'Entdecken Sie technische 3D-Druck-Lösungen.',
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_TITLE': 'Produkt | 3D fab',
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION':
|
||||||
|
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||||
|
};
|
||||||
|
return translations[key] ?? key;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>(
|
||||||
|
options?.currentLang ?? 'de',
|
||||||
|
);
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: () => options?.selectedLang ?? currentLang(),
|
||||||
|
setLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
|
'setLocalizedRouteOverrides',
|
||||||
|
),
|
||||||
|
clearLocalizedRouteOverrides: jasmine.createSpy(
|
||||||
|
'clearLocalizedRouteOverrides',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopService = {
|
||||||
|
cartLoaded: signal(false),
|
||||||
|
cartLoading: signal(false),
|
||||||
|
getProductByPublicPath: jasmine
|
||||||
|
.createSpy('getProductByPublicPath')
|
||||||
|
.and.returnValue(of(buildProduct())),
|
||||||
|
quantityForVariant: jasmine
|
||||||
|
.createSpy('quantityForVariant')
|
||||||
|
.and.returnValue(0),
|
||||||
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
|
resolveMediaUrl: jasmine
|
||||||
|
.createSpy('resolveMediaUrl')
|
||||||
|
.and.returnValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
url: routerUrl,
|
||||||
|
navigate: jasmine.createSpy('navigate'),
|
||||||
|
navigateByUrl: jasmine.createSpy('navigateByUrl'),
|
||||||
|
parseUrl: jasmine.createSpy('parseUrl'),
|
||||||
|
createUrlTree: jasmine.createSpy('createUrlTree'),
|
||||||
|
serializeUrl: jasmine.createSpy('serializeUrl'),
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
const activatedRoute = {
|
||||||
|
paramMap: of(
|
||||||
|
convertToParamMap({ productSlug: '91823f84-bike-wall-hanger' }),
|
||||||
|
),
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({
|
||||||
|
productSlug: '91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ProductDetailComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: SeoService, useValue: seoService },
|
||||||
|
{ provide: TranslateService, useValue: translate },
|
||||||
|
{ provide: LanguageService, useValue: languageService },
|
||||||
|
{ provide: ShopService, useValue: shopService },
|
||||||
|
{
|
||||||
|
provide: ShopRouteService,
|
||||||
|
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||||
|
'shopRootCommands',
|
||||||
|
'productPathSegment',
|
||||||
|
'isCatalogUrl',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||||
|
{
|
||||||
|
provide: Location,
|
||||||
|
useValue: jasmine.createSpyObj<Location>('Location', ['back']),
|
||||||
|
},
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture: ComponentFixture<ProductDetailComponent> =
|
||||||
|
TestBed.createComponent(ProductDetailComponent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: fixture.componentInstance,
|
||||||
|
seoService,
|
||||||
|
responseInit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('applies index follow SEO for indexable products', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct());
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Bike Wall-Hanger | 3D fab',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
alternates: buildProduct().localizedPaths,
|
||||||
|
xDefault: '/it/shop/p/91823f84-supporto-bici-muro',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the route language for canonical SEO even if the selected translation language lags', () => {
|
||||||
|
const { component, seoService } = createComponent(undefined, {
|
||||||
|
currentLang: 'de',
|
||||||
|
selectedLang: 'en',
|
||||||
|
});
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct());
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies noindex for products explicitly marked as non-indexable', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applySeo(buildProduct({ indexable: false }));
|
||||||
|
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a soft SSR fallback with 200 + index follow', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
|
(component as any).setResponseStatus(200);
|
||||||
|
(component as any).applySoftFallbackSeo('91823f84-bike-wall-hanger');
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(200);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Bike Wall Hanger | 3D fab',
|
||||||
|
description:
|
||||||
|
'Entdecken Sie Details, Materialien, Varianten und Verfügbarkeit.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/p/91823f84-bike-wall-hanger',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hard fallback noindex for missing products', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
|
(component as any).setResponseStatus(404);
|
||||||
|
(component as any).applyHardFallbackSeo();
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,31 @@
|
|||||||
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
import { CommonModule, Location, isPlatformBrowser } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
RESPONSE_INIT,
|
||||||
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injector,
|
Injector,
|
||||||
PLATFORM_ID,
|
PLATFORM_ID,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
|
import {
|
||||||
|
catchError,
|
||||||
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
|
finalize,
|
||||||
|
map,
|
||||||
|
of,
|
||||||
|
switchMap,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs';
|
||||||
import { SeoService } from '../../core/services/seo.service';
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
import { LanguageService } from '../../core/services/language.service';
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
import { findColorHex, getColorHex } from '../../core/constants/colors.const';
|
import { findColorHex } from '../../core/constants/colors.const';
|
||||||
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
|
||||||
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
|
||||||
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
|
||||||
@@ -25,6 +35,7 @@ import {
|
|||||||
ShopService,
|
ShopService,
|
||||||
} from './services/shop.service';
|
} from './services/shop.service';
|
||||||
import { ShopRouteService } from './services/shop-route.service';
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||||
|
|
||||||
interface ShopMaterialOption {
|
interface ShopMaterialOption {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -58,18 +69,23 @@ export class ProductDetailComponent {
|
|||||||
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly location = inject(Location);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
private readonly shopRouteService = inject(ShopRouteService);
|
||||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
readonly productSlug = input<string | undefined>();
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
|
readonly softFallbackActive = signal(false);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly product = signal<ShopProductDetail | null>(null);
|
readonly product = signal<ShopProductDetail | null>(null);
|
||||||
readonly selectedVariantId = signal<string | null>(null);
|
readonly selectedVariantId = signal<string | null>(null);
|
||||||
@@ -193,49 +209,76 @@ export class ProductDetailComponent {
|
|||||||
);
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!this.shopService.cartLoaded()) {
|
afterNextRender(() => {
|
||||||
this.shopService
|
this.scheduleCartWarmup();
|
||||||
.loadCart()
|
});
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
this.destroyRef.onDestroy(() => {
|
||||||
.subscribe({
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
error: () => {
|
});
|
||||||
this.shopService.cart.set(null);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.productSlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => ({
|
||||||
|
categorySlug: this.normalizeRouteParam(params.get('categorySlug')),
|
||||||
|
productSlug: this.normalizeRouteParam(params.get('productSlug')),
|
||||||
|
})),
|
||||||
|
distinctUntilChanged(
|
||||||
|
(previous, current) =>
|
||||||
|
previous.categorySlug === current.categorySlug &&
|
||||||
|
previous.productSlug === current.productSlug,
|
||||||
|
),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
this.addSuccess.set(false);
|
this.addSuccess.set(false);
|
||||||
this.modelError.set(false);
|
this.modelError.set(false);
|
||||||
this.colorPopupOpen.set(false);
|
this.colorPopupOpen.set(false);
|
||||||
this.modelModalOpen.set(false);
|
this.modelModalOpen.set(false);
|
||||||
}),
|
}),
|
||||||
switchMap(([productSlug]) => {
|
switchMap(([routeParams]) => {
|
||||||
if (!productSlug) {
|
this.routeCategorySlug.set(routeParams.categorySlug);
|
||||||
|
if (!routeParams.productSlug) {
|
||||||
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.error.set('SHOP.NOT_FOUND');
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
|
this.setResponseStatus(404);
|
||||||
|
this.applyHardFallbackSeo();
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
return of(null);
|
return of(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const productSlug = routeParams.productSlug as string;
|
||||||
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
return this.shopService.getProductByPublicPath(productSlug).pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
|
this.languageService.clearLocalizedRouteOverrides();
|
||||||
this.product.set(null);
|
this.product.set(null);
|
||||||
this.selectedVariantId.set(null);
|
this.selectedVariantId.set(null);
|
||||||
this.setSelectedImageAssetId(null);
|
this.setSelectedImageAssetId(null);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
this.error.set(
|
const isNotFound = error?.status === 404;
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
if (isNotFound) {
|
||||||
);
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
this.applyFallbackSeo();
|
this.setResponseStatus(404);
|
||||||
|
this.applyHardFallbackSeo();
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldUseSoftSeoFallback(error)) {
|
||||||
|
this.error.set(null);
|
||||||
|
this.softFallbackActive.set(true);
|
||||||
|
this.setResponseStatus(200);
|
||||||
|
this.applySoftFallbackSeo(productSlug);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('SHOP.LOAD_ERROR');
|
||||||
|
this.setResponseStatus(503);
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
finalize(() => this.loading.set(false)),
|
finalize(() => this.loading.set(false)),
|
||||||
@@ -249,6 +292,7 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.product.set(product);
|
this.product.set(product);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
this.selectedVariantId.set(
|
this.selectedVariantId.set(
|
||||||
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
product.defaultVariant?.id ?? product.variants[0]?.id ?? null,
|
||||||
);
|
);
|
||||||
@@ -263,6 +307,7 @@ export class ProductDetailComponent {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
this.quantity.set(1);
|
this.quantity.set(1);
|
||||||
|
this.languageService.setLocalizedRouteOverrides(product.localizedPaths);
|
||||||
this.syncPublicUrl(product);
|
this.syncPublicUrl(product);
|
||||||
this.applySeo(product);
|
this.applySeo(product);
|
||||||
this.modelFile.set(null);
|
this.modelFile.set(null);
|
||||||
@@ -282,6 +327,45 @@ export class ProductDetailComponent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private scheduleCartWarmup(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmup = () => this.loadCartIfNeeded();
|
||||||
|
const idleCallback = (
|
||||||
|
window as Window & {
|
||||||
|
requestIdleCallback?: (
|
||||||
|
callback: IdleRequestCallback,
|
||||||
|
options?: IdleRequestOptions,
|
||||||
|
) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
|
|
||||||
|
if (typeof idleCallback === 'function') {
|
||||||
|
idleCallback(() => warmup(), { timeout: 1500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(warmup, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCartIfNeeded(): void {
|
||||||
|
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shopService
|
||||||
|
.loadCart()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.shopService.cart.set(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
selectImage(mediaAssetId: string): void {
|
selectImage(mediaAssetId: string): void {
|
||||||
this.setSelectedImageAssetId(mediaAssetId);
|
this.setSelectedImageAssetId(mediaAssetId);
|
||||||
}
|
}
|
||||||
@@ -445,7 +529,8 @@ export class ProductDetailComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
productLinkRoot(): string[] {
|
productLinkRoot(): string[] {
|
||||||
const categorySlug = this.product()?.category.slug || this.categorySlug();
|
const categorySlug =
|
||||||
|
this.product()?.category.slug || this.routeCategorySlug();
|
||||||
return this.shopRouteService.shopRootCommands(categorySlug);
|
return this.shopRouteService.shopRootCommands(categorySlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +541,11 @@ export class ProductDetailComponent {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
|
if (returnUrl && this.shopRouteService.isCatalogUrl(returnUrl)) {
|
||||||
|
if (this.isBrowser && window.history.length > 1) {
|
||||||
|
this.location.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void this.router.navigateByUrl(returnUrl);
|
void this.router.navigateByUrl(returnUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -515,28 +605,86 @@ export class ProductDetailComponent {
|
|||||||
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
const robots =
|
const robots =
|
||||||
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
product.indexable === false ? 'noindex, nofollow' : 'index, follow';
|
||||||
|
const lang = this.languageService.currentLang();
|
||||||
|
const canonicalPath =
|
||||||
|
product.localizedPaths?.[lang] ?? product.localizedPaths?.it ?? null;
|
||||||
|
|
||||||
this.seoService.applyPageSeo({
|
this.seoService.applyResolvedSeo({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
robots,
|
robots,
|
||||||
ogTitle: product.ogTitle || title,
|
ogTitle: product.ogTitle || title,
|
||||||
ogDescription: product.ogDescription || description,
|
ogDescription: product.ogDescription || description,
|
||||||
|
canonicalPath,
|
||||||
|
alternates: product.localizedPaths,
|
||||||
|
xDefault: product.localizedPaths?.it ?? canonicalPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFallbackSeo(): void {
|
private applyHardFallbackSeo(): void {
|
||||||
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
||||||
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
this.seoService.applyPageSeo({
|
this.seoService.applyResolvedSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
ogTitle: title,
|
||||||
|
ogDescription: description,
|
||||||
|
canonicalPath: null,
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySoftFallbackSeo(productSlug: string): void {
|
||||||
|
const title = this.buildSoftFallbackTitle(productSlug);
|
||||||
|
const description = this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_DESCRIPTION',
|
||||||
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.seoService.applyResolvedSeo({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
robots: 'index, follow',
|
robots: 'index, follow',
|
||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
|
canonicalPath: this.currentPath(),
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
|
||||||
|
return !this.isBrowser && error?.status !== 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSoftFallbackTitle(productSlug: string): string {
|
||||||
|
const humanized = humanizeShopSlug(productSlug, {
|
||||||
|
stripProductIdPrefix: true,
|
||||||
|
});
|
||||||
|
if (humanized) {
|
||||||
|
return `${humanized} | 3D fab`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.PRODUCT_TITLE',
|
||||||
|
`${this.translate.instant('SHOP.TITLE')} | 3D fab`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTranslatedText(key: string, fallback: string): string {
|
||||||
|
const translated = this.translate.instant(key);
|
||||||
|
return typeof translated === 'string' && translated !== key
|
||||||
|
? translated
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPath(): string {
|
||||||
|
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
private materialLabelForVariant(
|
private materialLabelForVariant(
|
||||||
variant: ShopProductVariantOption | null,
|
variant: ShopProductVariantOption | null,
|
||||||
): string {
|
): string {
|
||||||
@@ -708,21 +856,23 @@ export class ProductDetailComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentProductSlug = this.productSlug()?.trim().toLowerCase() ?? '';
|
const currentTree = this.router.parseUrl(this.router.url);
|
||||||
const targetProductSlug = this.shopRouteService.productPathSegment(product);
|
const lang = this.languageService.currentLang();
|
||||||
if (currentProductSlug === targetProductSlug) {
|
const targetPath =
|
||||||
|
product.localizedPaths?.[lang] ??
|
||||||
|
`/${lang}/shop/p/${this.shopRouteService.productPathSegment(product)}`;
|
||||||
|
const normalizedTargetPath = targetPath.startsWith('/')
|
||||||
|
? targetPath
|
||||||
|
: `/${targetPath}`;
|
||||||
|
const currentPath = this.router
|
||||||
|
.serializeUrl(currentTree)
|
||||||
|
.split(/[?#]/, 1)[0];
|
||||||
|
if (currentPath === normalizedTargetPath) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentTree = this.router.parseUrl(this.router.url);
|
|
||||||
const targetTree = this.router.createUrlTree(
|
const targetTree = this.router.createUrlTree(
|
||||||
[
|
['/', ...normalizedTargetPath.split('/').filter(Boolean)],
|
||||||
'/',
|
|
||||||
this.languageService.selectedLang(),
|
|
||||||
'shop',
|
|
||||||
'p',
|
|
||||||
targetProductSlug,
|
|
||||||
],
|
|
||||||
{
|
{
|
||||||
queryParams: currentTree.queryParams,
|
queryParams: currentTree.queryParams,
|
||||||
fragment: currentTree.fragment ?? undefined,
|
fragment: currentTree.fragment ?? undefined,
|
||||||
@@ -741,4 +891,19 @@ export class ProductDetailComponent {
|
|||||||
state: history.state,
|
state: history.state,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setResponseStatus(status: number): void {
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readRouteParam(name: string): string | null {
|
||||||
|
return this.normalizeRouteParam(this.route.snapshot.paramMap.get(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRouteParam(value: string | null | undefined): string | null {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { LanguageService } from '../../../core/services/language.service';
|
import { LanguageService } from '../../../core/services/language.service';
|
||||||
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
|
||||||
export interface ShopProductRouteRef {
|
export interface ShopProductRouteRef {
|
||||||
id: string | null | undefined;
|
id: string | null | undefined;
|
||||||
name: string | null | undefined;
|
name: string | null | undefined;
|
||||||
slug?: string | null | undefined;
|
slug?: string | null | undefined;
|
||||||
}
|
publicPath?: string | null | undefined;
|
||||||
|
localizedPaths?: Partial<Record<SupportedLang, string>> | null | undefined;
|
||||||
export interface ShopProductLookup {
|
|
||||||
idPrefix: string | null;
|
|
||||||
slugHint: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -26,11 +25,21 @@ export class ShopRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
productCommands(product: ShopProductRouteRef): string[] {
|
productCommands(product: ShopProductRouteRef): string[] {
|
||||||
|
const localizedPath = this.localizedProductPath(product);
|
||||||
|
if (localizedPath) {
|
||||||
|
return ['/', ...localizedPath.split('/').filter(Boolean)];
|
||||||
|
}
|
||||||
|
|
||||||
const lang = this.languageService.currentLang();
|
const lang = this.languageService.currentLang();
|
||||||
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
|
return ['/', lang, 'shop', 'p', this.productPathSegment(product)];
|
||||||
}
|
}
|
||||||
|
|
||||||
productPathSegment(product: ShopProductRouteRef): string {
|
productPathSegment(product: ShopProductRouteRef): string {
|
||||||
|
const publicPath = String(product.publicPath ?? '').trim();
|
||||||
|
if (publicPath) {
|
||||||
|
return publicPath;
|
||||||
|
}
|
||||||
|
|
||||||
const idPrefix = this.productIdPrefix(product.id);
|
const idPrefix = this.productIdPrefix(product.id);
|
||||||
const tail =
|
const tail =
|
||||||
this.slugify(product.name) || this.slugify(product.slug) || 'product';
|
this.slugify(product.name) || this.slugify(product.slug) || 'product';
|
||||||
@@ -38,41 +47,6 @@ export class ShopRouteService {
|
|||||||
return idPrefix ? `${idPrefix}-${tail}` : tail;
|
return idPrefix ? `${idPrefix}-${tail}` : tail;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveProductLookup(
|
|
||||||
productPathSegment: string | null | undefined,
|
|
||||||
): ShopProductLookup {
|
|
||||||
const normalized = String(productPathSegment ?? '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return {
|
|
||||||
idPrefix: null,
|
|
||||||
slugHint: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const bareUuidMatch = normalized.match(/^([0-9a-f]{8})$/);
|
|
||||||
if (bareUuidMatch) {
|
|
||||||
return {
|
|
||||||
idPrefix: bareUuidMatch[1],
|
|
||||||
slugHint: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const publicSlugMatch = normalized.match(/^([0-9a-f]{8})-(.+)$/);
|
|
||||||
if (publicSlugMatch) {
|
|
||||||
return {
|
|
||||||
idPrefix: publicSlugMatch[1],
|
|
||||||
slugHint: this.slugify(publicSlugMatch[2]) || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
idPrefix: null,
|
|
||||||
slugHint: normalized,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
isCatalogUrl(url: string | null | undefined): boolean {
|
isCatalogUrl(url: string | null | undefined): boolean {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return false;
|
return false;
|
||||||
@@ -92,6 +66,12 @@ export class ShopRouteService {
|
|||||||
.replace(/-{2,}/g, '-');
|
.replace(/-{2,}/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private localizedProductPath(product: ShopProductRouteRef): string | null {
|
||||||
|
const lang = this.languageService.currentLang();
|
||||||
|
const localizedPath = String(product.localizedPaths?.[lang] ?? '').trim();
|
||||||
|
return localizedPath.startsWith('/') ? localizedPath : null;
|
||||||
|
}
|
||||||
|
|
||||||
private productIdPrefix(productId: string | null | undefined): string {
|
private productIdPrefix(productId: string | null | undefined): string {
|
||||||
const normalized = String(productId ?? '')
|
const normalized = String(productId ?? '')
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
HttpClientTestingModule,
|
HttpClientTestingModule,
|
||||||
@@ -5,7 +6,6 @@ import {
|
|||||||
} from '@angular/common/http/testing';
|
} from '@angular/common/http/testing';
|
||||||
import {
|
import {
|
||||||
ShopCartResponse,
|
ShopCartResponse,
|
||||||
ShopProductCatalogResponse,
|
|
||||||
ShopProductDetail,
|
ShopProductDetail,
|
||||||
ShopService,
|
ShopService,
|
||||||
} from './shop.service';
|
} from './shop.service';
|
||||||
@@ -14,6 +14,11 @@ import { LanguageService } from '../../../core/services/language.service';
|
|||||||
describe('ShopService', () => {
|
describe('ShopService', () => {
|
||||||
let service: ShopService;
|
let service: ShopService;
|
||||||
let httpMock: HttpTestingController;
|
let httpMock: HttpTestingController;
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('it');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: jasmine.createSpy('selectedLang').and.returnValue('it'),
|
||||||
|
};
|
||||||
|
|
||||||
const buildCart = (): ShopCartResponse => ({
|
const buildCart = (): ShopCartResponse => ({
|
||||||
session: {
|
session: {
|
||||||
@@ -90,32 +95,6 @@ describe('ShopService', () => {
|
|||||||
grandTotalChf: 36.8,
|
grandTotalChf: 36.8,
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildCatalog = (): ShopProductCatalogResponse => ({
|
|
||||||
categorySlug: null,
|
|
||||||
featuredOnly: false,
|
|
||||||
category: null,
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
|
||||||
slug: 'desk-cable-clip',
|
|
||||||
name: 'Supporto cavo scrivania',
|
|
||||||
excerpt: 'Accessorio tecnico',
|
|
||||||
isFeatured: true,
|
|
||||||
sortOrder: 0,
|
|
||||||
category: {
|
|
||||||
id: 'category-1',
|
|
||||||
slug: 'accessori',
|
|
||||||
name: 'Accessori',
|
|
||||||
},
|
|
||||||
priceFromChf: 9.9,
|
|
||||||
priceToChf: 12.5,
|
|
||||||
defaultVariant: null,
|
|
||||||
primaryImage: null,
|
|
||||||
model3d: null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const buildProduct = (): ShopProductDetail => ({
|
const buildProduct = (): ShopProductDetail => ({
|
||||||
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
id: '12345678-abcd-4abc-9abc-1234567890ab',
|
||||||
slug: 'desk-cable-clip',
|
slug: 'desk-cable-clip',
|
||||||
@@ -142,6 +121,13 @@ describe('ShopService', () => {
|
|||||||
primaryImage: null,
|
primaryImage: null,
|
||||||
images: [],
|
images: [],
|
||||||
model3d: null,
|
model3d: null,
|
||||||
|
publicPath: '12345678-supporto-cavo-scrivania',
|
||||||
|
localizedPaths: {
|
||||||
|
it: '/it/shop/p/12345678-supporto-cavo-scrivania',
|
||||||
|
en: '/en/shop/p/12345678-desk-cable-clip',
|
||||||
|
de: '/de/shop/p/12345678-schreibtisch-kabelhalter',
|
||||||
|
fr: '/fr/shop/p/12345678-support-cable-bureau',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -151,13 +137,14 @@ describe('ShopService', () => {
|
|||||||
ShopService,
|
ShopService,
|
||||||
{
|
{
|
||||||
provide: LanguageService,
|
provide: LanguageService,
|
||||||
useValue: {
|
useValue: languageService,
|
||||||
selectedLang: () => 'it',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
currentLang.set('it');
|
||||||
|
languageService.selectedLang.and.returnValue('it');
|
||||||
|
|
||||||
service = TestBed.inject(ShopService);
|
service = TestBed.inject(ShopService);
|
||||||
httpMock = TestBed.inject(HttpTestingController);
|
httpMock = TestBed.inject(HttpTestingController);
|
||||||
});
|
});
|
||||||
@@ -212,85 +199,89 @@ describe('ShopService', () => {
|
|||||||
response = product;
|
response = product;
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'it'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detailRequest.flush(buildProduct());
|
request.flush(buildProduct());
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
expect(response?.name).toBe('Supporto cavo scrivania');
|
expect(response?.name).toBe('Supporto cavo scrivania');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resolves product detail from uuid prefix even when slug tail does not match', () => {
|
it('resolves products from the stable uuid prefix even if the slug tail is stale', () => {
|
||||||
let response: ShopProductDetail | undefined;
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
service.getProductByPublicPath('12345678-qualunque-nome').subscribe({
|
||||||
|
next: (product) => {
|
||||||
|
response = product;
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected stale slug tails to resolve from the uuid prefix'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((request) => {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
|
request.params.get('lang') === 'it'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.flush(buildProduct());
|
||||||
|
|
||||||
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves bare uuid product paths through the stable uuid prefix endpoint', () => {
|
||||||
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
service.getProductByPublicPath('12345678').subscribe({
|
||||||
|
next: (product) => {
|
||||||
|
response = product;
|
||||||
|
},
|
||||||
|
error: () =>
|
||||||
|
fail('Expected bare uuid path to resolve from the uuid prefix'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = httpMock.expectOne((request) => {
|
||||||
|
return (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
request.url ===
|
||||||
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
|
request.params.get('lang') === 'it'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
request.flush(buildProduct());
|
||||||
|
|
||||||
|
expect(response?.publicPath).toBe('12345678-supporto-cavo-scrivania');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the route language for public shop lookups when translate.currentLang lags behind', () => {
|
||||||
|
let response: ShopProductDetail | undefined;
|
||||||
|
|
||||||
|
currentLang.set('de');
|
||||||
|
languageService.selectedLang.and.returnValue('en');
|
||||||
|
|
||||||
service
|
service
|
||||||
.getProductByPublicPath('12345678-qualunque-nome')
|
.getProductByPublicPath('12345678-schreibtisch-kabelhalter')
|
||||||
.subscribe((product) => {
|
.subscribe((product) => {
|
||||||
response = product;
|
response = product;
|
||||||
});
|
});
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
const request = httpMock.expectOne((request) => {
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
return (
|
||||||
request.method === 'GET' &&
|
request.method === 'GET' &&
|
||||||
request.url ===
|
request.url ===
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
'http://localhost:8000/api/shop/products/by-id-prefix/12345678' &&
|
||||||
request.params.get('lang') === 'it'
|
request.params.get('lang') === 'de'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
detailRequest.flush(buildProduct());
|
request.flush(buildProduct());
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves product detail from bare uuid prefix without slug tail', () => {
|
|
||||||
let response: ShopProductDetail | undefined;
|
|
||||||
|
|
||||||
service.getProductByPublicPath('12345678').subscribe((product) => {
|
|
||||||
response = product;
|
|
||||||
});
|
|
||||||
|
|
||||||
const catalogRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url === 'http://localhost:8000/api/shop/products' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
catalogRequest.flush(buildCatalog());
|
|
||||||
|
|
||||||
const detailRequest = httpMock.expectOne((request) => {
|
|
||||||
return (
|
|
||||||
request.method === 'GET' &&
|
|
||||||
request.url ===
|
|
||||||
'http://localhost:8000/api/shop/products/desk-cable-clip' &&
|
|
||||||
request.params.get('lang') === 'it'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
detailRequest.flush(buildProduct());
|
|
||||||
|
|
||||||
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
expect(response?.id).toBe('12345678-abcd-4abc-9abc-1234567890ab');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { computed, inject, Injectable, signal } from '@angular/core';
|
import { computed, inject, Injectable, signal } from '@angular/core';
|
||||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||||
import { map, Observable, switchMap, tap, throwError } from 'rxjs';
|
import { map, Observable, tap, throwError } from 'rxjs';
|
||||||
import { environment } from '../../../../environments/environment';
|
import { environment } from '../../../../environments/environment';
|
||||||
import {
|
import {
|
||||||
PublicMediaUsageDto,
|
PublicMediaUsageDto,
|
||||||
PublicMediaVariantDto,
|
PublicMediaVariantDto,
|
||||||
} from '../../../core/services/public-media.service';
|
} from '../../../core/services/public-media.service';
|
||||||
import { LanguageService } from '../../../core/services/language.service';
|
import { LanguageService } from '../../../core/services/language.service';
|
||||||
import { ShopRouteService } from './shop-route.service';
|
|
||||||
|
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
|
||||||
|
type LocalizedPathMap = Partial<Record<SupportedLang, string>>;
|
||||||
|
|
||||||
export interface ShopCategoryRef {
|
export interface ShopCategoryRef {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -84,6 +86,8 @@ export interface ShopProductSummary {
|
|||||||
defaultVariant: ShopProductVariantOption | null;
|
defaultVariant: ShopProductVariantOption | null;
|
||||||
primaryImage: PublicMediaUsageDto | null;
|
primaryImage: PublicMediaUsageDto | null;
|
||||||
model3d: ShopProductModel | null;
|
model3d: ShopProductModel | null;
|
||||||
|
publicPath: string;
|
||||||
|
localizedPaths: LocalizedPathMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopProductDetail {
|
export interface ShopProductDetail {
|
||||||
@@ -108,6 +112,8 @@ export interface ShopProductDetail {
|
|||||||
primaryImage: PublicMediaUsageDto | null;
|
primaryImage: PublicMediaUsageDto | null;
|
||||||
images: PublicMediaUsageDto[];
|
images: PublicMediaUsageDto[];
|
||||||
model3d: ShopProductModel | null;
|
model3d: ShopProductModel | null;
|
||||||
|
publicPath: string;
|
||||||
|
localizedPaths: LocalizedPathMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopProductCatalogResponse {
|
export interface ShopProductCatalogResponse {
|
||||||
@@ -185,7 +191,6 @@ export interface ShopCategoryNavNode {
|
|||||||
export class ShopService {
|
export class ShopService {
|
||||||
private readonly http = inject(HttpClient);
|
private readonly http = inject(HttpClient);
|
||||||
private readonly languageService = inject(LanguageService);
|
private readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
|
||||||
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
|
private readonly apiUrl = `${environment.apiUrl}/api/shop`;
|
||||||
|
|
||||||
readonly cart = signal<ShopCartResponse | null>(null);
|
readonly cart = signal<ShopCartResponse | null>(null);
|
||||||
@@ -278,27 +283,32 @@ export class ShopService {
|
|||||||
getProductByPublicPath(
|
getProductByPublicPath(
|
||||||
productPathSegment: string,
|
productPathSegment: string,
|
||||||
): Observable<ShopProductDetail> {
|
): Observable<ShopProductDetail> {
|
||||||
const lookup =
|
const normalizedPath = this.normalizePublicPath(productPathSegment);
|
||||||
this.shopRouteService.resolveProductLookup(productPathSegment);
|
if (!normalizedPath) {
|
||||||
if (!lookup.idPrefix && lookup.slugHint) {
|
return throwError(() => ({
|
||||||
return this.getProduct(lookup.slugHint);
|
status: 404,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.getProductCatalog().pipe(
|
const productIdPrefix = this.extractProductIdPrefix(normalizedPath);
|
||||||
map((catalog) =>
|
const endpoint = productIdPrefix
|
||||||
catalog.products.find((product) =>
|
? `${this.apiUrl}/products/by-id-prefix/${encodeURIComponent(productIdPrefix)}`
|
||||||
product.id.toLowerCase().startsWith(lookup.idPrefix ?? ''),
|
: `${this.apiUrl}/products/by-path/${encodeURIComponent(normalizedPath)}`;
|
||||||
),
|
|
||||||
),
|
return this.http.get<ShopProductDetail>(endpoint, {
|
||||||
switchMap((product) => {
|
params: this.buildLangParams(),
|
||||||
if (!product) {
|
});
|
||||||
return throwError(() => ({
|
}
|
||||||
status: 404,
|
|
||||||
}));
|
private normalizePublicPath(value: string | null | undefined): string {
|
||||||
}
|
return String(value ?? '')
|
||||||
return this.getProduct(product.slug);
|
.trim()
|
||||||
}),
|
.toLowerCase();
|
||||||
);
|
}
|
||||||
|
|
||||||
|
private extractProductIdPrefix(value: string): string | null {
|
||||||
|
const match = value.match(/^([0-9a-f]{8})(?:-|$)/);
|
||||||
|
return match?.[1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadCart(): Observable<ShopCartResponse> {
|
loadCart(): Observable<ShopCartResponse> {
|
||||||
@@ -452,7 +462,10 @@ export class ShopService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildLangParams(): HttpParams {
|
private buildLangParams(): HttpParams {
|
||||||
return new HttpParams().set('lang', this.languageService.selectedLang());
|
// Public shop URLs are localized. During direct loads the translation
|
||||||
|
// service can still momentarily reflect the browser language, while the
|
||||||
|
// route language has already been resolved from the URL.
|
||||||
|
return new HttpParams().set('lang', this.languageService.currentLang());
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCart(cart: ShopCartResponse): void {
|
private setCart(cart: ShopCartResponse): void {
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
<section class="shop-page">
|
<section class="shop-page">
|
||||||
<div class="container ui-simple-hero shop-hero">
|
<div class="container ui-simple-hero shop-hero">
|
||||||
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
|
<h1 class="ui-simple-hero__title">{{ "NAV.SHOP" | translate }}</h1>
|
||||||
<p class="ui-simple-hero__subtitle">
|
<p class="ui-simple-hero__subtitle">{{ heroSubtitle() }}</p>
|
||||||
{{
|
|
||||||
selectedCategory()
|
|
||||||
? selectedCategory()?.description ||
|
|
||||||
("SHOP.CATEGORY_META"
|
|
||||||
| translate: { count: selectedCategory()?.productCount || 0 })
|
|
||||||
: ("SHOP.SUBTITLE" | translate)
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container shop-layout">
|
<div class="container shop-layout">
|
||||||
@@ -181,17 +173,9 @@
|
|||||||
<div class="section-head catalog-head">
|
<div class="section-head catalog-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="ui-eyebrow ui-eyebrow--compact">
|
<p class="ui-eyebrow ui-eyebrow--compact">
|
||||||
{{
|
{{ catalogEyebrow() }}
|
||||||
selectedCategory()
|
|
||||||
? ("SHOP.SELECTED_CATEGORY" | translate)
|
|
||||||
: ("SHOP.CATALOG_LABEL" | translate)
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<h2 class="section-title">
|
<h2 class="section-title">{{ catalogTitle() }}</h2>
|
||||||
{{
|
|
||||||
selectedCategory()?.name || ("SHOP.CATALOG_TITLE" | translate)
|
|
||||||
}}
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="catalog-counter">
|
<span class="catalog-counter">
|
||||||
{{ products().length }}
|
{{ products().length }}
|
||||||
@@ -199,7 +183,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading() || softFallbackActive()) {
|
||||||
<div class="product-grid skeleton-grid">
|
<div class="product-grid skeleton-grid">
|
||||||
@for (ghost of [1, 2, 3, 4]; track ghost) {
|
@for (ghost of [1, 2, 3, 4]; track ghost) {
|
||||||
<div class="skeleton-card"></div>
|
<div class="skeleton-card"></div>
|
||||||
|
|||||||
@@ -332,6 +332,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
.cart-card {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.product-grid {
|
.product-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
233
frontend/src/app/features/shop/shop-page.component.spec.ts
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import { PLATFORM_ID, RESPONSE_INIT, signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
|
||||||
|
import { TranslateService } from '@ngx-translate/core';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
import { SeoService } from '../../core/services/seo.service';
|
||||||
|
import { LanguageService } from '../../core/services/language.service';
|
||||||
|
import {
|
||||||
|
ShopCategoryDetail,
|
||||||
|
ShopCategoryTree,
|
||||||
|
ShopProductCatalogResponse,
|
||||||
|
ShopService,
|
||||||
|
} from './services/shop.service';
|
||||||
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { ShopPageComponent } from './shop-page.component';
|
||||||
|
|
||||||
|
describe('ShopPageComponent', () => {
|
||||||
|
function buildCategory(
|
||||||
|
overrides: Partial<ShopCategoryDetail> = {},
|
||||||
|
): ShopCategoryDetail {
|
||||||
|
return {
|
||||||
|
id: 'cat-1',
|
||||||
|
slug: 'compatible-with-garmin',
|
||||||
|
name: 'Compatible with Garmin',
|
||||||
|
description: 'Accessories compatible with Garmin devices.',
|
||||||
|
seoTitle: null,
|
||||||
|
seoDescription: null,
|
||||||
|
ogTitle: null,
|
||||||
|
ogDescription: null,
|
||||||
|
indexable: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
productCount: 3,
|
||||||
|
breadcrumbs: [],
|
||||||
|
primaryImage: null,
|
||||||
|
images: [],
|
||||||
|
children: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCatalog(
|
||||||
|
overrides: Partial<ShopProductCatalogResponse> = {},
|
||||||
|
): ShopProductCatalogResponse {
|
||||||
|
return {
|
||||||
|
categorySlug: null,
|
||||||
|
featuredOnly: null,
|
||||||
|
category: null,
|
||||||
|
products: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createComponent(routerUrl = '/de/shop') {
|
||||||
|
const responseInit: { status?: number } = {};
|
||||||
|
const seoService = jasmine.createSpyObj<SeoService>('SeoService', [
|
||||||
|
'applyResolvedSeo',
|
||||||
|
'applyPageSeo',
|
||||||
|
]);
|
||||||
|
const translate = jasmine.createSpyObj<TranslateService>(
|
||||||
|
'TranslateService',
|
||||||
|
['instant'],
|
||||||
|
);
|
||||||
|
translate.instant.and.callFake(
|
||||||
|
(key: string, params?: { count?: number }) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'SHOP.TITLE': 'Technische Lösungen',
|
||||||
|
'SHOP.SUBTITLE': 'Fertige Produkte, die praktische Probleme lösen',
|
||||||
|
'SHOP.CATALOG_TITLE': 'Alle Produkte',
|
||||||
|
'SHOP.CATALOG_LABEL': 'Katalog',
|
||||||
|
'SHOP.SELECTED_CATEGORY': 'Ausgewählte Kategorie',
|
||||||
|
'SHOP.CATALOG_META_DESCRIPTION':
|
||||||
|
'Entdecken Sie 3D-gedruckte Produkte und technisches Zubehör.',
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_TITLE': 'Shop-Kategorie | 3D fab',
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION':
|
||||||
|
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||||
|
};
|
||||||
|
if (key === 'SHOP.CATEGORY_META') {
|
||||||
|
return `${params?.count ?? 0} Produkte in dieser Kategorie verfügbar`;
|
||||||
|
}
|
||||||
|
return translations[key] ?? key;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLang = signal<'it' | 'en' | 'de' | 'fr'>('de');
|
||||||
|
const languageService = {
|
||||||
|
currentLang,
|
||||||
|
selectedLang: () => currentLang(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const shopService = {
|
||||||
|
cart: signal(null),
|
||||||
|
cartLoading: signal(false),
|
||||||
|
cartLoaded: signal(false),
|
||||||
|
cartItemCount: signal(0),
|
||||||
|
cartSessionId: signal<string | null>(null),
|
||||||
|
getCategories: jasmine
|
||||||
|
.createSpy('getCategories')
|
||||||
|
.and.returnValue(of([] as ShopCategoryTree[])),
|
||||||
|
getProductCatalog: jasmine
|
||||||
|
.createSpy('getProductCatalog')
|
||||||
|
.and.returnValue(of(buildCatalog())),
|
||||||
|
flattenCategoryTree: jasmine
|
||||||
|
.createSpy('flattenCategoryTree')
|
||||||
|
.and.returnValue([]),
|
||||||
|
quantityForProduct: jasmine
|
||||||
|
.createSpy('quantityForProduct')
|
||||||
|
.and.returnValue(0),
|
||||||
|
loadCart: jasmine.createSpy('loadCart').and.returnValue(of(null)),
|
||||||
|
clearCart: jasmine.createSpy('clearCart').and.returnValue(of(null)),
|
||||||
|
removeCartItem: jasmine
|
||||||
|
.createSpy('removeCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
|
updateCartItem: jasmine
|
||||||
|
.createSpy('updateCartItem')
|
||||||
|
.and.returnValue(of(null)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const router = {
|
||||||
|
url: routerUrl,
|
||||||
|
navigate: jasmine.createSpy('navigate'),
|
||||||
|
} as unknown as Router;
|
||||||
|
|
||||||
|
const activatedRoute = {
|
||||||
|
paramMap: of(convertToParamMap({})),
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({}),
|
||||||
|
},
|
||||||
|
} as unknown as ActivatedRoute;
|
||||||
|
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [ShopPageComponent],
|
||||||
|
providers: [
|
||||||
|
{ provide: SeoService, useValue: seoService },
|
||||||
|
{ provide: TranslateService, useValue: translate },
|
||||||
|
{ provide: LanguageService, useValue: languageService },
|
||||||
|
{ provide: ShopService, useValue: shopService },
|
||||||
|
{
|
||||||
|
provide: ShopRouteService,
|
||||||
|
useValue: jasmine.createSpyObj<ShopRouteService>('ShopRouteService', [
|
||||||
|
'shopRootCommands',
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{ provide: Router, useValue: router },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRoute },
|
||||||
|
{ provide: RESPONSE_INIT, useValue: responseInit },
|
||||||
|
{ provide: PLATFORM_ID, useValue: 'server' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture: ComponentFixture<ShopPageComponent> =
|
||||||
|
TestBed.createComponent(ShopPageComponent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
component: fixture.componentInstance,
|
||||||
|
seoService,
|
||||||
|
responseInit,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps index follow on the public shop root', () => {
|
||||||
|
const { component, seoService } = createComponent();
|
||||||
|
|
||||||
|
(component as any).applyDefaultSeo();
|
||||||
|
|
||||||
|
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Technische Lösungen | 3D fab',
|
||||||
|
robots: 'index, follow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps noindex for categories explicitly marked as non-indexable', () => {
|
||||||
|
const { component, seoService } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
(component as any).applySeo(buildCategory({ indexable: false }));
|
||||||
|
|
||||||
|
expect(seoService.applyPageSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses a soft SSR fallback for non-404 category load errors', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 500 }),
|
||||||
|
).toBeTrue();
|
||||||
|
(component as any).setResponseStatus(200);
|
||||||
|
(component as any).applySoftFallbackSeo('compatible-with-garmin');
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(200);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
title: 'Compatible With Garmin | Technische Lösungen | 3D fab',
|
||||||
|
description:
|
||||||
|
'Entdecken Sie Produkte dieser Kategorie und technische Lösungen.',
|
||||||
|
robots: 'index, follow',
|
||||||
|
canonicalPath: '/de/shop/compatible-with-garmin',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps hard 404 noindex behavior for missing categories', () => {
|
||||||
|
const { component, seoService, responseInit } = createComponent(
|
||||||
|
'/de/shop/compatible-with-garmin',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(component as any).shouldUseSoftSeoFallback({ status: 404 }),
|
||||||
|
).toBeFalse();
|
||||||
|
(component as any).setResponseStatus(404);
|
||||||
|
(component as any).applyHardErrorSeo();
|
||||||
|
|
||||||
|
expect(responseInit.status).toBe(404);
|
||||||
|
expect(seoService.applyResolvedSeo).toHaveBeenCalledWith(
|
||||||
|
jasmine.objectContaining({
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule, isPlatformBrowser } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
|
PLATFORM_ID,
|
||||||
|
RESPONSE_INIT,
|
||||||
|
afterNextRender,
|
||||||
Component,
|
Component,
|
||||||
DestroyRef,
|
DestroyRef,
|
||||||
Injector,
|
Injector,
|
||||||
computed,
|
computed,
|
||||||
inject,
|
inject,
|
||||||
input,
|
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
|
||||||
import { Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
combineLatest,
|
combineLatest,
|
||||||
|
distinctUntilChanged,
|
||||||
finalize,
|
finalize,
|
||||||
forkJoin,
|
forkJoin,
|
||||||
|
map,
|
||||||
of,
|
of,
|
||||||
switchMap,
|
switchMap,
|
||||||
tap,
|
tap,
|
||||||
@@ -38,6 +42,7 @@ import {
|
|||||||
ShopService,
|
ShopService,
|
||||||
} from './services/shop.service';
|
} from './services/shop.service';
|
||||||
import { ShopRouteService } from './services/shop-route.service';
|
import { ShopRouteService } from './services/shop-route.service';
|
||||||
|
import { humanizeShopSlug } from './shop-seo-fallback';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-shop-page',
|
selector: 'app-shop-page',
|
||||||
@@ -56,16 +61,23 @@ import { ShopRouteService } from './services/shop-route.service';
|
|||||||
export class ShopPageComponent {
|
export class ShopPageComponent {
|
||||||
private readonly destroyRef = inject(DestroyRef);
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
private readonly injector = inject(Injector);
|
private readonly injector = inject(Injector);
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
private readonly translate = inject(TranslateService);
|
private readonly translate = inject(TranslateService);
|
||||||
private readonly seoService = inject(SeoService);
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||||
|
private readonly responseInit = inject(RESPONSE_INIT, { optional: true });
|
||||||
readonly languageService = inject(LanguageService);
|
readonly languageService = inject(LanguageService);
|
||||||
private readonly shopRouteService = inject(ShopRouteService);
|
private readonly shopRouteService = inject(ShopRouteService);
|
||||||
readonly shopService = inject(ShopService);
|
readonly shopService = inject(ShopService);
|
||||||
|
|
||||||
readonly categorySlug = input<string | undefined>();
|
readonly routeCategorySlug = signal<string | null>(
|
||||||
|
this.readRouteParam('categorySlug'),
|
||||||
|
);
|
||||||
|
|
||||||
readonly loading = signal(true);
|
readonly loading = signal(true);
|
||||||
|
readonly softFallbackActive = signal(false);
|
||||||
|
readonly softFallbackCategoryLabel = signal<string | null>(null);
|
||||||
readonly error = signal<string | null>(null);
|
readonly error = signal<string | null>(null);
|
||||||
readonly categories = signal<ShopCategoryTree[]>([]);
|
readonly categories = signal<ShopCategoryTree[]>([]);
|
||||||
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
readonly categoryNodes = signal<ShopCategoryNavNode[]>([]);
|
||||||
@@ -79,7 +91,7 @@ export class ShopPageComponent {
|
|||||||
readonly cartLoading = this.shopService.cartLoading;
|
readonly cartLoading = this.shopService.cartLoading;
|
||||||
readonly cartItemCount = this.shopService.cartItemCount;
|
readonly cartItemCount = this.shopService.cartItemCount;
|
||||||
readonly currentCategorySlug = computed(
|
readonly currentCategorySlug = computed(
|
||||||
() => this.selectedCategory()?.slug ?? this.categorySlug() ?? null,
|
() => this.selectedCategory()?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
);
|
);
|
||||||
readonly cartItems = computed(() =>
|
readonly cartItems = computed(() =>
|
||||||
(this.cart()?.items ?? []).filter(
|
(this.cart()?.items ?? []).filter(
|
||||||
@@ -87,49 +99,103 @@ export class ShopPageComponent {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
readonly cartHasItems = computed(() => this.cartItems().length > 0);
|
readonly cartHasItems = computed(() => this.cartItems().length > 0);
|
||||||
|
readonly heroSubtitle = computed(() => {
|
||||||
|
this.languageService.currentLang();
|
||||||
|
|
||||||
constructor() {
|
const category = this.selectedCategory();
|
||||||
if (!this.shopService.cartLoaded()) {
|
if (category) {
|
||||||
this.shopService
|
return (
|
||||||
.loadCart()
|
category.description ||
|
||||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
this.translate.instant('SHOP.CATEGORY_META', {
|
||||||
.subscribe({
|
count: category.productCount || 0,
|
||||||
error: () => {
|
})
|
||||||
this.shopService.cart.set(null);
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
this.scheduleCartWarmup();
|
||||||
|
});
|
||||||
|
|
||||||
combineLatest([
|
combineLatest([
|
||||||
toObservable(this.categorySlug, { injector: this.injector }),
|
this.route.paramMap.pipe(
|
||||||
|
map((params) => this.normalizeRouteParam(params.get('categorySlug'))),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
),
|
||||||
toObservable(this.languageService.currentLang, {
|
toObservable(this.languageService.currentLang, {
|
||||||
injector: this.injector,
|
injector: this.injector,
|
||||||
}),
|
}).pipe(distinctUntilChanged()),
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
tap(() => {
|
tap(() => {
|
||||||
this.loading.set(true);
|
this.loading.set(true);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
|
this.softFallbackCategoryLabel.set(null);
|
||||||
this.error.set(null);
|
this.error.set(null);
|
||||||
}),
|
}),
|
||||||
switchMap(([categorySlug]) =>
|
switchMap(([categorySlug]) => {
|
||||||
forkJoin({
|
this.routeCategorySlug.set(categorySlug);
|
||||||
|
return forkJoin({
|
||||||
categories: this.shopService.getCategories(),
|
categories: this.shopService.getCategories(),
|
||||||
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
catalog: this.shopService.getProductCatalog(categorySlug ?? null),
|
||||||
}).pipe(
|
}).pipe(
|
||||||
catchError((error) => {
|
catchError((error) => {
|
||||||
|
const isNotFound = error?.status === 404;
|
||||||
this.categories.set([]);
|
this.categories.set([]);
|
||||||
this.categoryNodes.set([]);
|
this.categoryNodes.set([]);
|
||||||
this.selectedCategory.set(null);
|
this.selectedCategory.set(null);
|
||||||
this.products.set([]);
|
this.products.set([]);
|
||||||
this.error.set(
|
if (isNotFound) {
|
||||||
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
|
this.error.set('SHOP.NOT_FOUND');
|
||||||
);
|
this.setResponseStatus(404);
|
||||||
this.applyDefaultSeo();
|
this.applyHardErrorSeo();
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shouldUseSoftSeoFallback(error)) {
|
||||||
|
this.error.set(null);
|
||||||
|
this.softFallbackActive.set(true);
|
||||||
|
this.softFallbackCategoryLabel.set(
|
||||||
|
categorySlug ? humanizeShopSlug(categorySlug) : null,
|
||||||
|
);
|
||||||
|
this.setResponseStatus(200);
|
||||||
|
this.applySoftFallbackSeo(categorySlug);
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error.set('SHOP.LOAD_ERROR');
|
||||||
|
this.setResponseStatus(503);
|
||||||
return of(null);
|
return of(null);
|
||||||
}),
|
}),
|
||||||
finalize(() => this.loading.set(false)),
|
finalize(() => this.loading.set(false)),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
takeUntilDestroyed(this.destroyRef),
|
takeUntilDestroyed(this.destroyRef),
|
||||||
)
|
)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -141,12 +207,54 @@ export class ShopPageComponent {
|
|||||||
this.categoryNodes.set(
|
this.categoryNodes.set(
|
||||||
this.shopService.flattenCategoryTree(
|
this.shopService.flattenCategoryTree(
|
||||||
result.categories,
|
result.categories,
|
||||||
result.catalog.category?.slug ?? this.categorySlug() ?? null,
|
result.catalog.category?.slug ?? this.routeCategorySlug() ?? null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
this.selectedCategory.set(result.catalog.category ?? null);
|
this.selectedCategory.set(result.catalog.category ?? null);
|
||||||
this.products.set(result.catalog.products);
|
this.products.set(result.catalog.products);
|
||||||
|
this.softFallbackActive.set(false);
|
||||||
|
this.softFallbackCategoryLabel.set(null);
|
||||||
this.applySeo(result.catalog.category ?? null);
|
this.applySeo(result.catalog.category ?? null);
|
||||||
|
this.restoreCatalogScrollIfNeeded();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleCartWarmup(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
this.loadCartIfNeeded();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const warmup = () => this.loadCartIfNeeded();
|
||||||
|
const idleCallback = (
|
||||||
|
window as Window & {
|
||||||
|
requestIdleCallback?: (
|
||||||
|
callback: IdleRequestCallback,
|
||||||
|
options?: IdleRequestOptions,
|
||||||
|
) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
|
|
||||||
|
if (typeof idleCallback === 'function') {
|
||||||
|
idleCallback(() => warmup(), { timeout: 1500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(warmup, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadCartIfNeeded(): void {
|
||||||
|
if (this.shopService.cartLoaded() || this.shopService.cartLoading()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shopService
|
||||||
|
.loadCart()
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
error: () => {
|
||||||
|
this.shopService.cart.set(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,4 +428,108 @@ export class ShopPageComponent {
|
|||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private applyHardErrorSeo(): void {
|
||||||
|
const title = `${this.translate.instant('SHOP.TITLE')} | 3D fab`;
|
||||||
|
const description = this.translate.instant('SHOP.CATALOG_META_DESCRIPTION');
|
||||||
|
|
||||||
|
this.seoService.applyResolvedSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots: 'noindex, nofollow',
|
||||||
|
ogTitle: title,
|
||||||
|
ogDescription: description,
|
||||||
|
canonicalPath: null,
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySoftFallbackSeo(categorySlug: string | null): void {
|
||||||
|
if (!categorySlug) {
|
||||||
|
this.applyDefaultSeo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = this.buildSoftFallbackCategoryTitle(categorySlug);
|
||||||
|
const description = this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_DESCRIPTION',
|
||||||
|
this.translate.instant('SHOP.CATALOG_META_DESCRIPTION'),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.seoService.applyResolvedSeo({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
robots: 'index, follow',
|
||||||
|
ogTitle: title,
|
||||||
|
ogDescription: description,
|
||||||
|
canonicalPath: this.currentPath(),
|
||||||
|
alternates: null,
|
||||||
|
xDefault: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldUseSoftSeoFallback(error: { status?: number } | null): boolean {
|
||||||
|
return !this.isBrowser && error?.status !== 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSoftFallbackCategoryTitle(categorySlug: string): string {
|
||||||
|
const shopTitle = this.translate.instant('SHOP.TITLE');
|
||||||
|
const humanized = humanizeShopSlug(categorySlug);
|
||||||
|
if (humanized) {
|
||||||
|
return `${humanized} | ${shopTitle} | 3D fab`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.resolveTranslatedText(
|
||||||
|
'SEO.ROUTES.SHOP.CATEGORY_TITLE',
|
||||||
|
`${shopTitle} | 3D fab`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveTranslatedText(key: string, fallback: string): string {
|
||||||
|
const translated = this.translate.instant(key);
|
||||||
|
return typeof translated === 'string' && translated !== key
|
||||||
|
? translated
|
||||||
|
: fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private currentPath(): string {
|
||||||
|
const path = String(this.router.url ?? '/').split(/[?#]/, 1)[0] || '/';
|
||||||
|
return path.startsWith('/') ? path : `/${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setResponseStatus(status: number): void {
|
||||||
|
if (this.responseInit) {
|
||||||
|
this.responseInit.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreCatalogScrollIfNeeded(): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollY = Number(history.state?.shopRestoreScrollY);
|
||||||
|
if (!Number.isFinite(scrollY) || scrollY < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { shopRestoreScrollY: _ignored, ...nextState } = history.state ?? {};
|
||||||
|
const restore = () => window.scrollTo({ left: 0, top: scrollY });
|
||||||
|
|
||||||
|
history.replaceState(nextState, '');
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
restore();
|
||||||
|
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
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { TranslateModule } from '@ngx-translate/core';
|
|||||||
export class AppDropzoneComponent {
|
export class AppDropzoneComponent {
|
||||||
label = input<string>('DROPZONE.DEFAULT_LABEL');
|
label = input<string>('DROPZONE.DEFAULT_LABEL');
|
||||||
subtext = input<string>('DROPZONE.DEFAULT_SUBTEXT');
|
subtext = input<string>('DROPZONE.DEFAULT_SUBTEXT');
|
||||||
accept = input<string>('.stl,.3mf,.step,.stp');
|
accept = input<string>('.stl,.3mf');
|
||||||
multiple = input<boolean>(true);
|
multiple = input<boolean>(true);
|
||||||
|
|
||||||
filesDropped = output<File[]>();
|
filesDropped = output<File[]>();
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<div
|
||||||
|
class="brand-animation"
|
||||||
|
[attr.data-variant]="variant()"
|
||||||
|
role="img"
|
||||||
|
[attr.aria-hidden]="decorative() ? 'true' : null"
|
||||||
|
[attr.aria-label]="decorative() ? null : ariaLabel()"
|
||||||
|
>
|
||||||
|
@for (letter of resolvedLetters(); track letter.key) {
|
||||||
|
<img
|
||||||
|
class="brand-animation__letter"
|
||||||
|
[src]="letter.src"
|
||||||
|
alt=""
|
||||||
|
[attr.data-letter]="letter.key"
|
||||||
|
[style.--word-x]="letter.wordX"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-animation {
|
||||||
|
--three-anchor-x: -9.4rem;
|
||||||
|
--bee-anchor-x: 10.2rem;
|
||||||
|
--word-scale: var(--brand-animation-scale, 1);
|
||||||
|
--word-spacing-factor: var(--brand-animation-word-spacing, 1);
|
||||||
|
--loader-group-scale-x: 0.94;
|
||||||
|
--loader-group-scale-y: 1.05;
|
||||||
|
--loader-exit-shift: 1.6rem;
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, var(--brand-animation-width, 26rem));
|
||||||
|
height: var(--brand-animation-height, 8rem);
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-animation__letter {
|
||||||
|
--word-x: 0rem;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: var(--brand-animation-letter-width, clamp(2.7rem, 6vw, 4rem));
|
||||||
|
height: auto;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transform-origin: center center;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-animation[data-variant="site-intro"] .brand-animation__letter {
|
||||||
|
animation: site-intro-preview var(--brand-animation-site-intro-duration, 1s)
|
||||||
|
linear 1 forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-animation[data-variant="calculator-loader"] .brand-animation__letter {
|
||||||
|
animation: calculator-loader-loop
|
||||||
|
var(--brand-animation-loader-loop-duration, 2.65s) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes site-intro-preview {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) translateX(0) scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
transform: translate(-50%, -50%) translateX(0) scale(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
80% {
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
|
||||||
|
)
|
||||||
|
scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
|
||||||
|
)
|
||||||
|
scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes calculator-loader-loop {
|
||||||
|
0%,
|
||||||
|
5% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(
|
||||||
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(
|
||||||
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
|
12% {
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 0.82, 0.28, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
38%,
|
||||||
|
56% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
|
||||||
|
)
|
||||||
|
scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
56% {
|
||||||
|
animation-timing-function: cubic-bezier(0.38, 0, 0.72, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
82%,
|
||||||
|
88% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(
|
||||||
|
var(--bee-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
|
}
|
||||||
|
|
||||||
|
88% {
|
||||||
|
animation-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
94% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(
|
||||||
|
(var(--bee-anchor-x) + var(--loader-exit-shift)) * var(--word-scale) *
|
||||||
|
var(--word-spacing-factor)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scaleX(0.98) scaleY(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
94.01%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(
|
||||||
|
var(--three-anchor-x) * var(--word-scale) * var(--word-spacing-factor)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
scaleX(var(--loader-group-scale-x)) scaleY(var(--loader-group-scale-y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.brand-animation {
|
||||||
|
width: min(100%, var(--brand-animation-width-mobile, 19rem));
|
||||||
|
height: var(--brand-animation-height-mobile, 6rem);
|
||||||
|
--word-scale: var(--brand-animation-scale-mobile, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-animation__letter {
|
||||||
|
width: var(--brand-animation-letter-width-mobile, 2.8rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.brand-animation__letter {
|
||||||
|
animation: none !important;
|
||||||
|
transform: translate(-50%, -50%)
|
||||||
|
translateX(
|
||||||
|
calc(var(--word-x) * var(--word-scale) * var(--word-spacing-factor))
|
||||||
|
)
|
||||||
|
scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Component, computed, input } from '@angular/core';
|
||||||
|
|
||||||
|
export type BrandAnimationVariant = 'site-intro' | 'calculator-loader';
|
||||||
|
|
||||||
|
interface AnimationLetter {
|
||||||
|
key: string;
|
||||||
|
darkSrc: string;
|
||||||
|
yellowSrc: string;
|
||||||
|
wordX: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedAnimationLetter {
|
||||||
|
key: string;
|
||||||
|
src: string;
|
||||||
|
wordX: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LETTERS: readonly AnimationLetter[] = [
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
darkSrc: '/assets/images/animation/31200.svg',
|
||||||
|
yellowSrc: '/assets/images/animation/3g1200.svg',
|
||||||
|
wordX: '-9.4rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'd',
|
||||||
|
darkSrc: '/assets/images/animation/d1200.svg',
|
||||||
|
yellowSrc: '/assets/images/animation/Dg1200.svg',
|
||||||
|
wordX: '-4.9rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'F',
|
||||||
|
darkSrc: '/assets/images/animation/F1200.svg',
|
||||||
|
yellowSrc: '/assets/images/animation/Fg1200.svg',
|
||||||
|
wordX: '1rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'A',
|
||||||
|
darkSrc: '/assets/images/animation/A1200.svg',
|
||||||
|
yellowSrc: '/assets/images/animation/Ag1200.svg',
|
||||||
|
wordX: '5.6rem',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'B',
|
||||||
|
darkSrc: '/assets/images/animation/B1200.svg',
|
||||||
|
yellowSrc: '/assets/images/animation/Bg1200.svg',
|
||||||
|
wordX: '10.2rem',
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-brand-animation-logo',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './brand-animation-logo.component.html',
|
||||||
|
styleUrl: './brand-animation-logo.component.scss',
|
||||||
|
})
|
||||||
|
export class BrandAnimationLogoComponent {
|
||||||
|
readonly variant = input<BrandAnimationVariant>('site-intro');
|
||||||
|
readonly decorative = input(true);
|
||||||
|
readonly ariaLabel = input('3D fab animated logo');
|
||||||
|
|
||||||
|
readonly resolvedLetters = computed<ResolvedAnimationLetter[]>(() =>
|
||||||
|
LETTERS.map((letter) => ({
|
||||||
|
key: letter.key,
|
||||||
|
src: this.variant() === 'site-intro' ? letter.yellowSrc : letter.darkSrc,
|
||||||
|
wordX: letter.wordX,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,12 +53,12 @@
|
|||||||
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
|
"TITLE": "3D-Druck-Angebotsrechner | 3D fab",
|
||||||
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
|
"DESCRIPTION": "Laden Sie Ihre 3D-Datei hoch und erhalten Sie Preis und Lieferzeit in Sekunden mit echtem Slicing.",
|
||||||
"BASIC": {
|
"BASIC": {
|
||||||
"TITLE": "Einfacher 3D-Druck-Rechner | 3D fab",
|
"TITLE": "Schnelles 3D-Druck-Angebot fuer fertige Dateien | 3D fab",
|
||||||
"DESCRIPTION": "Berechnen Sie den Preis Ihres 3D-Drucks schnell mit dem Basis-Workflow."
|
"DESCRIPTION": "Laden Sie eine fertige STL- oder 3MF-Datei hoch und erhalten Sie in Sekunden einen passenden 3D-Druck-Preis mit dem Basis-Rechner."
|
||||||
},
|
},
|
||||||
"ADVANCED": {
|
"ADVANCED": {
|
||||||
"TITLE": "Erweiterter 3D-Druck-Rechner | 3D fab",
|
"TITLE": "Praezises 3D-Druck-Angebot mit Material und Einstellungen | 3D fab",
|
||||||
"DESCRIPTION": "Konfigurieren Sie erweiterte Druckparameter und erhalten Sie ein präzises Angebot mit echtem Slicing."
|
"DESCRIPTION": "Waehlen Sie Material, Schichthoehe, Stuetzstrukturen und Infill fuer ein genaueres 3D-Druck-Angebot."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
@@ -107,14 +107,87 @@
|
|||||||
},
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
"TITLE": "3D-Angebot berechnen",
|
"TITLE": "3D-Angebot berechnen",
|
||||||
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF, STEP) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
|
"SUBTITLE": "Laden Sie Ihre 3D-Datei (STL, 3MF) hoch, stellen Sie Qualität und Farbe ein und berechnen Sie sofort Preis und Lieferzeit.",
|
||||||
|
"EYEBROW": "3D-Druck-Angebot online",
|
||||||
"CTA_START": "Jetzt starten",
|
"CTA_START": "Jetzt starten",
|
||||||
"BUSINESS": "Unternehmen",
|
"BUSINESS": "Unternehmen",
|
||||||
"PRIVATE": "Privat",
|
"PRIVATE": "Privat",
|
||||||
"MODE_EASY": "Basis",
|
"MODE_EASY": "Basis",
|
||||||
"MODE_ADVANCED": "Erweitert",
|
"MODE_ADVANCED": "Erweitert",
|
||||||
|
"WHEN_TO_USE_LABEL": "Wann verwenden:",
|
||||||
|
"MODES": {
|
||||||
|
"BASIC": {
|
||||||
|
"TOGGLE_HINT": "Fuer fertige Dateien",
|
||||||
|
"TITLE": "Schnelles Angebot fuer fertige 3D-Dateien",
|
||||||
|
"SUBTITLE": "Der Basis-Modus ist fuer Nutzer gedacht, die in Sekunden einen passenden Preis erhalten wollen, ohne technische Druckparameter im Detail zu verwalten.",
|
||||||
|
"UPLOAD_HELP": "Laden Sie eine bereits exportierte STL- oder 3MF-Datei hoch, waehlen Sie Material und Qualitaet und erhalten Sie sofort eine klare Preis- und Zeitabschaetzung.",
|
||||||
|
"WHEN_TO_USE": "Verwenden Sie Basis, wenn Ihr Modell fertig ist und Sie vor allem ein schnelles, einfaches Angebot brauchen."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TOGGLE_HINT": "Mehr Kontrolle ueber Druck und Material",
|
||||||
|
"TITLE": "Genaueres Angebot mit Material und Einstellungen",
|
||||||
|
"SUBTITLE": "Der erweiterte Modus ist fuer Nutzer gedacht, die durch relevante Druckeinstellungen eine genauere Schaetzung erhalten wollen.",
|
||||||
|
"UPLOAD_HELP": "Laden Sie Ihre 3D-Datei hoch und stellen Sie Material, Schichthoehe, Stuetzstrukturen, Infill und Duese ein, um naeher an die spaetere Druckkonfiguration zu kommen.",
|
||||||
|
"WHEN_TO_USE": "Verwenden Sie Erweitert, wenn Sie Materialien oder Einstellungen vergleichen und mehr Kontrolle ueber das Ergebnis wollen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"COMPARE_KICKER": "Schnelle Auswahl",
|
||||||
|
"COMPARE_TITLE": "Basis oder Erweitert?",
|
||||||
|
"COMPARE_SUBTITLE": "Die beiden Seiten sind fuer unterschiedliche Anwendungsfaelle gedacht. Starten Sie mit Basis fuer Geschwindigkeit und wechseln Sie zu Erweitert, wenn Material oder Druckeinstellungen wichtig werden.",
|
||||||
|
"COMPARE": {
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Basis: schnelles Angebot",
|
||||||
|
"TEXT": "Ideal, wenn Sie bereits eine fertige Datei haben und schnell einen passenden Preis moechten, ohne technische Druckdetails einzustellen."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Erweitert: genaueres Angebot",
|
||||||
|
"TEXT": "Ideal, wenn Sie Material, Schichthoehe, Stuetzstrukturen, Infill und weitere Einstellungen waehlen moechten, die das Endergebnis staerker beeinflussen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MODEL_SOURCES": {
|
||||||
|
"KICKER": "3D-Modelle",
|
||||||
|
"TITLE": "Wo Sie 3D-Modelle zum Hochladen finden",
|
||||||
|
"TEXT": "Wenn Sie noch keine Datei haben, koennen Sie auf diesen Plattformen starten. Pruefen Sie vor dem Hochladen immer Lizenz, Abmessungen und Modellqualitaet.",
|
||||||
|
"FAVORITES_TITLE": "Unsere Favoriten",
|
||||||
|
"OTHERS_TITLE": "Weitere",
|
||||||
|
"ITEMS": {
|
||||||
|
"PRINTABLES": "Grosses Verzeichnis druckbarer Modelle aus der Maker-Community.",
|
||||||
|
"MAKERWORLD": "Repository mit druckfertigen Modellen, besonders nuetzlich fuer Maker-Projekte.",
|
||||||
|
"THINGIVERSE": "Langjaehriges Archiv mit sehr vielen kostenlosen Modellen in verschiedenen Kategorien.",
|
||||||
|
"THANGS": "3D-Suchmaschine und Repository mit verwandten Versionen und Sammlungen.",
|
||||||
|
"CULTS3D": "Marktplatz mit kostenlosen und kostenpflichtigen Modellen fuer viele Einsatzzwecke.",
|
||||||
|
"YEGGI": "Suchmaschine, die 3D-Modelle aus verschiedenen Seiten zusammenfuehrt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FAQ": {
|
||||||
|
"KICKER": "Hauefige Fragen",
|
||||||
|
"TITLE": "Mini-FAQ",
|
||||||
|
"SUBTITLE": "Kurze Antworten auf die Fragen, die nicht-technische Nutzer am haeufigsten ausbremsen.",
|
||||||
|
"ITEMS": {
|
||||||
|
"FILES": {
|
||||||
|
"Q": "Welche Dateien kann ich hochladen?",
|
||||||
|
"A": "Sie koennen STL- oder 3MF-Dateien hochladen. Wenn Sie ein Modell von einer externen Plattform herunterladen, stellen Sie sicher, dass es sich wirklich um eine 3D-Datei und nicht nur um Bilder oder Dokumentation handelt."
|
||||||
|
},
|
||||||
|
"MODE": {
|
||||||
|
"Q": "Wie waehle ich zwischen Basis und Erweitert?",
|
||||||
|
"A": "Waehlen Sie Basis fuer ein schnelles Angebot zu einer fertigen Datei. Waehlen Sie Erweitert, wenn Sie Materialien vergleichen oder Einstellungen aendern moechten, die Zeit, Gewicht und Preis beeinflussen."
|
||||||
|
},
|
||||||
|
"NO_MODEL": {
|
||||||
|
"Q": "Was, wenn ich noch kein 3D-Modell habe?",
|
||||||
|
"A": "Sie koennen auf den oben genannten Plattformen suchen oder uns kontaktieren, wenn Sie Hilfe bei CAD-Konstruktion oder Anpassungen brauchen."
|
||||||
|
},
|
||||||
|
"PRICE": {
|
||||||
|
"Q": "Ist der angezeigte Preis schon verlaesslich?",
|
||||||
|
"A": "Es handelt sich um eine automatische Schaetzung auf Basis Ihrer Datei und der gewaehlten Einstellungen. Der erweiterte Modus ist genauer, weil mehr Druckparameter beruecksichtigt werden."
|
||||||
|
},
|
||||||
|
"BEFORE_UPLOAD": {
|
||||||
|
"Q": "Was sollte ich vor dem Hochladen pruefen?",
|
||||||
|
"A": "Pruefen Sie, ob das Modell den richtigen Massstab hat, die Datei nicht beschaedigt ist und die Geometrie wirklich dem finalen Bauteil entspricht, das Sie drucken moechten."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher",
|
"UPLOAD_LABEL": "Ziehen Sie Ihre 3D-Datei hierher",
|
||||||
"UPLOAD_SUB": "Wir unterstützen STL, 3MF, STEP bis 50MB",
|
"UPLOAD_SUB": "Wir unterstützen STL, 3MF bis 50MB",
|
||||||
"MATERIAL": "Material",
|
"MATERIAL": "Material",
|
||||||
"QUALITY": "Qualität",
|
"QUALITY": "Qualität",
|
||||||
"QUANTITY": "Menge",
|
"QUANTITY": "Menge",
|
||||||
@@ -141,11 +214,12 @@
|
|||||||
"BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle",
|
"BENEFITS_2": "Ausgewählte Materialien und Qualitätskontrolle",
|
||||||
"BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt",
|
"BENEFITS_3": "CAD-Beratung, falls die Datei Änderungen benötigt",
|
||||||
"ERR_FILE_REQUIRED": "Die Datei ist erforderlich.",
|
"ERR_FILE_REQUIRED": "Die Datei ist erforderlich.",
|
||||||
"STEP_WARNING": "Die 3D-Ansicht ist mit STEP- und 3MF-Dateien nicht kompatibel",
|
"STEP_WARNING": "Die 3D-Vorschau ist nur für STL-Dateien verfügbar.",
|
||||||
"REMOVE_FILE": "Datei entfernen",
|
"REMOVE_FILE": "Datei entfernen",
|
||||||
"FALLBACK_MATERIAL": "PLA (Fallback)",
|
"FALLBACK_MATERIAL": "PLA (Fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.",
|
"ERR_FILE_TOO_LARGE": "Einige Dateien überschreiten das 200MB-Limit und wurden nicht hinzugefügt.",
|
||||||
|
"ERR_INVALID_FILE_TYPE": "Sie können nur Dateien vom Typ .stl oder .3mf hochladen.",
|
||||||
"PRINT_SPEED": "Druckgeschwindigkeit",
|
"PRINT_SPEED": "Druckgeschwindigkeit",
|
||||||
"COLOR": "Farbe",
|
"COLOR": "Farbe",
|
||||||
"ANALYZING_TITLE": "Analyse läuft...",
|
"ANALYZING_TITLE": "Analyse läuft...",
|
||||||
@@ -612,11 +686,11 @@
|
|||||||
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
|
||||||
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
|
||||||
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Mit Sitz in der Schweiz",
|
||||||
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zürich",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
|
||||||
"BTN_CALCULATE": "Angebot berechnen",
|
"BTN_CALCULATE": "Angebot berechnen",
|
||||||
@@ -624,7 +698,7 @@
|
|||||||
"BTN_CONTACT": "Mit uns sprechen",
|
"BTN_CONTACT": "Mit uns sprechen",
|
||||||
"SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden",
|
"SEC_CALC_TITLE": "Korrekte Preisberechnung in wenigen Sekunden",
|
||||||
"SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.",
|
"SEC_CALC_SUBTITLE": "Keine Registrierung erforderlich. Die Schätzung basiert auf echtem Slicing.",
|
||||||
"SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF, STEP",
|
"SEC_CALC_LIST_1": "Unterstützte Formate: STL, 3MF",
|
||||||
"CARD_CALC_EYEBROW": "Automatische Berechnung",
|
"CARD_CALC_EYEBROW": "Automatische Berechnung",
|
||||||
"CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick",
|
"CARD_CALC_TITLE": "Preis und Lieferzeit mit einem Klick",
|
||||||
"CARD_CALC_TAG": "Ohne Registrierung",
|
"CARD_CALC_TAG": "Ohne Registrierung",
|
||||||
@@ -674,7 +748,7 @@
|
|||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
"DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen",
|
"DEFAULT_LABEL": "Dateien hierher ziehen oder klicken zum Hochladen",
|
||||||
"DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF, .STEP"
|
"DEFAULT_SUBTEXT": "Unterstützt .STL, .3MF"
|
||||||
},
|
},
|
||||||
"COLOR": {
|
"COLOR": {
|
||||||
"AVAILABLE_COLORS": "Verfügbare Farben",
|
"AVAILABLE_COLORS": "Verfügbare Farben",
|
||||||
|
|||||||
@@ -53,12 +53,12 @@
|
|||||||
"TITLE": "3D Printing Quote Calculator | 3D fab",
|
"TITLE": "3D Printing Quote Calculator | 3D fab",
|
||||||
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
|
"DESCRIPTION": "Upload your 3D file and get price and lead time in seconds with real slicing.",
|
||||||
"BASIC": {
|
"BASIC": {
|
||||||
"TITLE": "Basic 3D Printing Calculator | 3D fab",
|
"TITLE": "Fast 3D printing quote for ready files | 3D fab",
|
||||||
"DESCRIPTION": "Quickly estimate the price of your 3D print with the basic workflow."
|
"DESCRIPTION": "Upload a ready STL or 3MF file and get a correct 3D printing price in seconds with the basic calculator."
|
||||||
},
|
},
|
||||||
"ADVANCED": {
|
"ADVANCED": {
|
||||||
"TITLE": "Advanced 3D Printing Calculator | 3D fab",
|
"TITLE": "Precise 3D printing quote with materials and settings | 3D fab",
|
||||||
"DESCRIPTION": "Configure advanced print settings and get a precise quote based on real slicing."
|
"DESCRIPTION": "Set material, layer height, supports and infill to get a more precise 3D printing quote."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
@@ -107,14 +107,87 @@
|
|||||||
},
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
"TITLE": "3D Print Calculator",
|
"TITLE": "3D Print Calculator",
|
||||||
"SUBTITLE": "Upload your 3D file (STL, 3MF, STEP...) and get an instant estimate of costs and print time.",
|
"SUBTITLE": "Upload your 3D file (STL, 3MF) and get an instant estimate of costs and print time.",
|
||||||
|
"EYEBROW": "Online 3D printing quote",
|
||||||
"CTA_START": "Start Now",
|
"CTA_START": "Start Now",
|
||||||
"BUSINESS": "Business",
|
"BUSINESS": "Business",
|
||||||
"PRIVATE": "Private",
|
"PRIVATE": "Private",
|
||||||
"MODE_EASY": "Quick",
|
"MODE_EASY": "Quick",
|
||||||
"MODE_ADVANCED": "Advanced",
|
"MODE_ADVANCED": "Advanced",
|
||||||
|
"WHEN_TO_USE_LABEL": "When to use it:",
|
||||||
|
"MODES": {
|
||||||
|
"BASIC": {
|
||||||
|
"TOGGLE_HINT": "For ready-to-print files",
|
||||||
|
"TITLE": "Fast quote for ready 3D files",
|
||||||
|
"SUBTITLE": "Basic mode is for users who want a correct price in seconds without dealing with advanced technical settings.",
|
||||||
|
"UPLOAD_HELP": "Upload an STL or 3MF file that is already exported, choose material and quality, and get a clear price and lead-time estimate right away.",
|
||||||
|
"WHEN_TO_USE": "Use Basic if your model is already final and you mainly need a simple, fast quote."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TOGGLE_HINT": "More control over print and material",
|
||||||
|
"TITLE": "More precise quote with materials and settings",
|
||||||
|
"SUBTITLE": "Advanced mode is for users who want a more precise estimate by adjusting relevant print settings.",
|
||||||
|
"UPLOAD_HELP": "Upload your 3D file and set material, layer height, supports, infill and nozzle size to get closer to the final print configuration.",
|
||||||
|
"WHEN_TO_USE": "Use Advanced if you want to compare materials or settings and need more control over the outcome."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"COMPARE_KICKER": "Quick choice",
|
||||||
|
"COMPARE_TITLE": "Basic or Advanced?",
|
||||||
|
"COMPARE_SUBTITLE": "The two pages cover different use cases. Start with Basic for speed, then switch to Advanced when material or print settings matter.",
|
||||||
|
"COMPARE": {
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Basic: fast quote",
|
||||||
|
"TEXT": "Best if you already have a ready file and want a correct price quickly, without going into technical print details."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Advanced: more precise quote",
|
||||||
|
"TEXT": "Best if you want to choose material, layer height, supports, infill and other settings that have more impact on the final result."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MODEL_SOURCES": {
|
||||||
|
"KICKER": "3D models",
|
||||||
|
"TITLE": "Where to find 3D models to upload",
|
||||||
|
"TEXT": "If you do not have a file yet, start from these platforms. Always check license, dimensions and model quality before uploading it to the calculator.",
|
||||||
|
"FAVORITES_TITLE": "Our favorites",
|
||||||
|
"OTHERS_TITLE": "Others",
|
||||||
|
"ITEMS": {
|
||||||
|
"PRINTABLES": "Large catalog of printable models shared by the maker community.",
|
||||||
|
"MAKERWORLD": "Repository of ready-to-print models, especially useful for maker projects.",
|
||||||
|
"THINGIVERSE": "Long-running archive with a very large number of free models in many categories.",
|
||||||
|
"THANGS": "3D search engine and repository with related versions and collections.",
|
||||||
|
"CULTS3D": "Marketplace with both free and paid models across many use cases.",
|
||||||
|
"YEGGI": "Search engine that aggregates 3D models from different sites."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FAQ": {
|
||||||
|
"KICKER": "Common questions",
|
||||||
|
"TITLE": "Mini FAQ",
|
||||||
|
"SUBTITLE": "Quick answers to the questions that most often block non-technical users.",
|
||||||
|
"ITEMS": {
|
||||||
|
"FILES": {
|
||||||
|
"Q": "Which files can I upload?",
|
||||||
|
"A": "You can upload STL or 3MF files. If you download a model from an external portal, make sure it is an actual 3D file and not just images or documentation."
|
||||||
|
},
|
||||||
|
"MODE": {
|
||||||
|
"Q": "How do I choose between Basic and Advanced?",
|
||||||
|
"A": "Choose Basic if you want a fast quote for a ready file. Choose Advanced if you need to compare materials or change settings that affect time, weight and price."
|
||||||
|
},
|
||||||
|
"NO_MODEL": {
|
||||||
|
"Q": "What if I do not have a 3D model yet?",
|
||||||
|
"A": "You can search on the platforms listed above or contact us if you need help with CAD design or adaptation."
|
||||||
|
},
|
||||||
|
"PRICE": {
|
||||||
|
"Q": "Is the shown price already reliable?",
|
||||||
|
"A": "It is an automatic estimate based on your file and the selected settings. Advanced mode is more precise because it accounts for more print parameters."
|
||||||
|
},
|
||||||
|
"BEFORE_UPLOAD": {
|
||||||
|
"Q": "What should I check before uploading?",
|
||||||
|
"A": "Check that the model has the correct scale, that the file is not corrupted and that the geometry really matches the final part you want to print."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UPLOAD_LABEL": "Drag your 3D file here",
|
"UPLOAD_LABEL": "Drag your 3D file here",
|
||||||
"UPLOAD_SUB": "Supports STL, 3MF, STEP up to 50MB",
|
"UPLOAD_SUB": "Supports STL, 3MF up to 50MB",
|
||||||
"MATERIAL": "Material",
|
"MATERIAL": "Material",
|
||||||
"QUALITY": "Quality",
|
"QUALITY": "Quality",
|
||||||
"QUANTITY": "Quantity",
|
"QUANTITY": "Quantity",
|
||||||
@@ -141,11 +214,12 @@
|
|||||||
"BENEFITS_2": "Selected materials and quality control",
|
"BENEFITS_2": "Selected materials and quality control",
|
||||||
"BENEFITS_3": "CAD consultation if file needs modifications",
|
"BENEFITS_3": "CAD consultation if file needs modifications",
|
||||||
"ERR_FILE_REQUIRED": "File is required.",
|
"ERR_FILE_REQUIRED": "File is required.",
|
||||||
"STEP_WARNING": "3D preview is not available for STEP files, but the calculator works perfectly. You can proceed with the quotation.",
|
"STEP_WARNING": "3D preview is available only for STL files.",
|
||||||
"REMOVE_FILE": "Remove file",
|
"REMOVE_FILE": "Remove file",
|
||||||
"FALLBACK_MATERIAL": "PLA (fallback)",
|
"FALLBACK_MATERIAL": "PLA (fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.",
|
"ERR_FILE_TOO_LARGE": "Some files exceed the 200MB limit and were not added.",
|
||||||
|
"ERR_INVALID_FILE_TYPE": "You can upload only .stl or .3mf files.",
|
||||||
"PRINT_SPEED": "Print speed",
|
"PRINT_SPEED": "Print speed",
|
||||||
"COLOR": "Color",
|
"COLOR": "Color",
|
||||||
"ANALYZING_TITLE": "Analysis in progress...",
|
"ANALYZING_TITLE": "Analysis in progress...",
|
||||||
@@ -624,7 +698,7 @@
|
|||||||
"BTN_CONTACT": "Talk to us",
|
"BTN_CONTACT": "Talk to us",
|
||||||
"SEC_CALC_TITLE": "Accurate pricing in a few seconds",
|
"SEC_CALC_TITLE": "Accurate pricing in a few seconds",
|
||||||
"SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.",
|
"SEC_CALC_SUBTITLE": "No registration required. The estimate is calculated through real slicing.",
|
||||||
"SEC_CALC_LIST_1": "Supported formats: STL, 3MF, STEP",
|
"SEC_CALC_LIST_1": "Supported formats: STL, 3MF",
|
||||||
"CARD_CALC_EYEBROW": "Automatic calculation",
|
"CARD_CALC_EYEBROW": "Automatic calculation",
|
||||||
"CARD_CALC_TITLE": "Price and lead time in one click",
|
"CARD_CALC_TITLE": "Price and lead time in one click",
|
||||||
"CARD_CALC_TAG": "No registration",
|
"CARD_CALC_TAG": "No registration",
|
||||||
@@ -674,7 +748,7 @@
|
|||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
"DEFAULT_LABEL": "Drop files here or click to upload",
|
"DEFAULT_LABEL": "Drop files here or click to upload",
|
||||||
"DEFAULT_SUBTEXT": "Supports .stl, .3mf, .step"
|
"DEFAULT_SUBTEXT": "Supports .stl, .3mf"
|
||||||
},
|
},
|
||||||
"COLOR": {
|
"COLOR": {
|
||||||
"AVAILABLE_COLORS": "Available colors",
|
"AVAILABLE_COLORS": "Available colors",
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
"TITLE": "Calculateur de devis impression 3D | 3D fab",
|
"TITLE": "Calculateur de devis impression 3D | 3D fab",
|
||||||
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
|
"DESCRIPTION": "Chargez votre fichier 3D et obtenez prix et délais en quelques secondes avec un vrai slicing.",
|
||||||
"BASIC": {
|
"BASIC": {
|
||||||
"TITLE": "Calculateur impression 3D de base | 3D fab",
|
"TITLE": "Devis impression 3D rapide pour fichiers prets | 3D fab",
|
||||||
"DESCRIPTION": "Calculez rapidement le prix de votre impression 3D avec le parcours de base."
|
"DESCRIPTION": "Chargez un fichier STL ou 3MF deja pret et obtenez en quelques secondes un prix d impression 3D fiable avec le calculateur de base."
|
||||||
},
|
},
|
||||||
"ADVANCED": {
|
"ADVANCED": {
|
||||||
"TITLE": "Calculateur impression 3D avancé | 3D fab",
|
"TITLE": "Devis impression 3D precis avec materiaux et reglages | 3D fab",
|
||||||
"DESCRIPTION": "Configurez des paramètres avancés et obtenez un devis précis basé sur un vrai slicing."
|
"DESCRIPTION": "Definissez le materiau, la hauteur de couche, les supports et le remplissage pour obtenir un devis d impression 3D plus precis."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
|
||||||
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
|
||||||
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Basés en Suisse",
|
||||||
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
"HERO_SWISS_COPY": "Production et support en Suisse.",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"BTN_CONTACT": "Parlez avec nous",
|
"BTN_CONTACT": "Parlez avec nous",
|
||||||
"SEC_CALC_TITLE": "Prix correct en quelques secondes",
|
"SEC_CALC_TITLE": "Prix correct en quelques secondes",
|
||||||
"SEC_CALC_SUBTITLE": "Aucune inscription requise. L'estimation est effectuée via un vrai slicing.",
|
"SEC_CALC_SUBTITLE": "Aucune inscription requise. L'estimation est effectuée via un vrai slicing.",
|
||||||
"SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF, STEP",
|
"SEC_CALC_LIST_1": "Formats pris en charge : STL, 3MF",
|
||||||
"CARD_CALC_EYEBROW": "Calcul automatique",
|
"CARD_CALC_EYEBROW": "Calcul automatique",
|
||||||
"CARD_CALC_TITLE": "Prix et délais en un clic",
|
"CARD_CALC_TITLE": "Prix et délais en un clic",
|
||||||
"CARD_CALC_TAG": "Sans inscription",
|
"CARD_CALC_TAG": "Sans inscription",
|
||||||
@@ -139,14 +139,87 @@
|
|||||||
},
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
"TITLE": "Calculer un devis 3D",
|
"TITLE": "Calculer un devis 3D",
|
||||||
"SUBTITLE": "Chargez votre fichier 3D (STL, 3MF, STEP), réglez la qualité et la couleur puis calculez immédiatement prix et délais.",
|
"SUBTITLE": "Chargez votre fichier 3D (STL, 3MF), réglez la qualité et la couleur puis calculez immédiatement prix et délais.",
|
||||||
|
"EYEBROW": "Devis impression 3D en ligne",
|
||||||
"CTA_START": "Commencer maintenant",
|
"CTA_START": "Commencer maintenant",
|
||||||
"BUSINESS": "Entreprises",
|
"BUSINESS": "Entreprises",
|
||||||
"PRIVATE": "Particuliers",
|
"PRIVATE": "Particuliers",
|
||||||
"MODE_EASY": "Base",
|
"MODE_EASY": "Base",
|
||||||
"MODE_ADVANCED": "Avancée",
|
"MODE_ADVANCED": "Avancée",
|
||||||
|
"WHEN_TO_USE_LABEL": "Quand l'utiliser :",
|
||||||
|
"MODES": {
|
||||||
|
"BASIC": {
|
||||||
|
"TOGGLE_HINT": "Pour fichiers deja prets",
|
||||||
|
"TITLE": "Devis rapide pour fichiers 3D deja prets",
|
||||||
|
"SUBTITLE": "Le mode Base est pense pour les utilisateurs qui veulent un prix fiable en quelques secondes sans gerer des reglages techniques avances.",
|
||||||
|
"UPLOAD_HELP": "Chargez un fichier STL ou 3MF deja exporte, choisissez le materiau et la qualite, puis obtenez tout de suite une estimation claire du prix et du delai.",
|
||||||
|
"WHEN_TO_USE": "Utilisez Base si votre modele est deja finalise et que vous voulez surtout un devis simple et rapide."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TOGGLE_HINT": "Plus de controle sur l impression",
|
||||||
|
"TITLE": "Devis plus precis avec materiaux et reglages",
|
||||||
|
"SUBTITLE": "Le mode avance est pense pour les utilisateurs qui veulent une estimation plus precise en ajustant les parametres d impression importants.",
|
||||||
|
"UPLOAD_HELP": "Chargez votre fichier 3D et reglez le materiau, la hauteur de couche, les supports, le remplissage et la buse pour vous rapprocher de la configuration finale.",
|
||||||
|
"WHEN_TO_USE": "Utilisez Avancee si vous voulez comparer des materiaux ou des reglages et garder plus de controle sur le resultat."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"COMPARE_KICKER": "Choix rapide",
|
||||||
|
"COMPARE_TITLE": "Base ou Avancee ?",
|
||||||
|
"COMPARE_SUBTITLE": "Les deux pages repondent a des besoins differents. Commencez par Base pour aller vite, puis passez a Avancee quand le materiau ou les reglages d impression deviennent importants.",
|
||||||
|
"COMPARE": {
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Base : devis rapide",
|
||||||
|
"TEXT": "Ideal si vous avez deja un fichier pret et que vous voulez recevoir rapidement un prix fiable, sans entrer dans les details techniques."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Avancee : devis plus precis",
|
||||||
|
"TEXT": "Ideal si vous voulez choisir le materiau, la hauteur de couche, les supports, le remplissage et d autres reglages qui influencent davantage le resultat final."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MODEL_SOURCES": {
|
||||||
|
"KICKER": "Modeles 3D",
|
||||||
|
"TITLE": "Ou trouver des modeles 3D a charger",
|
||||||
|
"TEXT": "Si vous n avez pas encore de fichier, vous pouvez commencer par ces plateformes. Verifiez toujours la licence, les dimensions et la qualite du modele avant de le charger dans le calculateur.",
|
||||||
|
"FAVORITES_TITLE": "Nos preferes",
|
||||||
|
"OTHERS_TITLE": "Autres",
|
||||||
|
"ITEMS": {
|
||||||
|
"PRINTABLES": "Grand catalogue de modeles imprimables partages par la communaute maker.",
|
||||||
|
"MAKERWORLD": "Repository de modeles prets a imprimer, utile surtout pour les projets maker.",
|
||||||
|
"THINGIVERSE": "Archive historique avec un tres grand nombre de modeles gratuits dans de nombreuses categories.",
|
||||||
|
"THANGS": "Moteur de recherche et repository 3D avec versions et collections associees.",
|
||||||
|
"CULTS3D": "Marketplace avec des modeles gratuits et payants pour des usages tres varies.",
|
||||||
|
"YEGGI": "Moteur de recherche qui agrège des modeles 3D depuis plusieurs sites."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FAQ": {
|
||||||
|
"KICKER": "Questions frequentes",
|
||||||
|
"TITLE": "Mini FAQ",
|
||||||
|
"SUBTITLE": "Des reponses rapides aux questions qui bloquent le plus souvent les utilisateurs non techniques.",
|
||||||
|
"ITEMS": {
|
||||||
|
"FILES": {
|
||||||
|
"Q": "Quels fichiers puis-je charger ?",
|
||||||
|
"A": "Vous pouvez charger des fichiers STL ou 3MF. Si vous telechargez un modele depuis un portail externe, verifiez qu il s agit bien d un vrai fichier 3D et pas seulement d images ou de documentation."
|
||||||
|
},
|
||||||
|
"MODE": {
|
||||||
|
"Q": "Comment choisir entre Base et Avancee ?",
|
||||||
|
"A": "Choisissez Base si vous voulez un devis rapide pour un fichier deja pret. Choisissez Avancee si vous devez comparer des materiaux ou modifier des reglages qui influencent le temps, le poids et le prix."
|
||||||
|
},
|
||||||
|
"NO_MODEL": {
|
||||||
|
"Q": "Et si je n ai pas encore de modele 3D ?",
|
||||||
|
"A": "Vous pouvez chercher sur les plateformes ci-dessus ou nous contacter si vous avez besoin d aide pour la conception ou l adaptation CAD."
|
||||||
|
},
|
||||||
|
"PRICE": {
|
||||||
|
"Q": "Le prix affiche est-il deja fiable ?",
|
||||||
|
"A": "C est une estimation automatique basee sur votre fichier et les reglages choisis. Le mode Avancee est plus precis car il tient compte d un plus grand nombre de parametres d impression."
|
||||||
|
},
|
||||||
|
"BEFORE_UPLOAD": {
|
||||||
|
"Q": "Que faut-il verifier avant le chargement ?",
|
||||||
|
"A": "Verifiez que le modele a la bonne echelle, que le fichier n est pas corrompu et que la geometrie correspond bien a la piece finale que vous voulez imprimer."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UPLOAD_LABEL": "Glissez votre fichier 3D ici",
|
"UPLOAD_LABEL": "Glissez votre fichier 3D ici",
|
||||||
"UPLOAD_SUB": "Nous prenons en charge STL, 3MF, STEP jusqu'à 50MB",
|
"UPLOAD_SUB": "Nous prenons en charge STL, 3MF jusqu'à 50MB",
|
||||||
"MATERIAL": "Matériau",
|
"MATERIAL": "Matériau",
|
||||||
"QUALITY": "Qualité",
|
"QUALITY": "Qualité",
|
||||||
"PRINT_SPEED": "Vitesse d'impression",
|
"PRINT_SPEED": "Vitesse d'impression",
|
||||||
@@ -185,11 +258,12 @@
|
|||||||
"NOTES_PLACEHOLDER": "Instructions spécifiques...",
|
"NOTES_PLACEHOLDER": "Instructions spécifiques...",
|
||||||
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
|
"SETUP_NOTE": "* Inclut {{cost}} comme coût de setup",
|
||||||
"SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante",
|
"SHIPPING_NOTE": "* Frais d'expédition exclus, calculés à l'étape suivante",
|
||||||
"STEP_WARNING": "La visualisation 3D n'est pas compatible avec les fichiers STEP et 3MF",
|
"STEP_WARNING": "La prévisualisation 3D est disponible uniquement pour les fichiers STL.",
|
||||||
"REMOVE_FILE": "Supprimer le fichier",
|
"REMOVE_FILE": "Supprimer le fichier",
|
||||||
"FALLBACK_MATERIAL": "PLA (fallback)",
|
"FALLBACK_MATERIAL": "PLA (fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.",
|
"ERR_FILE_TOO_LARGE": "Certains fichiers dépassent la limite de 200MB et n'ont pas été ajoutés.",
|
||||||
|
"ERR_INVALID_FILE_TYPE": "Vous pouvez téléverser uniquement des fichiers .stl ou .3mf.",
|
||||||
"ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.",
|
"ERROR_ZERO_PRICE": "Un problème est survenu pendant le calcul. Essayez un autre format ou contactez-nous directement via Demander une consultation.",
|
||||||
"ZERO_RESULT_TITLE": "Résultat invalide",
|
"ZERO_RESULT_TITLE": "Résultat invalide",
|
||||||
"ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation."
|
"ZERO_RESULT_HELP": "Le calcul a renvoyé des valeurs nulles invalides. Essayez un autre format de fichier ou contactez-nous directement via Demander une consultation."
|
||||||
@@ -680,7 +754,7 @@
|
|||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
"DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser",
|
"DEFAULT_LABEL": "Glissez les fichiers ici ou cliquez pour téléverser",
|
||||||
"DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF, .STEP"
|
"DEFAULT_SUBTEXT": "Prend en charge .STL, .3MF"
|
||||||
},
|
},
|
||||||
"COLOR": {
|
"COLOR": {
|
||||||
"AVAILABLE_COLORS": "Couleurs disponibles",
|
"AVAILABLE_COLORS": "Couleurs disponibles",
|
||||||
|
|||||||
@@ -27,12 +27,12 @@
|
|||||||
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
|
"TITLE": "Calcolatore preventivo stampa 3D | 3D fab",
|
||||||
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
|
"DESCRIPTION": "Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.",
|
||||||
"BASIC": {
|
"BASIC": {
|
||||||
"TITLE": "Calcolatore stampa 3D base | 3D fab",
|
"TITLE": "Preventivo stampa 3D veloce da file pronti | 3D fab",
|
||||||
"DESCRIPTION": "Calcola rapidamente il prezzo della tua stampa 3D in modalita base."
|
"DESCRIPTION": "Carica un file STL o 3MF gia pronto e ricevi un prezzo corretto in pochi secondi con il calcolatore base."
|
||||||
},
|
},
|
||||||
"ADVANCED": {
|
"ADVANCED": {
|
||||||
"TITLE": "Calcolatore stampa 3D avanzato | 3D fab",
|
"TITLE": "Preventivo stampa 3D preciso con materiali e impostazioni | 3D fab",
|
||||||
"DESCRIPTION": "Configura parametri avanzati e ottieni un preventivo preciso con slicing reale."
|
"DESCRIPTION": "Configura materiale, layer, supporti e riempimento per ottenere un preventivo stampa 3D piu preciso."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SHOP": {
|
"SHOP": {
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
|
||||||
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
|
||||||
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
|
||||||
"HERO_SWISS_TITLE": "Based in Switzerland",
|
"HERO_SWISS_TITLE": "Con sede in Svizzera",
|
||||||
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
|
||||||
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
|
||||||
"HERO_SWISS_LOCATION_1": "Ticino",
|
"HERO_SWISS_LOCATION_1": "Ticino",
|
||||||
"HERO_SWISS_LOCATION_2": "Zurich",
|
"HERO_SWISS_LOCATION_2": "Zurigo",
|
||||||
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
|
||||||
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
|
||||||
"BTN_CALCULATE": "Calcola Preventivo",
|
"BTN_CALCULATE": "Calcola Preventivo",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"BTN_CONTACT": "Parla con noi",
|
"BTN_CONTACT": "Parla con noi",
|
||||||
"SEC_CALC_TITLE": "Prezzo corretto in pochi secondi",
|
"SEC_CALC_TITLE": "Prezzo corretto in pochi secondi",
|
||||||
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.",
|
"SEC_CALC_SUBTITLE": "Nessuna registrazione necessaria. La stima è effettuata tramite vero slicing.",
|
||||||
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF, STEP",
|
"SEC_CALC_LIST_1": "Formati supportati: STL, 3MF",
|
||||||
"CARD_CALC_EYEBROW": "Calcolo automatico",
|
"CARD_CALC_EYEBROW": "Calcolo automatico",
|
||||||
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
|
"CARD_CALC_TITLE": "Prezzo e tempi in un click",
|
||||||
"CARD_CALC_TAG": "Senza registrazione",
|
"CARD_CALC_TAG": "Senza registrazione",
|
||||||
@@ -139,14 +139,87 @@
|
|||||||
},
|
},
|
||||||
"CALC": {
|
"CALC": {
|
||||||
"TITLE": "Calcola Preventivo 3D",
|
"TITLE": "Calcola Preventivo 3D",
|
||||||
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF, STEP), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.",
|
"SUBTITLE": "Carica il tuo file 3D (STL, 3MF), imposta la qualità, il colore e calcola immediatamente prezzo e tempi.",
|
||||||
|
"EYEBROW": "Preventivo stampa 3D online",
|
||||||
"CTA_START": "Inizia Ora",
|
"CTA_START": "Inizia Ora",
|
||||||
"BUSINESS": "Aziende",
|
"BUSINESS": "Aziende",
|
||||||
"PRIVATE": "Privati",
|
"PRIVATE": "Privati",
|
||||||
"MODE_EASY": "Base",
|
"MODE_EASY": "Base",
|
||||||
"MODE_ADVANCED": "Avanzata",
|
"MODE_ADVANCED": "Avanzata",
|
||||||
|
"WHEN_TO_USE_LABEL": "Quando usarla:",
|
||||||
|
"MODES": {
|
||||||
|
"BASIC": {
|
||||||
|
"TOGGLE_HINT": "Per file gia pronti",
|
||||||
|
"TITLE": "Preventivo veloce per file 3D gia pronti",
|
||||||
|
"SUBTITLE": "La modalita Base e pensata per chi vuole un prezzo corretto in pochi secondi senza gestire parametri tecnici avanzati.",
|
||||||
|
"UPLOAD_HELP": "Carica un file STL o 3MF gia esportato, scegli materiale e qualita, poi ricevi subito una stima chiara di prezzo e tempo.",
|
||||||
|
"WHEN_TO_USE": "Usa Base se il modello e pronto e ti serve soprattutto un preventivo rapido e semplice."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TOGGLE_HINT": "Piu controllo su stampa e materiale",
|
||||||
|
"TITLE": "Preventivo piu preciso con materiali e impostazioni",
|
||||||
|
"SUBTITLE": "La modalita Avanzata e pensata per chi vuole una stima piu precisa regolando parametri di stampa rilevanti.",
|
||||||
|
"UPLOAD_HELP": "Carica il file 3D e imposta materiale, layer, supporti, riempimento e ugello per avvicinare di piu il preventivo alla configurazione finale.",
|
||||||
|
"WHEN_TO_USE": "Usa Avanzata se devi confrontare materiali o impostazioni e vuoi piu controllo sul risultato."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"COMPARE_KICKER": "Scelta rapida",
|
||||||
|
"COMPARE_TITLE": "Basic o Advanced?",
|
||||||
|
"COMPARE_SUBTITLE": "Le due pagine servono a casi d uso diversi. Parti da Base per fare presto, passa ad Avanzata quando il materiale o le impostazioni fanno la differenza.",
|
||||||
|
"COMPARE": {
|
||||||
|
"BASIC": {
|
||||||
|
"TITLE": "Base: preventivo veloce",
|
||||||
|
"TEXT": "Ideale se hai un file gia pronto e vuoi ricevere un prezzo corretto in modo semplice, senza entrare nei dettagli tecnici."
|
||||||
|
},
|
||||||
|
"ADVANCED": {
|
||||||
|
"TITLE": "Avanzata: preventivo piu preciso",
|
||||||
|
"TEXT": "Ideale se vuoi scegliere materiale, layer, supporti, riempimento e altre impostazioni che incidono di piu sul risultato finale."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"MODEL_SOURCES": {
|
||||||
|
"KICKER": "Modelli 3D",
|
||||||
|
"TITLE": "Dove trovare modelli 3D da caricare",
|
||||||
|
"TEXT": "Se non hai ancora un file, puoi partire da queste piattaforme. Controlla sempre licenza, dimensioni e qualita del modello prima di caricarlo nel calcolatore.",
|
||||||
|
"FAVORITES_TITLE": "I nostri preferiti",
|
||||||
|
"OTHERS_TITLE": "Altri",
|
||||||
|
"ITEMS": {
|
||||||
|
"PRINTABLES": "Ampio catalogo di modelli stampabili condivisi dalla community.",
|
||||||
|
"MAKERWORLD": "Repository con modelli pronti alla stampa, utile soprattutto in ambito maker.",
|
||||||
|
"THINGIVERSE": "Archivio storico con moltissimi modelli gratuiti in diverse categorie.",
|
||||||
|
"THANGS": "Motore di ricerca e repository 3D con raccolte e versioni correlate.",
|
||||||
|
"CULTS3D": "Marketplace con modelli gratuiti e a pagamento per progetti molto diversi.",
|
||||||
|
"YEGGI": "Motore di ricerca che aggrega modelli 3D da siti differenti."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FAQ": {
|
||||||
|
"KICKER": "Dubbi comuni",
|
||||||
|
"TITLE": "Mini FAQ",
|
||||||
|
"SUBTITLE": "Le risposte rapide alle domande che bloccano piu spesso chi non usa abitualmente la stampa 3D.",
|
||||||
|
"ITEMS": {
|
||||||
|
"FILES": {
|
||||||
|
"Q": "Che file posso caricare?",
|
||||||
|
"A": "Puoi caricare file STL o 3MF. Se scarichi un modello da un portale esterno, verifica che sia gia un file 3D pronto e non solo immagini o documentazione."
|
||||||
|
},
|
||||||
|
"MODE": {
|
||||||
|
"Q": "Come scelgo tra Base e Avanzata?",
|
||||||
|
"A": "Scegli Base se vuoi un preventivo rapido per un file gia pronto. Scegli Avanzata se devi confrontare materiali o modificare impostazioni che cambiano tempo, peso e costo."
|
||||||
|
},
|
||||||
|
"NO_MODEL": {
|
||||||
|
"Q": "E se non ho ancora il modello 3D?",
|
||||||
|
"A": "Puoi cercarlo nei portali suggeriti qui sopra oppure contattarci se ti serve aiuto con progettazione o adattamento CAD."
|
||||||
|
},
|
||||||
|
"PRICE": {
|
||||||
|
"Q": "Il prezzo mostrato e gia affidabile?",
|
||||||
|
"A": "E una stima automatica basata sul file e sulle impostazioni selezionate. In modalita Avanzata la stima e piu precisa perche considera piu parametri di stampa."
|
||||||
|
},
|
||||||
|
"BEFORE_UPLOAD": {
|
||||||
|
"Q": "Cosa conviene controllare prima del caricamento?",
|
||||||
|
"A": "Controlla che il modello abbia la scala corretta, che il file non sia corrotto e che la geometria sia davvero quella finale che vuoi stampare."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
"UPLOAD_LABEL": "Trascina il tuo file 3D qui",
|
||||||
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP fino a 50MB",
|
"UPLOAD_SUB": "Supportiamo STL, 3MF fino a 50MB",
|
||||||
"MATERIAL": "Materiale",
|
"MATERIAL": "Materiale",
|
||||||
"QUALITY": "Qualità",
|
"QUALITY": "Qualità",
|
||||||
"PRINT_SPEED": "Velocità di Stampa",
|
"PRINT_SPEED": "Velocità di Stampa",
|
||||||
@@ -185,11 +258,12 @@
|
|||||||
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
"NOTES_PLACEHOLDER": "Istruzioni specifiche...",
|
||||||
"SETUP_NOTE": "* Include {{cost}} come costo di setup",
|
"SETUP_NOTE": "* Include {{cost}} come costo di setup",
|
||||||
"SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo",
|
"SHIPPING_NOTE": "* Costi di spedizione esclusi, calcolati al passaggio successivo",
|
||||||
"STEP_WARNING": "La visualizzazione 3D non è compatibile con i file step e 3mf",
|
"STEP_WARNING": "La visualizzazione 3D è disponibile solo per i file STL",
|
||||||
"REMOVE_FILE": "Rimuovi file",
|
"REMOVE_FILE": "Rimuovi file",
|
||||||
"FALLBACK_MATERIAL": "PLA (fallback)",
|
"FALLBACK_MATERIAL": "PLA (fallback)",
|
||||||
"FALLBACK_QUALITY_STANDARD": "Standard",
|
"FALLBACK_QUALITY_STANDARD": "Standard",
|
||||||
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.",
|
"ERR_FILE_TOO_LARGE": "Alcuni file superano il limite di 200MB e non sono stati aggiunti.",
|
||||||
|
"ERR_INVALID_FILE_TYPE": "Puoi caricare solo file .stl o .3mf.",
|
||||||
"ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.",
|
"ERROR_ZERO_PRICE": "Qualcosa è andato storto nel calcolo. Prova un altro formato o contattaci direttamente con Richiedi Consulenza.",
|
||||||
"ZERO_RESULT_TITLE": "Risultato non valido",
|
"ZERO_RESULT_TITLE": "Risultato non valido",
|
||||||
"ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza."
|
"ZERO_RESULT_HELP": "Il calcolo ha restituito valori non validi (0). Prova con un altro formato file oppure contattaci direttamente con Richiedi Consulenza."
|
||||||
@@ -680,7 +754,7 @@
|
|||||||
},
|
},
|
||||||
"DROPZONE": {
|
"DROPZONE": {
|
||||||
"DEFAULT_LABEL": "Trascina i file qui o clicca per caricare",
|
"DEFAULT_LABEL": "Trascina i file qui o clicca per caricare",
|
||||||
"DEFAULT_SUBTEXT": "Supporta .STL, .3MF, .STEP"
|
"DEFAULT_SUBTEXT": "Supporta .STL, .3MF"
|
||||||
},
|
},
|
||||||
"COLOR": {
|
"COLOR": {
|
||||||
"AVAILABLE_COLORS": "Colori disponibili",
|
"AVAILABLE_COLORS": "Colori disponibili",
|
||||||
|
|||||||
1080
frontend/src/assets/images/Asset 2.svg
Normal file
|
After Width: | Height: | Size: 74 KiB |
1080
frontend/src/assets/images/Asset 223.svg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
frontend/src/assets/images/Fav-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
frontend/src/assets/images/Fav-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 325 KiB |
BIN
frontend/src/assets/images/Fav-icon-browser-192x192.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
1080
frontend/src/assets/images/SVG/Asset 1.svg
Normal file
|
After Width: | Height: | Size: 76 KiB |