diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 5be1b24..bfb9caf 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -217,9 +217,12 @@ jobs: ADMIN_TTL="${ADMIN_TTL:-480}" 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 + 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:" - 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 }}" \ "setenv ${{ env.ENV }}" < /tmp/full_env.env diff --git a/README.md b/README.md index 06988c5..933dbdc 100644 --- a/README.md +++ b/README.md @@ -111,3 +111,6 @@ Verifica che `MEDIA_FFMPEG_PATH` punti a un `ffmpeg` con supporto JPEG, WebP e A ### Database connection 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`. diff --git a/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java b/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java new file mode 100644 index 0000000..679b309 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/config/AllowedOriginService.java @@ -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 allowedOrigins; + + public AllowedOriginService( + @Value("${app.frontend.base-url:http://localhost:4200}") String frontendBaseUrl, + @Value("${app.cors.additional-allowed-origins:}") String additionalAllowedOrigins + ) { + LinkedHashSet 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 getAllowedOrigins() { + return allowedOrigins; + } + + public boolean isAllowed(String rawOriginOrUrl) { + String normalizedOrigin = normalizeRequestOrigin(rawOriginOrUrl); + return normalizedOrigin != null && allowedOrigins.contains(normalizedOrigin); + } + + private void addConfiguredOrigin(Set 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); + } +} diff --git a/backend/src/main/java/com/printcalculator/config/CorsConfig.java b/backend/src/main/java/com/printcalculator/config/CorsConfig.java index b3a9869..5157fad 100644 --- a/backend/src/main/java/com/printcalculator/config/CorsConfig.java +++ b/backend/src/main/java/com/printcalculator/config/CorsConfig.java @@ -1,27 +1,27 @@ package com.printcalculator.config; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration -public class CorsConfig implements WebMvcConfigurer { +public class CorsConfig { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedOrigins( - "http://localhost", - "http://localhost:4200", - "http://localhost:80", - "http://127.0.0.1", - "https://dev.3d-fab.ch", - "https://int.3d-fab.ch", - "https://3d-fab.ch" - ) - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH") - .allowedHeaders("*") - .allowCredentials(true); + @Bean + public CorsConfigurationSource corsConfigurationSource(AllowedOriginService allowedOriginService) { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(allowedOriginService.getAllowedOrigins()); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; } } diff --git a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java index e7e6670..69a9971 100644 --- a/backend/src/main/java/com/printcalculator/config/SecurityConfig.java +++ b/backend/src/main/java/com/printcalculator/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.printcalculator.config; +import com.printcalculator.security.AdminCsrfProtectionFilter; import com.printcalculator.security.AdminSessionAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -18,6 +19,7 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain( HttpSecurity http, + AdminCsrfProtectionFilter adminCsrfProtectionFilter, AdminSessionAuthenticationFilter adminSessionAuthenticationFilter ) throws Exception { http @@ -40,7 +42,8 @@ public class SecurityConfig { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write("{\"error\":\"UNAUTHORIZED\"}"); })) - .addFilterBefore(adminSessionAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(adminCsrfProtectionFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterAfter(adminSessionAuthenticationFilter, AdminCsrfProtectionFilter.class); return http.build(); } diff --git a/backend/src/main/java/com/printcalculator/controller/OptionsController.java b/backend/src/main/java/com/printcalculator/controller/OptionsController.java index 28a1abb..d4f65e1 100644 --- a/backend/src/main/java/com/printcalculator/controller/OptionsController.java +++ b/backend/src/main/java/com/printcalculator/controller/OptionsController.java @@ -94,6 +94,10 @@ public class OptionsController { v.getId(), v.getVariantDisplayName(), v.getColorName(), + v.getColorLabelIt(), + v.getColorLabelEn(), + v.getColorLabelDe(), + v.getColorLabelFr(), resolveHexColor(v), v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, diff --git a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java index e4680f0..1043025 100644 --- a/backend/src/main/java/com/printcalculator/controller/PublicShopController.java +++ b/backend/src/main/java/com/printcalculator/controller/PublicShopController.java @@ -56,6 +56,12 @@ public class PublicShopController { return ResponseEntity.ok(publicShopCatalogService.getProduct(slug, lang)); } + @GetMapping("/products/by-path/{publicPath}") + public ResponseEntity getProductByPublicPath(@PathVariable String publicPath, + @RequestParam(required = false) String lang) { + return ResponseEntity.ok(publicShopCatalogService.getProductByPublicPath(publicPath, lang)); + } + @GetMapping("/products/{slug}/model") public ResponseEntity getProductModel(@PathVariable String slug) throws IOException { PublicShopCatalogService.ProductModelDownload model = publicShopCatalogService.getProductModelDownload(slug); diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteController.java b/backend/src/main/java/com/printcalculator/controller/QuoteController.java index 8675415..d848d4c 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteController.java @@ -18,6 +18,7 @@ import java.math.BigDecimal; import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; +import java.util.Locale; import java.util.Map; import static org.springframework.http.HttpStatus.BAD_REQUEST; @@ -124,6 +125,9 @@ public class QuoteController { if (file.isEmpty()) { return ResponseEntity.badRequest().build(); } + if (!isSupportedInputFile(file)) { + throw new ResponseStatusException(BAD_REQUEST, "Unsupported file type. Allowed: stl, 3mf"); + } // Scan for virus clamAVService.scan(file.getInputStream()); @@ -153,4 +157,14 @@ public class QuoteController { 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"); + } } diff --git a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java index cde3605..e4c1442 100644 --- a/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java +++ b/backend/src/main/java/com/printcalculator/controller/QuoteSessionController.java @@ -130,6 +130,7 @@ public class QuoteSessionController { } @GetMapping("/{id}") + @Transactional(readOnly = true) public ResponseEntity> getQuoteSession(@PathVariable UUID id) { QuoteSession session = sessionRepo.findById(id) .orElseThrow(() -> new RuntimeException("Session not found")); diff --git a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java index dc31270..af7e5c5 100644 --- a/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java +++ b/backend/src/main/java/com/printcalculator/controller/admin/AdminShopProductController.java @@ -1,8 +1,11 @@ package com.printcalculator.controller.admin; import com.printcalculator.dto.AdminShopProductDto; +import com.printcalculator.dto.AdminTranslateShopProductRequest; +import com.printcalculator.dto.AdminTranslateShopProductResponse; import com.printcalculator.dto.AdminUpsertShopProductRequest; import com.printcalculator.service.admin.AdminShopProductControllerService; +import com.printcalculator.service.admin.AdminShopProductTranslationService; import org.springframework.core.io.Resource; import org.springframework.core.io.UrlResource; import org.springframework.http.HttpHeaders; @@ -29,9 +32,12 @@ import java.util.UUID; @Transactional(readOnly = true) public class AdminShopProductController { private final AdminShopProductControllerService adminShopProductControllerService; + private final AdminShopProductTranslationService adminShopProductTranslationService; - public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService) { + public AdminShopProductController(AdminShopProductControllerService adminShopProductControllerService, + AdminShopProductTranslationService adminShopProductTranslationService) { this.adminShopProductControllerService = adminShopProductControllerService; + this.adminShopProductTranslationService = adminShopProductTranslationService; } @GetMapping @@ -50,6 +56,11 @@ public class AdminShopProductController { return ResponseEntity.ok(adminShopProductControllerService.createProduct(payload)); } + @PostMapping("/translate") + public ResponseEntity translateProduct(@RequestBody AdminTranslateShopProductRequest payload) { + return ResponseEntity.ok(adminShopProductTranslationService.translateProduct(payload)); + } + @PutMapping("/{productId}") @Transactional public ResponseEntity updateProduct(@PathVariable UUID productId, diff --git a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java index 88b32ac..f5cb9a3 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminFilamentVariantDto.java @@ -12,6 +12,10 @@ public class AdminFilamentVariantDto { private String materialTechnicalTypeLabel; private String variantDisplayName; private String colorName; + private String colorLabelIt; + private String colorLabelEn; + private String colorLabelDe; + private String colorLabelFr; private String colorHex; private String finishType; private String brand; @@ -89,6 +93,38 @@ public class AdminFilamentVariantDto { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java index 3e43c0d..a61c326 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopCategoryDto.java @@ -10,9 +10,25 @@ public class AdminShopCategoryDto { private String parentCategoryName; private String slug; private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; private String ogTitle; private String ogDescription; private Boolean indexable; @@ -69,6 +85,38 @@ public class AdminShopCategoryDto { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getDescription() { return description; } @@ -77,6 +125,38 @@ public class AdminShopCategoryDto { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } @@ -85,6 +165,38 @@ public class AdminShopCategoryDto { this.seoTitle = seoTitle; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + public String getSeoDescription() { return seoDescription; } @@ -93,6 +205,38 @@ public class AdminShopCategoryDto { this.seoDescription = seoDescription; } + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java index e03c629..9a32330 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminShopProductVariantDto.java @@ -9,6 +9,10 @@ public class AdminShopProductVariantDto { private String sku; private String variantLabel; private String colorName; + private String colorLabelIt; + private String colorLabelEn; + private String colorLabelDe; + private String colorLabelFr; private String colorHex; private String internalMaterialCode; private BigDecimal priceChf; @@ -50,6 +54,38 @@ public class AdminShopProductVariantDto { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java new file mode 100644 index 0000000..25b09dc --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductRequest.java @@ -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 materialCodes; + private Map names; + private Map excerpts; + private Map descriptions; + private Map seoTitles; + private Map 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 getMaterialCodes() { + return materialCodes; + } + + public void setMaterialCodes(List materialCodes) { + this.materialCodes = materialCodes; + } + + public Map getNames() { + return names; + } + + public void setNames(Map names) { + this.names = names; + } + + public Map getExcerpts() { + return excerpts; + } + + public void setExcerpts(Map excerpts) { + this.excerpts = excerpts; + } + + public Map getDescriptions() { + return descriptions; + } + + public void setDescriptions(Map descriptions) { + this.descriptions = descriptions; + } + + public Map getSeoTitles() { + return seoTitles; + } + + public void setSeoTitles(Map seoTitles) { + this.seoTitles = seoTitles; + } + + public Map getSeoDescriptions() { + return seoDescriptions; + } + + public void setSeoDescriptions(Map seoDescriptions) { + this.seoDescriptions = seoDescriptions; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java new file mode 100644 index 0000000..1b4be40 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/dto/AdminTranslateShopProductResponse.java @@ -0,0 +1,70 @@ +package com.printcalculator.dto; + +import java.util.List; +import java.util.Map; + +public class AdminTranslateShopProductResponse { + private String sourceLanguage; + private List targetLanguages; + private Map names; + private Map excerpts; + private Map descriptions; + private Map seoTitles; + private Map seoDescriptions; + + public String getSourceLanguage() { + return sourceLanguage; + } + + public void setSourceLanguage(String sourceLanguage) { + this.sourceLanguage = sourceLanguage; + } + + public List getTargetLanguages() { + return targetLanguages; + } + + public void setTargetLanguages(List targetLanguages) { + this.targetLanguages = targetLanguages; + } + + public Map getNames() { + return names; + } + + public void setNames(Map names) { + this.names = names; + } + + public Map getExcerpts() { + return excerpts; + } + + public void setExcerpts(Map excerpts) { + this.excerpts = excerpts; + } + + public Map getDescriptions() { + return descriptions; + } + + public void setDescriptions(Map descriptions) { + this.descriptions = descriptions; + } + + public Map getSeoTitles() { + return seoTitles; + } + + public void setSeoTitles(Map seoTitles) { + this.seoTitles = seoTitles; + } + + public Map getSeoDescriptions() { + return seoDescriptions; + } + + public void setSeoDescriptions(Map seoDescriptions) { + this.seoDescriptions = seoDescriptions; + } +} diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java index 89cd51c..820141a 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertFilamentVariantRequest.java @@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest { private Long materialTypeId; private String variantDisplayName; private String colorName; + private String colorLabelIt; + private String colorLabelEn; + private String colorLabelDe; + private String colorLabelFr; private String colorHex; private String finishType; private String brand; @@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java index 28096f2..a8ed10f 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopCategoryRequest.java @@ -6,9 +6,25 @@ public class AdminUpsertShopCategoryRequest { private UUID parentCategoryId; private String slug; private String name; + private String nameIt; + private String nameEn; + private String nameDe; + private String nameFr; private String description; + private String descriptionIt; + private String descriptionEn; + private String descriptionDe; + private String descriptionFr; private String seoTitle; + private String seoTitleIt; + private String seoTitleEn; + private String seoTitleDe; + private String seoTitleFr; private String seoDescription; + private String seoDescriptionIt; + private String seoDescriptionEn; + private String seoDescriptionDe; + private String seoDescriptionFr; private String ogTitle; private String ogDescription; private Boolean indexable; @@ -39,6 +55,38 @@ public class AdminUpsertShopCategoryRequest { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getDescription() { return description; } @@ -47,6 +95,38 @@ public class AdminUpsertShopCategoryRequest { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } @@ -55,6 +135,38 @@ public class AdminUpsertShopCategoryRequest { this.seoTitle = seoTitle; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + public String getSeoDescription() { return seoDescription; } @@ -63,6 +175,38 @@ public class AdminUpsertShopCategoryRequest { this.seoDescription = seoDescription; } + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } diff --git a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java index 14ef9af..2b84871 100644 --- a/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java +++ b/backend/src/main/java/com/printcalculator/dto/AdminUpsertShopProductVariantRequest.java @@ -8,6 +8,10 @@ public class AdminUpsertShopProductVariantRequest { private String sku; private String variantLabel; private String colorName; + private String colorLabelIt; + private String colorLabelEn; + private String colorLabelDe; + private String colorLabelFr; private String colorHex; private String internalMaterialCode; private BigDecimal priceChf; @@ -47,6 +51,38 @@ public class AdminUpsertShopProductVariantRequest { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } diff --git a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java index 9b85460..566c366 100644 --- a/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java +++ b/backend/src/main/java/com/printcalculator/dto/OptionsResponse.java @@ -15,6 +15,10 @@ public record OptionsResponse( Long id, String name, String colorName, + String colorLabelIt, + String colorLabelEn, + String colorLabelDe, + String colorLabelFr, String hexColor, String finishType, Double stockSpools, diff --git a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java index efbcc87..8c21d5e 100644 --- a/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java +++ b/backend/src/main/java/com/printcalculator/dto/OrderItemDto.java @@ -17,9 +17,17 @@ public class OrderItemDto { private String shopProductName; private String shopVariantLabel; private String shopVariantColorName; + private String shopVariantColorLabelIt; + private String shopVariantColorLabelEn; + private String shopVariantColorLabelDe; + private String shopVariantColorLabelFr; private String shopVariantColorHex; private String filamentVariantDisplayName; private String filamentColorName; + private String filamentColorLabelIt; + private String filamentColorLabelEn; + private String filamentColorLabelDe; + private String filamentColorLabelFr; private String filamentColorHex; private String quality; private BigDecimal nozzleDiameterMm; @@ -73,6 +81,18 @@ public class OrderItemDto { public String getShopVariantColorName() { return shopVariantColorName; } public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = shopVariantColorName; } + public String getShopVariantColorLabelIt() { return shopVariantColorLabelIt; } + public void setShopVariantColorLabelIt(String shopVariantColorLabelIt) { this.shopVariantColorLabelIt = shopVariantColorLabelIt; } + + public String getShopVariantColorLabelEn() { return shopVariantColorLabelEn; } + public void setShopVariantColorLabelEn(String shopVariantColorLabelEn) { this.shopVariantColorLabelEn = shopVariantColorLabelEn; } + + public String getShopVariantColorLabelDe() { return shopVariantColorLabelDe; } + public void setShopVariantColorLabelDe(String shopVariantColorLabelDe) { this.shopVariantColorLabelDe = shopVariantColorLabelDe; } + + public String getShopVariantColorLabelFr() { return shopVariantColorLabelFr; } + public void setShopVariantColorLabelFr(String shopVariantColorLabelFr) { this.shopVariantColorLabelFr = shopVariantColorLabelFr; } + public String getShopVariantColorHex() { return shopVariantColorHex; } public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; } @@ -82,6 +102,18 @@ public class OrderItemDto { public String getFilamentColorName() { return filamentColorName; } public void setFilamentColorName(String filamentColorName) { this.filamentColorName = filamentColorName; } + public String getFilamentColorLabelIt() { return filamentColorLabelIt; } + public void setFilamentColorLabelIt(String filamentColorLabelIt) { this.filamentColorLabelIt = filamentColorLabelIt; } + + public String getFilamentColorLabelEn() { return filamentColorLabelEn; } + public void setFilamentColorLabelEn(String filamentColorLabelEn) { this.filamentColorLabelEn = filamentColorLabelEn; } + + public String getFilamentColorLabelDe() { return filamentColorLabelDe; } + public void setFilamentColorLabelDe(String filamentColorLabelDe) { this.filamentColorLabelDe = filamentColorLabelDe; } + + public String getFilamentColorLabelFr() { return filamentColorLabelFr; } + public void setFilamentColorLabelFr(String filamentColorLabelFr) { this.filamentColorLabelFr = filamentColorLabelFr; } + public String getFilamentColorHex() { return filamentColorHex; } public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java index 265cb8b..ba205a4 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductDetailDto.java @@ -2,6 +2,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.UUID; public record ShopProductDetailDto( @@ -25,6 +26,8 @@ public record ShopProductDetailDto( List variants, PublicMediaUsageDto primaryImage, List images, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java index d563a07..2d4e14e 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductSummaryDto.java @@ -1,6 +1,7 @@ package com.printcalculator.dto; import java.math.BigDecimal; +import java.util.Map; import java.util.UUID; public record ShopProductSummaryDto( @@ -15,6 +16,8 @@ public record ShopProductSummaryDto( BigDecimal priceToChf, ShopProductVariantOptionDto defaultVariant, PublicMediaUsageDto primaryImage, - ShopProductModelDto model3d + ShopProductModelDto model3d, + String publicPath, + Map localizedPaths ) { } diff --git a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java index 318a87c..c959bb4 100644 --- a/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java +++ b/backend/src/main/java/com/printcalculator/dto/ShopProductVariantOptionDto.java @@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto( String sku, String variantLabel, String colorName, + String colorLabel, String colorHex, BigDecimal priceChf, Boolean isDefault diff --git a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java index e2f8bf5..465635e 100644 --- a/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/FilamentVariant.java @@ -24,6 +24,18 @@ public class FilamentVariant { @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) private String colorName; + @Column(name = "color_label_it", length = Integer.MAX_VALUE) + private String colorLabelIt; + + @Column(name = "color_label_en", length = Integer.MAX_VALUE) + private String colorLabelEn; + + @Column(name = "color_label_de", length = Integer.MAX_VALUE) + private String colorLabelDe; + + @Column(name = "color_label_fr", length = Integer.MAX_VALUE) + private String colorLabelFr; + @Column(name = "color_hex", length = Integer.MAX_VALUE) private String colorHex; @@ -93,6 +105,38 @@ public class FilamentVariant { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } @@ -173,4 +217,60 @@ public class FilamentVariant { this.createdAt = createdAt; } + public String getColorLabelForLanguage(String language) { + return resolveLocalizedValue( + language, + colorName, + colorLabelIt, + colorLabelEn, + colorLabelDe, + colorLabelFr + ); + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + 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; + } + } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java index a018a97..87b4dd0 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopCategory.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopCategory.java @@ -15,6 +15,7 @@ import jakarta.persistence.Table; import org.hibernate.annotations.ColumnDefault; import java.time.OffsetDateTime; +import java.util.List; import java.util.UUID; @Entity @@ -23,6 +24,8 @@ import java.util.UUID; @Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order") }) public class ShopCategory { + public static final List SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr"); + @Id @GeneratedValue(strategy = GenerationType.AUTO) @Column(name = "shop_category_id", nullable = false) @@ -38,15 +41,63 @@ public class ShopCategory { @Column(name = "name", nullable = false, length = Integer.MAX_VALUE) private String name; + @Column(name = "name_it", length = Integer.MAX_VALUE) + private String nameIt; + + @Column(name = "name_en", length = Integer.MAX_VALUE) + private String nameEn; + + @Column(name = "name_de", length = Integer.MAX_VALUE) + private String nameDe; + + @Column(name = "name_fr", length = Integer.MAX_VALUE) + private String nameFr; + @Column(name = "description", length = Integer.MAX_VALUE) private String description; + @Column(name = "description_it", length = Integer.MAX_VALUE) + private String descriptionIt; + + @Column(name = "description_en", length = Integer.MAX_VALUE) + private String descriptionEn; + + @Column(name = "description_de", length = Integer.MAX_VALUE) + private String descriptionDe; + + @Column(name = "description_fr", length = Integer.MAX_VALUE) + private String descriptionFr; + @Column(name = "seo_title", length = Integer.MAX_VALUE) private String seoTitle; + @Column(name = "seo_title_it", length = Integer.MAX_VALUE) + private String seoTitleIt; + + @Column(name = "seo_title_en", length = Integer.MAX_VALUE) + private String seoTitleEn; + + @Column(name = "seo_title_de", length = Integer.MAX_VALUE) + private String seoTitleDe; + + @Column(name = "seo_title_fr", length = Integer.MAX_VALUE) + private String seoTitleFr; + @Column(name = "seo_description", length = Integer.MAX_VALUE) private String seoDescription; + @Column(name = "seo_description_it", length = Integer.MAX_VALUE) + private String seoDescriptionIt; + + @Column(name = "seo_description_en", length = Integer.MAX_VALUE) + private String seoDescriptionEn; + + @Column(name = "seo_description_de", length = Integer.MAX_VALUE) + private String seoDescriptionDe; + + @Column(name = "seo_description_fr", length = Integer.MAX_VALUE) + private String seoDescriptionFr; + @Column(name = "og_title", length = Integer.MAX_VALUE) private String ogTitle; @@ -139,6 +190,38 @@ public class ShopCategory { this.name = name; } + public String getNameIt() { + return nameIt; + } + + public void setNameIt(String nameIt) { + this.nameIt = nameIt; + } + + public String getNameEn() { + return nameEn; + } + + public void setNameEn(String nameEn) { + this.nameEn = nameEn; + } + + public String getNameDe() { + return nameDe; + } + + public void setNameDe(String nameDe) { + this.nameDe = nameDe; + } + + public String getNameFr() { + return nameFr; + } + + public void setNameFr(String nameFr) { + this.nameFr = nameFr; + } + public String getDescription() { return description; } @@ -147,6 +230,38 @@ public class ShopCategory { this.description = description; } + public String getDescriptionIt() { + return descriptionIt; + } + + public void setDescriptionIt(String descriptionIt) { + this.descriptionIt = descriptionIt; + } + + public String getDescriptionEn() { + return descriptionEn; + } + + public void setDescriptionEn(String descriptionEn) { + this.descriptionEn = descriptionEn; + } + + public String getDescriptionDe() { + return descriptionDe; + } + + public void setDescriptionDe(String descriptionDe) { + this.descriptionDe = descriptionDe; + } + + public String getDescriptionFr() { + return descriptionFr; + } + + public void setDescriptionFr(String descriptionFr) { + this.descriptionFr = descriptionFr; + } + public String getSeoTitle() { return seoTitle; } @@ -155,6 +270,38 @@ public class ShopCategory { this.seoTitle = seoTitle; } + public String getSeoTitleIt() { + return seoTitleIt; + } + + public void setSeoTitleIt(String seoTitleIt) { + this.seoTitleIt = seoTitleIt; + } + + public String getSeoTitleEn() { + return seoTitleEn; + } + + public void setSeoTitleEn(String seoTitleEn) { + this.seoTitleEn = seoTitleEn; + } + + public String getSeoTitleDe() { + return seoTitleDe; + } + + public void setSeoTitleDe(String seoTitleDe) { + this.seoTitleDe = seoTitleDe; + } + + public String getSeoTitleFr() { + return seoTitleFr; + } + + public void setSeoTitleFr(String seoTitleFr) { + this.seoTitleFr = seoTitleFr; + } + public String getSeoDescription() { return seoDescription; } @@ -163,6 +310,38 @@ public class ShopCategory { this.seoDescription = seoDescription; } + public String getSeoDescriptionIt() { + return seoDescriptionIt; + } + + public void setSeoDescriptionIt(String seoDescriptionIt) { + this.seoDescriptionIt = seoDescriptionIt; + } + + public String getSeoDescriptionEn() { + return seoDescriptionEn; + } + + public void setSeoDescriptionEn(String seoDescriptionEn) { + this.seoDescriptionEn = seoDescriptionEn; + } + + public String getSeoDescriptionDe() { + return seoDescriptionDe; + } + + public void setSeoDescriptionDe(String seoDescriptionDe) { + this.seoDescriptionDe = seoDescriptionDe; + } + + public String getSeoDescriptionFr() { + return seoDescriptionFr; + } + + public void setSeoDescriptionFr(String seoDescriptionFr) { + this.seoDescriptionFr = seoDescriptionFr; + } + public String getOgTitle() { return ogTitle; } @@ -218,4 +397,109 @@ public class ShopCategory { public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + + public String getNameForLanguage(String language) { + return resolveLocalizedValue(language, name, nameIt, nameEn, nameDe, nameFr); + } + + public void setNameForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> nameIt = value; + case "en" -> nameEn = value; + case "de" -> nameDe = value; + case "fr" -> nameFr = value; + default -> { + } + } + } + + public String getDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, description, descriptionIt, descriptionEn, descriptionDe, descriptionFr); + } + + public void setDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> descriptionIt = value; + case "en" -> descriptionEn = value; + case "de" -> descriptionDe = value; + case "fr" -> descriptionFr = value; + default -> { + } + } + } + + public String getSeoTitleForLanguage(String language) { + return resolveLocalizedValue(language, seoTitle, seoTitleIt, seoTitleEn, seoTitleDe, seoTitleFr); + } + + public void setSeoTitleForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoTitleIt = value; + case "en" -> seoTitleEn = value; + case "de" -> seoTitleDe = value; + case "fr" -> seoTitleFr = value; + default -> { + } + } + } + + public String getSeoDescriptionForLanguage(String language) { + return resolveLocalizedValue(language, seoDescription, seoDescriptionIt, seoDescriptionEn, seoDescriptionDe, seoDescriptionFr); + } + + public void setSeoDescriptionForLanguage(String language, String value) { + switch (normalizeLanguage(language)) { + case "it" -> seoDescriptionIt = value; + case "en" -> seoDescriptionEn = value; + case "de" -> seoDescriptionDe = value; + case "fr" -> seoDescriptionFr = value; + default -> { + } + } + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + 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; + } } diff --git a/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java index d1d6d03..24932a3 100644 --- a/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java +++ b/backend/src/main/java/com/printcalculator/entity/ShopProductVariant.java @@ -42,6 +42,18 @@ public class ShopProductVariant { @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) private String colorName; + @Column(name = "color_label_it", length = Integer.MAX_VALUE) + private String colorLabelIt; + + @Column(name = "color_label_en", length = Integer.MAX_VALUE) + private String colorLabelEn; + + @Column(name = "color_label_de", length = Integer.MAX_VALUE) + private String colorLabelDe; + + @Column(name = "color_label_fr", length = Integer.MAX_VALUE) + private String colorLabelFr; + @Column(name = "color_hex", length = Integer.MAX_VALUE) private String colorHex; @@ -152,6 +164,38 @@ public class ShopProductVariant { this.colorName = colorName; } + public String getColorLabelIt() { + return colorLabelIt; + } + + public void setColorLabelIt(String colorLabelIt) { + this.colorLabelIt = colorLabelIt; + } + + public String getColorLabelEn() { + return colorLabelEn; + } + + public void setColorLabelEn(String colorLabelEn) { + this.colorLabelEn = colorLabelEn; + } + + public String getColorLabelDe() { + return colorLabelDe; + } + + public void setColorLabelDe(String colorLabelDe) { + this.colorLabelDe = colorLabelDe; + } + + public String getColorLabelFr() { + return colorLabelFr; + } + + public void setColorLabelFr(String colorLabelFr) { + this.colorLabelFr = colorLabelFr; + } + public String getColorHex() { return colorHex; } @@ -215,4 +259,60 @@ public class ShopProductVariant { public void setUpdatedAt(OffsetDateTime updatedAt) { this.updatedAt = updatedAt; } + + public String getColorLabelForLanguage(String language) { + return resolveLocalizedValue( + language, + colorName, + colorLabelIt, + colorLabelEn, + colorLabelDe, + colorLabelFr + ); + } + + private String resolveLocalizedValue(String language, + String fallback, + String valueIt, + String valueEn, + String valueDe, + String valueFr) { + String normalizedLanguage = normalizeLanguage(language); + String preferred = switch (normalizedLanguage) { + case "it" -> valueIt; + case "en" -> valueEn; + case "de" -> valueDe; + case "fr" -> valueFr; + default -> null; + }; + String resolved = firstNonBlank(preferred, fallback); + if (resolved != null) { + return resolved; + } + return firstNonBlank(valueIt, valueEn, valueDe, valueFr); + } + + private String normalizeLanguage(String language) { + if (language == null) { + return ""; + } + String normalized = language.trim().toLowerCase(); + int separatorIndex = normalized.indexOf('-'); + if (separatorIndex > 0) { + normalized = normalized.substring(0, separatorIndex); + } + 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; + } } diff --git a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java index 27b349c..8772f60 100644 --- a/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java +++ b/backend/src/main/java/com/printcalculator/event/listener/OrderEmailListener.java @@ -223,10 +223,15 @@ public class OrderEmailListener { order.getCreatedAt().format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(locale)) ); templateData.put("totalCost", currencyFormatter.format(order.getTotalChf())); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); return templateData; } + private String buildLogoUrl() { + return frontendBaseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg"; + } + private String applyOrderConfirmationTexts(Map templateData, String language, String orderNumber) { return switch (language) { case "en" -> { diff --git a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java index 5b51980..658a6e9 100644 --- a/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java +++ b/backend/src/main/java/com/printcalculator/repository/QuoteLineItemRepository.java @@ -1,6 +1,7 @@ package com.printcalculator.repository; import com.printcalculator.entity.QuoteLineItem; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -8,9 +9,16 @@ import java.util.Optional; import java.util.UUID; public interface QuoteLineItemRepository extends JpaRepository { + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) List findByQuoteSessionId(UUID quoteSessionId); + + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) List findByQuoteSessionIdOrderByCreatedAtAsc(UUID quoteSessionId); + + @EntityGraph(attributePaths = {"filamentVariant", "shopProduct", "shopProductVariant"}) Optional findByIdAndQuoteSession_Id(UUID lineItemId, UUID quoteSessionId); + + @EntityGraph(attributePaths = {"shopProductVariant"}) Optional findFirstByQuoteSession_IdAndLineItemTypeAndShopProductVariant_Id( UUID quoteSessionId, String lineItemType, diff --git a/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java b/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java new file mode 100644 index 0000000..47321d4 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/security/AdminCsrfProtectionFilter.java @@ -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 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/OrderService.java b/backend/src/main/java/com/printcalculator/service/OrderService.java index 9179f7d..db3875a 100644 --- a/backend/src/main/java/com/printcalculator/service/OrderService.java +++ b/backend/src/main/java/com/printcalculator/service/OrderService.java @@ -29,6 +29,7 @@ import java.util.*; @Service public class OrderService { private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); + private static final String SHOP_LINE_ITEM_TYPE = "SHOP_PRODUCT"; private final OrderRepository orderRepo; private final OrderItemRepository orderItemRepo; @@ -235,18 +236,20 @@ public class OrderService { oItem = orderItemRepo.save(oItem); - String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; - oItem.setStoredRelativePath(relativePath); - Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); if (sourcePath == null || !Files.exists(sourcePath)) { - throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); - } - try { - storageService.store(sourcePath, Paths.get(relativePath)); - oItem.setFileSizeBytes(Files.size(sourcePath)); - } catch (IOException e) { - throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); + if (requiresStoredSourceFile(qItem)) { + throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); + } + } else { + String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename; + oItem.setStoredRelativePath(relativePath); + try { + storageService.store(sourcePath, Paths.get(relativePath)); + oItem.setFileSizeBytes(Files.size(sourcePath)); + } catch (IOException e) { + throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); + } } oItem = orderItemRepo.save(oItem); @@ -318,6 +321,12 @@ public class OrderService { return "stl"; } + private boolean requiresStoredSourceFile(QuoteLineItem qItem) { + return !SHOP_LINE_ITEM_TYPE.equalsIgnoreCase( + qItem.getLineItemType() != null ? qItem.getLineItemType() : "" + ); + } + private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) { if (storedPath == null || storedPath.isBlank()) { return null; diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java index 1fc2de4..b540984 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminFilamentControllerService.java @@ -161,10 +161,21 @@ public class AdminFilamentControllerService { String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); String normalizedBrand = normalizeOptional(payload.getBrand()); + String fallbackColorLabel = firstNonBlank( + normalizeOptional(payload.getColorLabelIt()), + normalizeOptional(payload.getColorLabelEn()), + normalizeOptional(payload.getColorLabelDe()), + normalizeOptional(payload.getColorLabelFr()), + normalizedColorName + ); variant.setFilamentMaterialType(material); variant.setVariantDisplayName(normalizedDisplayName); variant.setColorName(normalizedColorName); + variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel)); + variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel)); + variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel)); + variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel)); variant.setColorHex(normalizedColorHex); variant.setFinishType(normalizedFinishType); variant.setBrand(normalizedBrand); @@ -226,6 +237,18 @@ public class AdminFilamentControllerService { return normalized.isBlank() ? null : 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 FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) { if (payload == null || payload.getMaterialTypeId() == null) { throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); @@ -306,6 +329,10 @@ public class AdminFilamentControllerService { dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setColorName(variant.getColorName()); + dto.setColorLabelIt(variant.getColorLabelIt()); + dto.setColorLabelEn(variant.getColorLabelEn()); + dto.setColorLabelDe(variant.getColorLabelDe()); + dto.setColorLabelFr(variant.getColorLabelFr()); dto.setColorHex(variant.getColorHex()); dto.setFinishType(variant.getFinishType()); dto.setBrand(variant.getBrand()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java index e327ac6..e7665c9 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopCategoryControllerService.java @@ -67,13 +67,13 @@ public class AdminShopCategoryControllerService { @Transactional public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) { ensurePayload(payload); - String normalizedName = normalizeRequiredName(payload.getName()); - String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); ensureSlugAvailable(normalizedSlug, null); ShopCategory category = new ShopCategory(); category.setCreatedAt(OffsetDateTime.now()); - applyPayload(category, payload, normalizedName, normalizedSlug, null); + applyPayload(category, payload, localizedContent, normalizedSlug, null); ShopCategory saved = shopCategoryRepository.save(category); return getCategory(saved.getId()); @@ -86,11 +86,11 @@ public class AdminShopCategoryControllerService { ShopCategory category = shopCategoryRepository.findById(categoryId) .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); - String normalizedName = normalizeRequiredName(payload.getName()); - String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); + LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload); + String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName()); ensureSlugAvailable(normalizedSlug, category.getId()); - applyPayload(category, payload, normalizedName, normalizedSlug, category.getId()); + applyPayload(category, payload, localizedContent, normalizedSlug, category.getId()); ShopCategory saved = shopCategoryRepository.save(category); return getCategory(saved.getId()); } @@ -112,17 +112,33 @@ public class AdminShopCategoryControllerService { private void applyPayload(ShopCategory category, AdminUpsertShopCategoryRequest payload, - String normalizedName, + LocalizedCategoryContent localizedContent, String normalizedSlug, UUID currentCategoryId) { ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId); category.setParentCategory(parentCategory); category.setSlug(normalizedSlug); - category.setName(normalizedName); - category.setDescription(normalizeOptional(payload.getDescription())); - category.setSeoTitle(normalizeOptional(payload.getSeoTitle())); - category.setSeoDescription(normalizeOptional(payload.getSeoDescription())); + category.setName(localizedContent.defaultName()); + category.setNameIt(localizedContent.names().get("it")); + category.setNameEn(localizedContent.names().get("en")); + category.setNameDe(localizedContent.names().get("de")); + category.setNameFr(localizedContent.names().get("fr")); + category.setDescription(localizedContent.defaultDescription()); + category.setDescriptionIt(localizedContent.descriptions().get("it")); + category.setDescriptionEn(localizedContent.descriptions().get("en")); + category.setDescriptionDe(localizedContent.descriptions().get("de")); + category.setDescriptionFr(localizedContent.descriptions().get("fr")); + category.setSeoTitle(localizedContent.defaultSeoTitle()); + category.setSeoTitleIt(localizedContent.seoTitles().get("it")); + category.setSeoTitleEn(localizedContent.seoTitles().get("en")); + category.setSeoTitleDe(localizedContent.seoTitles().get("de")); + category.setSeoTitleFr(localizedContent.seoTitles().get("fr")); + category.setSeoDescription(localizedContent.defaultSeoDescription()); + category.setSeoDescriptionIt(localizedContent.seoDescriptions().get("it")); + category.setSeoDescriptionEn(localizedContent.seoDescriptions().get("en")); + category.setSeoDescriptionDe(localizedContent.seoDescriptions().get("de")); + category.setSeoDescriptionFr(localizedContent.seoDescriptions().get("fr")); category.setOgTitle(normalizeOptional(payload.getOgTitle())); category.setOgDescription(normalizeOptional(payload.getOgDescription())); category.setIndexable(payload.getIndexable() == null || payload.getIndexable()); @@ -161,14 +177,6 @@ public class AdminShopCategoryControllerService { } } - private String normalizeRequiredName(String name) { - String normalized = normalizeOptional(name); - if (normalized == null) { - throw new ResponseStatusException(BAD_REQUEST, "Category name is required"); - } - return normalized; - } - private String normalizeAndValidateSlug(String slug, String fallbackName) { String source = normalizeOptional(slug); if (source == null) { @@ -203,6 +211,103 @@ public class AdminShopCategoryControllerService { return normalized.isBlank() ? null : normalized; } + private String normalizeRequired(String value, String message) { + String normalized = normalizeOptional(value); + if (normalized == null) { + throw new ResponseStatusException(BAD_REQUEST, message); + } + return normalized; + } + + private LocalizedCategoryContent normalizeLocalizedCategoryContent(AdminUpsertShopCategoryRequest payload) { + String legacyName = normalizeOptional(payload.getName()); + String fallbackName = firstNonBlank( + legacyName, + normalizeOptional(payload.getNameIt()), + normalizeOptional(payload.getNameEn()), + normalizeOptional(payload.getNameDe()), + normalizeOptional(payload.getNameFr()) + ); + if (fallbackName == null) { + throw new ResponseStatusException(BAD_REQUEST, "Category name is required"); + } + + Map names = new LinkedHashMap<>(); + names.put("it", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameIt()), fallbackName), "Italian category name is required")); + names.put("en", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameEn()), fallbackName), "English category name is required")); + names.put("de", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameDe()), fallbackName), "German category name is required")); + names.put("fr", normalizeRequired(firstNonBlank(normalizeOptional(payload.getNameFr()), fallbackName), "French category name is required")); + + String fallbackDescription = firstNonBlank( + normalizeOptional(payload.getDescription()), + normalizeOptional(payload.getDescriptionIt()), + normalizeOptional(payload.getDescriptionEn()), + normalizeOptional(payload.getDescriptionDe()), + normalizeOptional(payload.getDescriptionFr()) + ); + Map descriptions = new LinkedHashMap<>(); + descriptions.put("it", firstNonBlank(normalizeOptional(payload.getDescriptionIt()), fallbackDescription)); + descriptions.put("en", firstNonBlank(normalizeOptional(payload.getDescriptionEn()), fallbackDescription)); + descriptions.put("de", firstNonBlank(normalizeOptional(payload.getDescriptionDe()), fallbackDescription)); + descriptions.put("fr", firstNonBlank(normalizeOptional(payload.getDescriptionFr()), fallbackDescription)); + + String fallbackSeoTitle = firstNonBlank( + normalizeOptional(payload.getSeoTitle()), + normalizeOptional(payload.getSeoTitleIt()), + normalizeOptional(payload.getSeoTitleEn()), + normalizeOptional(payload.getSeoTitleDe()), + normalizeOptional(payload.getSeoTitleFr()) + ); + Map seoTitles = new LinkedHashMap<>(); + seoTitles.put("it", firstNonBlank(normalizeOptional(payload.getSeoTitleIt()), fallbackSeoTitle)); + seoTitles.put("en", firstNonBlank(normalizeOptional(payload.getSeoTitleEn()), fallbackSeoTitle)); + seoTitles.put("de", firstNonBlank(normalizeOptional(payload.getSeoTitleDe()), fallbackSeoTitle)); + seoTitles.put("fr", firstNonBlank(normalizeOptional(payload.getSeoTitleFr()), fallbackSeoTitle)); + + String fallbackSeoDescription = firstNonBlank( + normalizeOptional(payload.getSeoDescription()), + normalizeOptional(payload.getSeoDescriptionIt()), + normalizeOptional(payload.getSeoDescriptionEn()), + normalizeOptional(payload.getSeoDescriptionDe()), + normalizeOptional(payload.getSeoDescriptionFr()) + ); + Map seoDescriptions = new LinkedHashMap<>(); + seoDescriptions.put("it", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionIt()), fallbackSeoDescription), "Italian")); + seoDescriptions.put("en", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionEn()), fallbackSeoDescription), "English")); + seoDescriptions.put("de", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionDe()), fallbackSeoDescription), "German")); + seoDescriptions.put("fr", validateSeoDescriptionLength(firstNonBlank(normalizeOptional(payload.getSeoDescriptionFr()), fallbackSeoDescription), "French")); + + return new LocalizedCategoryContent( + names.get("it"), + firstNonBlank(descriptions.get("it"), fallbackDescription), + firstNonBlank(seoTitles.get("it"), fallbackSeoTitle), + firstNonBlank(seoDescriptions.get("it"), fallbackSeoDescription), + names, + descriptions, + seoTitles, + seoDescriptions + ); + } + + private String validateSeoDescriptionLength(String value, String languageLabel) { + if (value != null && value.length() > 160) { + throw new ResponseStatusException(BAD_REQUEST, languageLabel + " SEO description must be at most 160 characters"); + } + return value; + } + + private String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } + private CategoryContext buildContext() { List categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); List products = shopProductRepository.findAll(); @@ -278,9 +383,25 @@ public class AdminShopCategoryControllerService { dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null); dto.setSlug(category.getSlug()); dto.setName(category.getName()); + dto.setNameIt(category.getNameIt()); + dto.setNameEn(category.getNameEn()); + dto.setNameDe(category.getNameDe()); + dto.setNameFr(category.getNameFr()); dto.setDescription(category.getDescription()); + dto.setDescriptionIt(category.getDescriptionIt()); + dto.setDescriptionEn(category.getDescriptionEn()); + dto.setDescriptionDe(category.getDescriptionDe()); + dto.setDescriptionFr(category.getDescriptionFr()); dto.setSeoTitle(category.getSeoTitle()); + dto.setSeoTitleIt(category.getSeoTitleIt()); + dto.setSeoTitleEn(category.getSeoTitleEn()); + dto.setSeoTitleDe(category.getSeoTitleDe()); + dto.setSeoTitleFr(category.getSeoTitleFr()); dto.setSeoDescription(category.getSeoDescription()); + dto.setSeoDescriptionIt(category.getSeoDescriptionIt()); + dto.setSeoDescriptionEn(category.getSeoDescriptionEn()); + dto.setSeoDescriptionDe(category.getSeoDescriptionDe()); + dto.setSeoDescriptionFr(category.getSeoDescriptionFr()); dto.setOgTitle(category.getOgTitle()); dto.setOgDescription(category.getOgDescription()); dto.setIndexable(category.getIndexable()); @@ -331,4 +452,16 @@ public class AdminShopCategoryControllerService { Map descendantProductCounts ) { } + + private record LocalizedCategoryContent( + String defaultName, + String defaultDescription, + String defaultSeoTitle, + String defaultSeoDescription, + Map names, + Map descriptions, + Map seoTitles, + Map seoDescriptions + ) { + } } diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java index f562a92..225d952 100644 --- a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductControllerService.java @@ -353,6 +353,13 @@ public class AdminShopProductControllerService { String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel()); String normalizedSku = normalizeOptional(payload.getSku()); + String fallbackColorLabel = firstNonBlank( + normalizeOptional(payload.getColorLabelIt()), + normalizeOptional(payload.getColorLabelEn()), + normalizeOptional(payload.getColorLabelDe()), + normalizeOptional(payload.getColorLabelFr()), + normalizedColorName + ); String normalizedMaterialCode = normalizeRequired( payload.getInternalMaterialCode(), "Variant internalMaterialCode is required" @@ -380,6 +387,10 @@ public class AdminShopProductControllerService { variant.setSku(normalizedSku); variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName); variant.setColorName(normalizedColorName); + variant.setColorLabelIt(firstNonBlank(normalizeOptional(payload.getColorLabelIt()), fallbackColorLabel)); + variant.setColorLabelEn(firstNonBlank(normalizeOptional(payload.getColorLabelEn()), fallbackColorLabel)); + variant.setColorLabelDe(firstNonBlank(normalizeOptional(payload.getColorLabelDe()), fallbackColorLabel)); + variant.setColorLabelFr(firstNonBlank(normalizeOptional(payload.getColorLabelFr()), fallbackColorLabel)); variant.setColorHex(normalizeColorHex(payload.getColorHex())); variant.setInternalMaterialCode(normalizedMaterialCode); variant.setPriceChf(price); @@ -531,6 +542,10 @@ public class AdminShopProductControllerService { dto.setSku(variant.getSku()); dto.setVariantLabel(variant.getVariantLabel()); dto.setColorName(variant.getColorName()); + dto.setColorLabelIt(variant.getColorLabelIt()); + dto.setColorLabelEn(variant.getColorLabelEn()); + dto.setColorLabelDe(variant.getColorLabelDe()); + dto.setColorLabelFr(variant.getColorLabelFr()); dto.setColorHex(variant.getColorHex()); dto.setInternalMaterialCode(variant.getInternalMaterialCode()); dto.setPriceChf(variant.getPriceChf()); diff --git a/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java new file mode 100644 index 0000000..84fa20a --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/admin/AdminShopProductTranslationService.java @@ -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 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 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 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 names = normalizeLocalizedMap(payload.getNames(), false); + Map excerpts = normalizeLocalizedMap(payload.getExcerpts(), false); + Map descriptions = normalizeLocalizedMap(payload.getDescriptions(), true); + Map seoTitles = normalizeLocalizedMap(payload.getSeoTitles(), false); + Map 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 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 resolveTargetLanguages(NormalizedTranslationRequest request) { + List 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 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 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 targetLanguages, + CategoryContext categoryContext, + List 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 targetLanguages) { + ObjectNode node = objectMapper.createObjectNode(); + for (String language : targetLanguages) { + node.set(language, localizedFieldNode(request, language)); + } + return node; + } + + private ObjectNode buildTranslationToolSchema(List 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 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 buildValidationNotes(TranslationBundle bundle, List targetLanguages) { + List 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 targetLanguages) { + Map names = new LinkedHashMap<>(); + Map excerpts = new LinkedHashMap<>(); + Map descriptions = new LinkedHashMap<>(); + Map seoTitles = new LinkedHashMap<>(); + Map 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 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 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 normalizeLocalizedMap(Map rawValues, boolean richText) { + Map 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 materialCodes, + Map names, + Map excerpts, + Map descriptions, + Map seoTitles, + Map seoDescriptions) { + } + + private record CategoryContext(String slug, + Map names, + Map descriptions) { + } + + private record TranslationBundle(Map names, + Map excerpts, + Map descriptions, + Map seoTitles, + Map seoDescriptions) { + static TranslationBundle fromJson(JsonNode translationsNode) { + Map names = new LinkedHashMap<>(); + Map excerpts = new LinkedHashMap<>(); + Map descriptions = new LinkedHashMap<>(); + Map seoTitles = new LinkedHashMap<>(); + Map 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; + } + } +} diff --git a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java index 164ac74..2e15aa8 100644 --- a/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/AdminOrderControllerService.java @@ -280,11 +280,19 @@ public class AdminOrderControllerService { itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null); + itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null); + itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null); + itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null); itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt()); + itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn()); + itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe()); + itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); } itemDto.setQuality(item.getQuality()); diff --git a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java index 9b1ae40..69c36cb 100644 --- a/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java +++ b/backend/src/main/java/com/printcalculator/service/order/OrderControllerService.java @@ -334,11 +334,19 @@ public class OrderControllerService { itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantColorName(item.getShopVariantColorName()); + itemDto.setShopVariantColorLabelIt(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null); + itemDto.setShopVariantColorLabelEn(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null); + itemDto.setShopVariantColorLabelDe(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null); + itemDto.setShopVariantColorLabelFr(item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null); itemDto.setShopVariantColorHex(item.getShopVariantColorHex()); if (item.getFilamentVariant() != null) { itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); + itemDto.setFilamentColorLabelIt(item.getFilamentVariant().getColorLabelIt()); + itemDto.setFilamentColorLabelEn(item.getFilamentVariant().getColorLabelEn()); + itemDto.setFilamentColorLabelDe(item.getFilamentVariant().getColorLabelDe()); + itemDto.setFilamentColorLabelFr(item.getFilamentVariant().getColorLabelFr()); itemDto.setFilamentColorHex(item.getFilamentVariant().getColorHex()); } itemDto.setQuality(item.getQuality()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java index 50496b3..24a2737 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionItemService.java @@ -72,10 +72,14 @@ public class QuoteSessionItemService { 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()); Path sessionStorageDir = quoteStorageService.sessionStorageDir(session.getId()); - String ext = quoteStorageService.getSafeExtension(file.getOriginalFilename(), "stl"); String storedFilename = UUID.randomUUID() + "." + ext; Path persistentPath = quoteStorageService.resolveSessionPath(sessionStorageDir, storedFilename); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java index 555ecc5..375d7fa 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteSessionResponseAssembler.java @@ -81,7 +81,15 @@ public class QuoteSessionResponseAssembler { dto.put("shopProductName", item.getShopProductName()); dto.put("shopVariantLabel", item.getShopVariantLabel()); dto.put("shopVariantColorName", item.getShopVariantColorName()); + dto.put("shopVariantColorLabelIt", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelIt() : null); + dto.put("shopVariantColorLabelEn", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelEn() : null); + dto.put("shopVariantColorLabelDe", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelDe() : null); + dto.put("shopVariantColorLabelFr", item.getShopProductVariant() != null ? item.getShopProductVariant().getColorLabelFr() : null); dto.put("shopVariantColorHex", item.getShopVariantColorHex()); + dto.put("filamentColorLabelIt", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelIt() : null); + dto.put("filamentColorLabelEn", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelEn() : null); + dto.put("filamentColorLabelDe", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelDe() : null); + dto.put("filamentColorLabelFr", item.getFilamentVariant() != null ? item.getFilamentVariant().getColorLabelFr() : null); dto.put("materialCode", item.getMaterialCode()); dto.put("quality", item.getQuality()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); diff --git a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java index 87e5e44..b1359df 100644 --- a/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java +++ b/backend/src/main/java/com/printcalculator/service/quote/QuoteStorageService.java @@ -54,7 +54,6 @@ public class QuoteStorageService { return switch (ext) { case "stl" -> "stl"; case "3mf" -> "3mf"; - case "step", "stp" -> "step"; default -> fallback; }; } diff --git a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java index 6c6d53c..41ecddd 100644 --- a/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java +++ b/backend/src/main/java/com/printcalculator/service/request/CustomQuoteRequestNotificationService.java @@ -30,6 +30,9 @@ public class CustomQuoteRequestNotificationService { @Value("${app.mail.contact-request.customer.enabled:true}") private boolean contactRequestCustomerMailEnabled; + @Value("${app.frontend.base-url:http://localhost:4200}") + private String frontendBaseUrl; + public CustomQuoteRequestNotificationService(EmailNotificationService emailNotificationService, ContactRequestLocalizationService localizationService) { this.emailNotificationService = emailNotificationService; @@ -63,6 +66,7 @@ public class CustomQuoteRequestNotificationService { templateData.put("phone", safeValue(request.getPhone())); templateData.put("message", safeValue(request.getMessage())); templateData.put("attachmentsCount", attachmentsCount); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); emailNotificationService.sendEmail( @@ -101,6 +105,7 @@ public class CustomQuoteRequestNotificationService { templateData.put("phone", safeValue(request.getPhone())); templateData.put("message", safeValue(request.getMessage())); templateData.put("attachmentsCount", attachmentsCount); + templateData.put("logoUrl", buildLogoUrl()); templateData.put("currentYear", Year.now().getValue()); String subject = localizationService.applyCustomerContactRequestTexts(templateData, language, request.getId()); @@ -119,4 +124,11 @@ public class CustomQuoteRequestNotificationService { } return value; } + + private String buildLogoUrl() { + String baseUrl = frontendBaseUrl == null || frontendBaseUrl.isBlank() + ? "http://localhost:4200" + : frontendBaseUrl; + return baseUrl.replaceAll("/+$", "") + "/assets/images/brand-logo-yellow.svg"; + } } diff --git a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index e37c450..1ecd99e 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java @@ -71,7 +71,7 @@ public class PublicShopCatalogService { public List getCategories(String language) { CategoryContext categoryContext = loadCategoryContext(language); - return buildCategoryTree(null, categoryContext); + return buildCategoryTree(null, categoryContext, language); } public ShopCategoryDetailDto getCategory(String slug, String language) { @@ -83,7 +83,7 @@ public class PublicShopCatalogService { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); } - return buildCategoryDetail(category, categoryContext); + return buildCategoryDetail(category, categoryContext, language); } public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) { @@ -114,7 +114,7 @@ public class PublicShopCatalogService { .toList(); ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null - ? buildCategoryDetail(selectedCategory, categoryContext) + ? buildCategoryDetail(selectedCategory, categoryContext, language) : null; return new ShopProductCatalogResponseDto( @@ -126,24 +126,40 @@ public class PublicShopCatalogService { } public ShopProductDetailDto getProduct(String slug, String language) { - CategoryContext categoryContext = loadCategoryContext(language); - PublicProductContext productContext = loadPublicProductContext(categoryContext, language); + String normalizedLanguage = normalizeLanguage(language); + CategoryContext categoryContext = loadCategoryContext(normalizedLanguage); + PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage); + ProductEntry entry = requirePublicProductEntry( + productContext.entriesBySlug().get(slug), + categoryContext + ); + return toProductDetailDto( + entry, + productContext.productMediaBySlug(), + productContext.variantColorHexByMaterialAndColor(), + normalizedLanguage + ); + } - ProductEntry entry = productContext.entriesBySlug().get(slug); - if (entry == null) { + public ShopProductDetailDto getProductByPublicPath(String publicPathSegment, String language) { + String normalizedLanguage = normalizeLanguage(language); + String normalizedPublicPath = normalizePublicPathSegment(publicPathSegment); + if (normalizedPublicPath == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); } - ShopCategory category = entry.product().getCategory(); - if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); - } + CategoryContext categoryContext = loadCategoryContext(normalizedLanguage); + PublicProductContext productContext = loadPublicProductContext(categoryContext, normalizedLanguage); + ProductEntry entry = requirePublicProductEntry( + productContext.entriesByPublicPath().get(normalizedPublicPath), + categoryContext + ); return toProductDetailDto( entry, productContext.productMediaBySlug(), productContext.variantColorHexByMaterialAndColor(), - language + normalizedLanguage ); } @@ -197,6 +213,7 @@ public class PublicShopCatalogService { } private PublicProductContext loadPublicProductContext(CategoryContext categoryContext, String language) { + String normalizedLanguage = normalizeLanguage(language); List entries = loadPublicProducts(categoryContext.categoriesById().keySet()); Map> productMediaBySlug = publicMediaQueryService.getUsageMediaMap( SHOP_PRODUCT_MEDIA_USAGE_TYPE, @@ -207,8 +224,21 @@ public class PublicShopCatalogService { Map entriesBySlug = entries.stream() .collect(Collectors.toMap(entry -> entry.product().getSlug(), entry -> entry, (left, right) -> left, LinkedHashMap::new)); + Map entriesByPublicPath = entries.stream() + .collect(Collectors.toMap( + entry -> normalizePublicPathSegment(ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage)), + entry -> entry, + (left, right) -> left, + LinkedHashMap::new + )); - return new PublicProductContext(entries, entriesBySlug, productMediaBySlug, variantColorHexByMaterialAndColor); + return new PublicProductContext( + entries, + entriesBySlug, + entriesByPublicPath, + productMediaBySlug, + variantColorHexByMaterialAndColor + ); } private Map buildFilamentVariantColorHexMap() { @@ -316,53 +346,63 @@ public class PublicShopCatalogService { return total; } - private List buildCategoryTree(UUID parentId, CategoryContext categoryContext) { + private List buildCategoryTree(UUID parentId, + CategoryContext categoryContext, + String language) { return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream() .map(category -> new ShopCategoryTreeDto( category.getId(), category.getParentCategory() != null ? category.getParentCategory().getId() : null, category.getSlug(), - category.getName(), - category.getDescription(), - category.getSeoTitle(), - category.getSeoDescription(), + category.getNameForLanguage(language), + category.getDescriptionForLanguage(language), + category.getSeoTitleForLanguage(language), + category.getSeoDescriptionForLanguage(language), category.getOgTitle(), category.getOgDescription(), category.getIndexable(), category.getSortOrder(), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))), - buildCategoryTree(category.getId(), categoryContext) + buildCategoryTree(category.getId(), categoryContext, language) )) .toList(); } - private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) { + private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, + CategoryContext categoryContext, + String language) { List images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of()); + String localizedSeoTitle = category.getSeoTitleForLanguage(language); + String localizedSeoDescription = category.getSeoDescriptionForLanguage(language); return new ShopCategoryDetailDto( category.getId(), category.getSlug(), - category.getName(), - category.getDescription(), - category.getSeoTitle(), - category.getSeoDescription(), + category.getNameForLanguage(language), + category.getDescriptionForLanguage(language), + localizedSeoTitle, + localizedSeoDescription, category.getOgTitle(), category.getOgDescription(), category.getIndexable(), category.getSortOrder(), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), - buildCategoryBreadcrumbs(category), + buildCategoryBreadcrumbs(category, language), selectPrimaryMedia(images), images, - buildCategoryTree(category.getId(), categoryContext) + buildCategoryTree(category.getId(), categoryContext, language) ); } - private List buildCategoryBreadcrumbs(ShopCategory category) { + private List buildCategoryBreadcrumbs(ShopCategory category, String language) { List breadcrumbs = new ArrayList<>(); ShopCategory current = category; while (current != null) { - breadcrumbs.add(new ShopCategoryRefDto(current.getId(), current.getSlug(), current.getName())); + breadcrumbs.add(new ShopCategoryRefDto( + current.getId(), + current.getSlug(), + current.getNameForLanguage(language) + )); current = current.getParentCategory(); } java.util.Collections.reverse(breadcrumbs); @@ -389,6 +429,9 @@ public class PublicShopCatalogService { Map variantColorHexByMaterialAndColor, String language) { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); + String normalizedLanguage = normalizeLanguage(language); + String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); return new ShopProductSummaryDto( entry.product().getId(), entry.product().getSlug(), @@ -399,13 +442,15 @@ public class PublicShopCatalogService { new ShopCategoryRefDto( entry.product().getCategory().getId(), entry.product().getCategory().getSlug(), - entry.product().getCategory().getName() + entry.product().getCategory().getNameForLanguage(language) ), resolvePriceFrom(entry.variants()), resolvePriceTo(entry.variants()), - toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), + toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), selectPrimaryMedia(images), - toProductModelDto(entry) + toProductModelDto(entry), + publicPathSegment, + localizedPaths ); } @@ -416,8 +461,10 @@ public class PublicShopCatalogService { List images = productMediaBySlug.getOrDefault(productMediaUsageKey(entry.product()), List.of()); String localizedSeoTitle = entry.product().getSeoTitleForLanguage(language); String localizedSeoDescription = entry.product().getSeoDescriptionForLanguage(language); - return new ShopProductDetailDto( - entry.product().getId(), + String normalizedLanguage = normalizeLanguage(language); + String publicPathSegment = ShopPublicPathSupport.buildProductPathSegment(entry.product(), normalizedLanguage); + Map localizedPaths = ShopPublicPathSupport.buildLocalizedProductPaths(entry.product()); + return new ShopProductDetailDto(entry.product().getId(), entry.product().getSlug(), entry.product().getNameForLanguage(language), entry.product().getExcerptForLanguage(language), @@ -432,24 +479,27 @@ public class PublicShopCatalogService { new ShopCategoryRefDto( entry.product().getCategory().getId(), entry.product().getCategory().getSlug(), - entry.product().getCategory().getName() + entry.product().getCategory().getNameForLanguage(language) ), - buildCategoryBreadcrumbs(entry.product().getCategory()), + buildCategoryBreadcrumbs(entry.product().getCategory(), language), resolvePriceFrom(entry.variants()), resolvePriceTo(entry.variants()), - toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), + toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language), entry.variants().stream() - .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor)) + .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language)) .toList(), selectPrimaryMedia(images), images, - toProductModelDto(entry) + toProductModelDto(entry), + publicPathSegment, + localizedPaths ); } private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant, - Map variantColorHexByMaterialAndColor) { + Map variantColorHexByMaterialAndColor, + String language) { if (variant == null) { return null; } @@ -463,6 +513,7 @@ public class PublicShopCatalogService { variant.getSku(), variant.getVariantLabel(), variant.getColorName(), + variant.getColorLabelForLanguage(language), colorHex, variant.getPriceChf(), defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) @@ -494,6 +545,27 @@ public class PublicShopCatalogService { return raw.toLowerCase(Locale.ROOT); } + private ProductEntry requirePublicProductEntry(ProductEntry entry, CategoryContext categoryContext) { + if (entry == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + ShopCategory category = entry.product().getCategory(); + if (category == null || !categoryContext.categoriesById().containsKey(category.getId())) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found"); + } + + return entry; + } + + private String normalizePublicPathSegment(String publicPathSegment) { + String normalized = trimToNull(publicPathSegment); + if (normalized == null) { + return null; + } + return normalized.toLowerCase(Locale.ROOT); + } + private String trimToNull(String value) { String raw = String.valueOf(value == null ? "" : value).trim(); if (raw.isEmpty()) { @@ -502,6 +574,22 @@ public class PublicShopCatalogService { 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) { if (entry.modelAsset() == null) { return null; @@ -573,6 +661,7 @@ public class PublicShopCatalogService { private record PublicProductContext( List entries, Map entriesBySlug, + Map entriesByPublicPath, Map> productMediaBySlug, Map variantColorHexByMaterialAndColor ) { diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java new file mode 100644 index 0000000..cd16503 --- /dev/null +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopPublicPathSupport.java @@ -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 buildLocalizedProductPaths(ShopProduct product) { + Map 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; + } +} diff --git a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java index e3cf38a..54ad68f 100644 --- a/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java +++ b/backend/src/main/java/com/printcalculator/service/shop/ShopSitemapService.java @@ -11,7 +11,6 @@ import org.springframework.transaction.annotation.Transactional; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.text.Normalizer; import java.time.Clock; import java.time.Duration; import java.time.Instant; @@ -19,7 +18,6 @@ import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -31,6 +29,12 @@ public class ShopSitemapService { private static final List SUPPORTED_LANGUAGES = ShopProduct.SUPPORTED_LANGUAGES; private static final String DEFAULT_LANGUAGE = "it"; private static final DateTimeFormatter LASTMOD_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + private static final Map HREFLANG_BY_LANGUAGE = Map.of( + "it", "it-CH", + "en", "en-CH", + "de", "de-CH", + "fr", "fr-CH" + ); private final ShopCategoryRepository shopCategoryRepository; private final ShopProductRepository shopProductRepository; @@ -130,7 +134,7 @@ public class ShopSitemapService { Map hrefByLanguage = new LinkedHashMap<>(); 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)); } @@ -169,7 +173,7 @@ public class ShopSitemapService { continue; } xml.append(" \n"); @@ -186,48 +190,6 @@ public class ShopSitemapService { xml.append(" \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) { String safeSegment = rawSegment == null ? "" : rawSegment; return URLEncoder.encode(safeSegment, StandardCharsets.UTF_8).replace("+", "%20"); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 8fbd17c..486bb00 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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.customer.enabled=${APP_MAIL_CONTACT_REQUEST_CUSTOMER_ENABLED:true} app.frontend.base-url=${APP_FRONTEND_BASE_URL:http://localhost:4200} +app.cors.additional-allowed-origins=${APP_CORS_ADDITIONAL_ALLOWED_ORIGINS:} app.sitemap.shop.cache-seconds=${APP_SITEMAP_SHOP_CACHE_SECONDS:3600} +openai.translation.api-key=${OPENAI_API_KEY:} +openai.translation.base-url=${OPENAI_BASE_URL:https://api.openai.com/v1} +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.password=${ADMIN_PASSWORD} diff --git a/backend/src/main/resources/templates/email/contact-request-admin.html b/backend/src/main/resources/templates/email/contact-request-admin.html index 4341c34..dcecd57 100644 --- a/backend/src/main/resources/templates/email/contact-request-admin.html +++ b/backend/src/main/resources/templates/email/contact-request-admin.html @@ -25,6 +25,21 @@ 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 { color: #444444; line-height: 1.5; @@ -63,7 +78,10 @@
-

Nuova richiesta di contatto

+
+ +

Nuova richiesta di contatto

+

E' stata ricevuta una nuova richiesta dal form contatti/su misura.

diff --git a/backend/src/main/resources/templates/email/contact-request-customer.html b/backend/src/main/resources/templates/email/contact-request-customer.html index d308b0c..35def67 100644 --- a/backend/src/main/resources/templates/email/contact-request-customer.html +++ b/backend/src/main/resources/templates/email/contact-request-customer.html @@ -25,6 +25,21 @@ 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 { margin-top: 18px; color: #222222; @@ -69,7 +84,10 @@
-

We received your contact request

+
+ +

We received your contact request

+

Hi customer,

Thank you for contacting us. Our team will reply as soon as possible.

diff --git a/backend/src/main/resources/templates/email/order-confirmation.html b/backend/src/main/resources/templates/email/order-confirmation.html index 37a6082..d8e4b86 100644 --- a/backend/src/main/resources/templates/email/order-confirmation.html +++ b/backend/src/main/resources/templates/email/order-confirmation.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -67,6 +76,7 @@

+

Thank you for your order #00000000

diff --git a/backend/src/main/resources/templates/email/order-shipped.html b/backend/src/main/resources/templates/email/order-shipped.html index 74f5aa7..1d06f44 100644 --- a/backend/src/main/resources/templates/email/order-shipped.html +++ b/backend/src/main/resources/templates/email/order-shipped.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Your order #00000000 has been shipped

diff --git a/backend/src/main/resources/templates/email/payment-confirmed.html b/backend/src/main/resources/templates/email/payment-confirmed.html index 657f1ef..c8c0f75 100644 --- a/backend/src/main/resources/templates/email/payment-confirmed.html +++ b/backend/src/main/resources/templates/email/payment-confirmed.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Payment confirmed for order #00000000

diff --git a/backend/src/main/resources/templates/email/payment-reported.html b/backend/src/main/resources/templates/email/payment-reported.html index c7d2b72..94abd0f 100644 --- a/backend/src/main/resources/templates/email/payment-reported.html +++ b/backend/src/main/resources/templates/email/payment-reported.html @@ -27,8 +27,17 @@ margin-bottom: 20px; } + .brand-logo { + display: block; + width: 220px; + max-width: 220px; + height: auto; + margin: 0 auto 16px; + } + .header h1 { color: #333333; + margin: 0; } .content { @@ -70,6 +79,7 @@
+

Payment reported for order #00000000

diff --git a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java index 62511f7..557f8e8 100644 --- a/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/AdminAuthSecurityTest.java @@ -1,7 +1,10 @@ package com.printcalculator.controller; +import com.printcalculator.config.AllowedOriginService; +import com.printcalculator.config.CorsConfig; import com.printcalculator.config.SecurityConfig; import com.printcalculator.controller.admin.AdminAuthController; +import com.printcalculator.security.AdminCsrfProtectionFilter; import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionService; @@ -19,13 +22,18 @@ import org.springframework.test.web.servlet.MvcResult; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest(controllers = AdminAuthController.class) @Import({ + CorsConfig.class, + AllowedOriginService.class, SecurityConfig.class, + AdminCsrfProtectionFilter.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class, AdminLoginThrottleService.class @@ -37,6 +45,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) class AdminAuthSecurityTest { + private static final String ALLOWED_ORIGIN = "http://localhost:4200"; + @Autowired private MockMvc mockMvc; @@ -47,6 +57,7 @@ class AdminAuthSecurityTest { req.setRemoteAddr("10.0.0.1"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"test-admin-password\"}")) .andExpect(status().isOk()) @@ -69,6 +80,7 @@ class AdminAuthSecurityTest { req.setRemoteAddr("10.0.0.2"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"wrong-password\"}")) .andExpect(status().isUnauthorized()) @@ -83,6 +95,7 @@ class AdminAuthSecurityTest { req.setRemoteAddr("10.0.0.3"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"wrong-password\"}")) .andExpect(status().isUnauthorized()) @@ -93,12 +106,36 @@ class AdminAuthSecurityTest { req.setRemoteAddr("10.0.0.3"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"wrong-password\"}")) .andExpect(status().isTooManyRequests()) .andExpect(jsonPath("$.authenticated").value(false)); } + @Test + void loginWithoutTrustedOrigin_ShouldReturnForbidden() throws Exception { + mockMvc.perform(post("/api/admin/auth/login") + .with(req -> { + req.setRemoteAddr("10.0.0.30"); + return req; + }) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"password\":\"test-admin-password\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error").value("CSRF_INVALID")); + } + + @Test + void preflightFromAllowedOrigin_ShouldExposeCorsHeaders() throws Exception { + mockMvc.perform(options("/api/admin/auth/login") + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "POST")) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, ALLOWED_ORIGIN)) + .andExpect(header().string(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS, "true")); + } + @Test void adminAccessWithoutCookie_ShouldReturn401() throws Exception { mockMvc.perform(get("/api/admin/auth/me")) @@ -112,6 +149,7 @@ class AdminAuthSecurityTest { req.setRemoteAddr("10.0.0.4"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"test-admin-password\"}")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java index 799526d..3fed567 100644 --- a/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminOrderControllerSecurityTest.java @@ -1,7 +1,10 @@ package com.printcalculator.controller.admin; +import com.printcalculator.config.AllowedOriginService; +import com.printcalculator.config.CorsConfig; import com.printcalculator.config.SecurityConfig; import com.printcalculator.service.order.AdminOrderControllerService; +import com.printcalculator.security.AdminCsrfProtectionFilter; import com.printcalculator.security.AdminLoginThrottleService; import com.printcalculator.security.AdminSessionAuthenticationFilter; import com.printcalculator.security.AdminSessionService; @@ -35,7 +38,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @WebMvcTest(controllers = {AdminAuthController.class, AdminOrderController.class}) @Import({ + CorsConfig.class, + AllowedOriginService.class, SecurityConfig.class, + AdminCsrfProtectionFilter.class, AdminSessionAuthenticationFilter.class, AdminSessionService.class, AdminLoginThrottleService.class, @@ -48,6 +54,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) class AdminOrderControllerSecurityTest { + private static final String ALLOWED_ORIGIN = "http://localhost:4200"; + @Autowired private MockMvc mockMvc; @@ -96,6 +104,7 @@ class AdminOrderControllerSecurityTest { req.setRemoteAddr("10.0.0.44"); return req; }) + .header(HttpHeaders.ORIGIN, ALLOWED_ORIGIN) .contentType(MediaType.APPLICATION_JSON) .content("{\"password\":\"test-admin-password\"}")) .andExpect(status().isOk()) diff --git a/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java new file mode 100644 index 0000000..565ac42 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/controller/admin/AdminShopProductControllerSecurityTest.java @@ -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":"

Descrizione

"}, + "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. + } + }; + } + } +} diff --git a/backend/src/test/java/com/printcalculator/entity/FilamentVariantTest.java b/backend/src/test/java/com/printcalculator/entity/FilamentVariantTest.java new file mode 100644 index 0000000..1fad30e --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/FilamentVariantTest.java @@ -0,0 +1,31 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class FilamentVariantTest { + + @Test + void getColorLabelForLanguageShouldReturnLocalizedValue() { + FilamentVariant variant = new FilamentVariant(); + variant.setColorName("Orange"); + variant.setColorLabelIt("Arancione"); + variant.setColorLabelEn("Orange"); + variant.setColorLabelDe("Orange"); + variant.setColorLabelFr("Orange"); + + assertEquals("Arancione", variant.getColorLabelForLanguage("it")); + assertEquals("Orange", variant.getColorLabelForLanguage("en")); + assertEquals("Orange", variant.getColorLabelForLanguage("de-CH")); + } + + @Test + void getColorLabelForLanguageShouldFallbackToColorName() { + FilamentVariant variant = new FilamentVariant(); + variant.setColorName("Orange"); + + assertEquals("Orange", variant.getColorLabelForLanguage("it")); + assertEquals("Orange", variant.getColorLabelForLanguage("fr")); + } +} diff --git a/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java b/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java new file mode 100644 index 0000000..ac987e8 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopCategoryTest.java @@ -0,0 +1,55 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ShopCategoryTest { + + @Test + void localizedAccessorsShouldReturnLanguageSpecificValues() { + ShopCategory category = new ShopCategory(); + category.setName("Desk accessories"); + category.setNameIt("Accessori da scrivania"); + category.setNameEn("Desk accessories"); + category.setNameDe("Schreibtischzubehor"); + category.setNameFr("Accessoires de bureau"); + category.setDescription("Legacy description"); + category.setDescriptionIt("Organizer e accessori stampati per la scrivania."); + category.setDescriptionEn("Printed desk organizers and accessories."); + category.setDescriptionDe("Gedruckte Organizer und Zubehor fur den Schreibtisch."); + category.setDescriptionFr("Accessoires et organiseurs imprimes pour le bureau."); + category.setSeoTitle("Legacy SEO title"); + category.setSeoTitleIt("Accessori da scrivania stampati in 3D"); + category.setSeoTitleEn("3D printed desk accessories"); + category.setSeoTitleDe("3D-gedruckte Schreibtischaccessoires"); + category.setSeoTitleFr("Accessoires de bureau imprimes en 3D"); + category.setSeoDescription("Legacy SEO description"); + category.setSeoDescriptionIt("Accessori da scrivania personalizzati e funzionali."); + category.setSeoDescriptionEn("Functional custom desk accessories."); + category.setSeoDescriptionDe("Funktionale personalisierte Schreibtischaccessoires."); + category.setSeoDescriptionFr("Accessoires de bureau fonctionnels et personnalises."); + + assertEquals("Accessori da scrivania", category.getNameForLanguage("it")); + assertEquals("Desk accessories", category.getNameForLanguage("en")); + assertEquals("Schreibtischzubehor", category.getNameForLanguage("de")); + assertEquals("Accessoires de bureau", category.getNameForLanguage("fr")); + assertEquals("Gedruckte Organizer und Zubehor fur den Schreibtisch.", category.getDescriptionForLanguage("de")); + assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("en")); + assertEquals("Accessoires de bureau fonctionnels et personnalises.", category.getSeoDescriptionForLanguage("fr")); + } + + @Test + void localizedAccessorsShouldFallbackToLegacyValues() { + ShopCategory category = new ShopCategory(); + category.setName("Desk accessories"); + category.setDescription("Printed desk organizers and accessories."); + category.setSeoTitle("3D printed desk accessories"); + category.setSeoDescription("Functional custom desk accessories."); + + assertEquals("Desk accessories", category.getNameForLanguage("it")); + assertEquals("Printed desk organizers and accessories.", category.getDescriptionForLanguage("de")); + assertEquals("3D printed desk accessories", category.getSeoTitleForLanguage("fr-CH")); + assertEquals("Functional custom desk accessories.", category.getSeoDescriptionForLanguage("en-US")); + } +} diff --git a/backend/src/test/java/com/printcalculator/entity/ShopProductVariantTest.java b/backend/src/test/java/com/printcalculator/entity/ShopProductVariantTest.java new file mode 100644 index 0000000..ec07ce1 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/entity/ShopProductVariantTest.java @@ -0,0 +1,32 @@ +package com.printcalculator.entity; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ShopProductVariantTest { + + @Test + void getColorLabelForLanguageShouldReturnLocalizedValue() { + ShopProductVariant variant = new ShopProductVariant(); + variant.setColorName("Gray"); + variant.setColorLabelIt("Grigio"); + variant.setColorLabelEn("Gray"); + variant.setColorLabelDe("Grau"); + variant.setColorLabelFr("Gris"); + + assertEquals("Grigio", variant.getColorLabelForLanguage("it")); + assertEquals("Gray", variant.getColorLabelForLanguage("en")); + assertEquals("Grau", variant.getColorLabelForLanguage("de")); + assertEquals("Gris", variant.getColorLabelForLanguage("fr-CH")); + } + + @Test + void getColorLabelForLanguageShouldFallbackToColorName() { + ShopProductVariant variant = new ShopProductVariant(); + variant.setColorName("Gray"); + + assertEquals("Gray", variant.getColorLabelForLanguage("it")); + assertEquals("Gray", variant.getColorLabelForLanguage("de")); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java index aa90829..63f23e4 100644 --- a/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/OrderServiceTest.java @@ -40,10 +40,13 @@ import java.util.Optional; import java.util.UUID; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -217,6 +220,210 @@ class OrderServiceTest { verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); } + @Test + void createOrderFromQuote_withShopProductMissingSourceFile_shouldNotFail() throws Exception { + UUID sessionId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("ACTIVE"); + session.setSessionType("SHOP_CART"); + session.setMaterialCode("SHOP"); + session.setPricingVersion("v1"); + session.setSetupCostChf(BigDecimal.ZERO); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + + ShopCategory category = new ShopCategory(); + category.setId(UUID.randomUUID()); + category.setSlug("desk"); + category.setName("Desk"); + + ShopProduct product = new ShopProduct(); + product.setId(UUID.randomUUID()); + product.setCategory(category); + product.setSlug("organizer"); + product.setName("Organizer"); + + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(UUID.randomUUID()); + variant.setProduct(product); + variant.setVariantLabel("PLA"); + variant.setColorName("Orange"); + variant.setColorHex("#ff8a00"); + variant.setInternalMaterialCode("PLA"); + variant.setPriceChf(new BigDecimal("18.00")); + + Path missingSource = Path.of("storage_quotes") + .toAbsolutePath() + .normalize() + .resolve(sessionId.toString()) + .resolve("missing-shop-item.stl"); + + QuoteLineItem qItem = new QuoteLineItem(); + qItem.setId(UUID.randomUUID()); + qItem.setQuoteSession(session); + qItem.setStatus("READY"); + qItem.setLineItemType("SHOP_PRODUCT"); + qItem.setOriginalFilename("organizer.stl"); + qItem.setDisplayName("Organizer"); + qItem.setQuantity(1); + qItem.setColorCode("Orange"); + qItem.setMaterialCode("PLA"); + qItem.setShopProduct(product); + qItem.setShopProductVariant(variant); + qItem.setShopProductSlug(product.getSlug()); + qItem.setShopProductName(product.getName()); + qItem.setShopVariantLabel("PLA"); + qItem.setShopVariantColorName("Orange"); + qItem.setShopVariantColorHex("#ff8a00"); + qItem.setUnitPriceChf(new BigDecimal("18.00")); + qItem.setStoredPath(missingSource.toString()); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty()); + when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> { + Customer saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(UUID.randomUUID()); + } + return saved; + }); + when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem)); + when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("18.00"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("18.00"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("18.00"), + BigDecimal.ZERO + ) + ); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> { + Order saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderId); + } + return saved; + }); + when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> { + OrderItem saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderItemId); + } + return saved; + }); + when(qrBillService.generateQrBillSvg(any(Order.class))).thenReturn("".getBytes(StandardCharsets.UTF_8)); + when(invoiceService.generateDocumentPdf(any(Order.class), any(List.class), eq(true), eq(qrBillService), isNull())) + .thenReturn("pdf".getBytes(StandardCharsets.UTF_8)); + when(paymentService.getOrCreatePaymentForOrder(any(Order.class), eq("OTHER"))).thenReturn(new Payment()); + + Order order = service.createOrderFromQuote(sessionId, buildRequest()); + + assertEquals(orderId, order.getId()); + assertEquals("CONVERTED", session.getStatus()); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(OrderItem.class); + verify(orderItemRepo, times(2)).save(itemCaptor.capture()); + OrderItem savedItem = itemCaptor.getAllValues().getLast(); + assertEquals("PENDING", savedItem.getStoredRelativePath()); + assertNull(savedItem.getFileSizeBytes()); + + verify(storageService, never()).store(eq(missingSource), any(Path.class)); + verify(paymentService).getOrCreatePaymentForOrder(order, "OTHER"); + } + + @Test + void createOrderFromQuote_withCalculatorItemMissingSourceFile_shouldFail() { + UUID sessionId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); + UUID orderItemId = UUID.randomUUID(); + + QuoteSession session = new QuoteSession(); + session.setId(sessionId); + session.setStatus("ACTIVE"); + session.setSessionType("QUOTE"); + session.setMaterialCode("PLA"); + session.setPricingVersion("v1"); + session.setSetupCostChf(BigDecimal.ZERO); + session.setExpiresAt(OffsetDateTime.now().plusDays(30)); + + Path missingSource = Path.of("storage_quotes") + .toAbsolutePath() + .normalize() + .resolve(sessionId.toString()) + .resolve("missing-calculator-item.stl"); + + QuoteLineItem qItem = new QuoteLineItem(); + qItem.setId(UUID.randomUUID()); + qItem.setQuoteSession(session); + qItem.setStatus("READY"); + qItem.setLineItemType("PRINT_FILE"); + qItem.setOriginalFilename("part.stl"); + qItem.setDisplayName("part.stl"); + qItem.setQuantity(1); + qItem.setMaterialCode("PLA"); + qItem.setUnitPriceChf(new BigDecimal("9.50")); + qItem.setStoredPath(missingSource.toString()); + + when(quoteSessionRepo.findById(sessionId)).thenReturn(Optional.of(session)); + when(customerRepo.findByEmail("buyer@example.com")).thenReturn(Optional.empty()); + when(customerRepo.save(any(Customer.class))).thenAnswer(invocation -> { + Customer saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(UUID.randomUUID()); + } + return saved; + }); + when(quoteLineItemRepo.findByQuoteSessionId(sessionId)).thenReturn(List.of(qItem)); + when(quoteSessionTotalsService.compute(eq(session), eq(List.of(qItem)))).thenReturn( + new QuoteSessionTotalsService.QuoteSessionTotals( + new BigDecimal("9.50"), + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("9.50"), + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + new BigDecimal("9.50"), + BigDecimal.ZERO + ) + ); + when(orderRepo.save(any(Order.class))).thenAnswer(invocation -> { + Order saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderId); + } + return saved; + }); + when(orderItemRepo.save(any(OrderItem.class))).thenAnswer(invocation -> { + OrderItem saved = invocation.getArgument(0); + if (saved.getId() == null) { + saved.setId(orderItemId); + } + return saved; + }); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> service.createOrderFromQuote(sessionId, buildRequest()) + ); + + assertEquals( + "Source file not available for quote line item " + qItem.getId(), + exception.getMessage() + ); + verify(paymentService, never()).getOrCreatePaymentForOrder(any(Order.class), eq("OTHER")); + verify(eventPublisher, never()).publishEvent(any(OrderCreatedEvent.class)); + } + private CreateOrderRequest buildRequest() { CustomerDto customer = new CustomerDto(); customer.setEmail("buyer@example.com"); diff --git a/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductTranslationServiceTest.java b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductTranslationServiceTest.java new file mode 100644 index 0000000..f88daed --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/admin/AdminShopProductTranslationServiceTest.java @@ -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 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", "

Desk cable clip for clean cable routing.

", "Desk cable clip | 3D fab", "Technical 3D printed desk cable clip for clean cable routing."), + "de", localized("Schreibtisch-Kabelhalter", "Technisches Schreibtisch-Zubehor", "

Kabelhalter fur einen aufgeraumten Schreibtisch.

", "Schreibtisch-Kabelhalter | 3D fab", "Technischer 3D-gedruckter Kabelhalter fur einen aufgeraumten Schreibtisch."), + "fr", localized("Support de cable de bureau", "Accessoire technique de bureau", "

Support de cable pour un bureau ordonne.

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

Supporto per tenere i cavi ordinati sulla scrivania.

", + "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("

")); + 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", "

Descrizione

", + "en", "

Description

", + "de", "

Beschreibung

", + "fr", "

Description

" + )); + 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> translations) throws IOException { + Map arguments = Map.of("translations", translations); + Map item = Map.of( + "type", "function_call", + "name", functionName, + "arguments", objectMapper.writeValueAsString(arguments) + ); + Map response = Map.of( + "id", "resp_test", + "output", List.of(item) + ); + return objectMapper.writeValueAsString(response); + } + + private Map localized(String name, + String excerpt, + String description, + String seoTitle, + String seoDescription) { + return Map.of( + "name", name, + "excerpt", excerpt, + "description", description, + "seoTitle", seoTitle, + "seoDescription", seoDescription + ); + } +} diff --git a/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java new file mode 100644 index 0000000..9c182d1 --- /dev/null +++ b/backend/src/test/java/com/printcalculator/service/shop/PublicShopCatalogServiceTest.java @@ -0,0 +1,176 @@ +package com.printcalculator.service.shop; + +import com.printcalculator.dto.ShopProductCatalogResponseDto; +import com.printcalculator.dto.ShopProductDetailDto; +import com.printcalculator.entity.ShopCategory; +import com.printcalculator.entity.ShopProduct; +import com.printcalculator.entity.ShopProductVariant; +import com.printcalculator.repository.FilamentVariantRepository; +import com.printcalculator.repository.ShopCategoryRepository; +import com.printcalculator.repository.ShopProductModelAssetRepository; +import com.printcalculator.repository.ShopProductRepository; +import com.printcalculator.repository.ShopProductVariantRepository; +import com.printcalculator.service.media.PublicMediaQueryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PublicShopCatalogServiceTest { + + @Mock + private ShopCategoryRepository shopCategoryRepository; + @Mock + private ShopProductRepository shopProductRepository; + @Mock + private ShopProductVariantRepository shopProductVariantRepository; + @Mock + private ShopProductModelAssetRepository shopProductModelAssetRepository; + @Mock + private FilamentVariantRepository filamentVariantRepository; + @Mock + private PublicMediaQueryService publicMediaQueryService; + @Mock + private ShopStorageService shopStorageService; + + private PublicShopCatalogService service; + + @BeforeEach + void setUp() { + service = new PublicShopCatalogService( + shopCategoryRepository, + shopProductRepository, + shopProductVariantRepository, + shopProductModelAssetRepository, + filamentVariantRepository, + publicMediaQueryService, + shopStorageService + ); + } + + @Test + void getProductCatalog_shouldExposePublicPathAsSegment() { + ShopCategory category = buildCategory(); + ShopProduct product = buildProduct(category); + ShopProductVariant variant = buildVariant(product); + + stubPublicCatalog(category, product, variant); + + ShopProductCatalogResponseDto response = service.getProductCatalog(null, false, "en"); + + assertEquals(1, response.products().size()); + assertEquals("12345678-bike-wall-hanger", response.products().getFirst().publicPath()); + assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.products().getFirst().localizedPaths().get("en")); + assertEquals("/it/shop/p/12345678-supporto-bici", response.products().getFirst().localizedPaths().get("it")); + } + + @Test + void getProduct_shouldExposePublicPathAsSegment() { + ShopCategory category = buildCategory(); + ShopProduct product = buildProduct(category); + ShopProductVariant variant = buildVariant(product); + + stubPublicCatalog(category, product, variant); + + ShopProductDetailDto response = service.getProduct("bike-wall-hanger", "en"); + + assertEquals("12345678-bike-wall-hanger", response.publicPath()); + assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en")); + assertEquals("/it/shop/p/12345678-supporto-bici", response.localizedPaths().get("it")); + } + + @Test + void getProductByPublicPath_shouldResolveLocalizedSegment() { + ShopCategory category = buildCategory(); + ShopProduct product = buildProduct(category); + ShopProductVariant variant = buildVariant(product); + + stubPublicCatalog(category, product, variant); + + ShopProductDetailDto response = service.getProductByPublicPath("12345678-bike-wall-hanger", "en"); + + assertEquals("bike-wall-hanger", response.slug()); + assertEquals("12345678-bike-wall-hanger", response.publicPath()); + assertEquals("/en/shop/p/12345678-bike-wall-hanger", response.localizedPaths().get("en")); + } + + @Test + void getProductByPublicPath_shouldRejectNonCanonicalSegment() { + ShopCategory category = buildCategory(); + ShopProduct product = buildProduct(category); + ShopProductVariant variant = buildVariant(product); + + stubPublicCatalog(category, product, variant); + + ResponseStatusException exception = assertThrows( + ResponseStatusException.class, + () -> service.getProductByPublicPath("12345678-wrong-tail", "en") + ); + + assertEquals(404, exception.getStatusCode().value()); + } + + private void stubPublicCatalog(ShopCategory category, ShopProduct product, ShopProductVariant variant) { + when(shopCategoryRepository.findAllByIsActiveTrueOrderBySortOrderAscNameAsc()).thenReturn(List.of(category)); + when(shopProductRepository.findAllByIsActiveTrueOrderByIsFeaturedDescSortOrderAscNameAsc()).thenReturn(List.of(product)); + when(shopProductVariantRepository.findByProduct_IdInAndIsActiveTrueOrderBySortOrderAscColorNameAsc(anyList())) + .thenReturn(List.of(variant)); + when(shopProductModelAssetRepository.findByProduct_IdIn(anyList())).thenReturn(List.of()); + when(filamentVariantRepository.findByIsActiveTrue()).thenReturn(List.of()); + when(publicMediaQueryService.getUsageMediaMap(anyString(), anyList(), anyString())).thenReturn(Map.of()); + } + + private ShopCategory buildCategory() { + ShopCategory category = new ShopCategory(); + category.setId(UUID.fromString("21111111-1111-1111-1111-111111111111")); + category.setSlug("accessori"); + category.setName("Accessori"); + category.setNameIt("Accessori"); + category.setNameEn("Accessories"); + category.setIsActive(true); + category.setSortOrder(0); + return category; + } + + private ShopProduct buildProduct(ShopCategory category) { + ShopProduct product = new ShopProduct(); + product.setId(UUID.fromString("12345678-abcd-4abc-9abc-1234567890ab")); + product.setCategory(category); + product.setSlug("bike-wall-hanger"); + product.setName("Bike Wall-Hanger"); + product.setNameIt("Supporto bici"); + product.setNameEn("Bike Wall-Hanger"); + product.setIsActive(true); + product.setIsFeatured(true); + product.setSortOrder(0); + return product; + } + + private ShopProductVariant buildVariant(ShopProduct product) { + ShopProductVariant variant = new ShopProductVariant(); + variant.setId(UUID.fromString("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")); + variant.setProduct(product); + variant.setVariantLabel("PLA"); + variant.setColorName("Grigio"); + variant.setInternalMaterialCode("PLA"); + variant.setPriceChf(new BigDecimal("29.90")); + variant.setIsActive(true); + variant.setIsDefault(true); + variant.setSortOrder(0); + return variant; + } +} diff --git a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java index 988b13e..778a150 100644 --- a/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java +++ b/backend/src/test/java/com/printcalculator/service/shop/ShopSitemapServiceTest.java @@ -92,15 +92,15 @@ class ShopSitemapServiceTest { assertTrue(xml.contains("https://3d-fab.ch/en/shop/accessori")); assertTrue(xml.contains("https://3d-fab.ch/de/shop/accessori")); assertTrue(xml.contains("https://3d-fab.ch/fr/shop/accessori")); - 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")); assertTrue(xml.contains("https://3d-fab.ch/it/shop/p/123e4567-supporto-bici")); assertTrue(xml.contains("https://3d-fab.ch/en/shop/p/123e4567-bike-holder")); assertTrue(xml.contains("https://3d-fab.ch/de/shop/p/123e4567-fahrrad-halter")); assertTrue(xml.contains("https://3d-fab.ch/fr/shop/p/123e4567-support-velo")); - assertTrue(xml.contains("hreflang=\"en\" 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=\"en-CH\" href=\"https://3d-fab.ch/en/shop/p/123e4567-bike-holder\"")); + 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("2026-03-11T07:30:00Z")); assertFalse(xml.contains("33333333-draft")); diff --git a/db.sql b/db.sql index c3975e6..3ad02dd 100644 --- a/db.sql +++ b/db.sql @@ -44,6 +44,10 @@ create table filament_variant variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" color_name text not null, -- Nero, Bianco, ecc. + color_label_it text, + color_label_en text, + color_label_de text, + color_label_fr text, color_hex text, finish_type text not null default 'GLOSSY' check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), @@ -70,6 +74,22 @@ select filament_variant_id, (stock_spools * spool_net_kg) as stock_kg from filament_variant; +alter table filament_variant + add column if not exists color_label_it text, + add column if not exists color_label_en text, + add column if not exists color_label_de text, + add column if not exists color_label_fr text; + +update filament_variant +set color_label_it = coalesce(nullif(btrim(color_label_it), ''), color_name), + color_label_en = coalesce(nullif(btrim(color_label_en), ''), color_name), + color_label_de = coalesce(nullif(btrim(color_label_de), ''), color_name), + color_label_fr = coalesce(nullif(btrim(color_label_fr), ''), color_name) +where nullif(btrim(color_label_it), '') is null + or nullif(btrim(color_label_en), '') is null + or nullif(btrim(color_label_de), '') is null + or nullif(btrim(color_label_fr), '') is null; + create table printer_machine_profile ( printer_machine_profile_id bigserial primary key, @@ -1013,9 +1033,25 @@ CREATE TABLE IF NOT EXISTS shop_category parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL, slug text NOT NULL UNIQUE, name text NOT NULL, + name_it text, + name_en text, + name_de text, + name_fr text, description text, + description_it text, + description_en text, + description_de text, + description_fr text, seo_title text, + seo_title_it text, + seo_title_en text, + seo_title_de text, + seo_title_fr text, seo_description text, + seo_description_it text, + seo_description_en text, + seo_description_de text, + seo_description_fr text, og_title text, og_description text, indexable boolean NOT NULL DEFAULT true, @@ -1034,6 +1070,66 @@ CREATE INDEX IF NOT EXISTS ix_shop_category_parent_sort CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort ON shop_category (is_active, sort_order, created_at DESC); +ALTER TABLE shop_category + ADD COLUMN IF NOT EXISTS name_it text, + ADD COLUMN IF NOT EXISTS name_en text, + ADD COLUMN IF NOT EXISTS name_de text, + ADD COLUMN IF NOT EXISTS name_fr text, + ADD COLUMN IF NOT EXISTS description_it text, + ADD COLUMN IF NOT EXISTS description_en text, + ADD COLUMN IF NOT EXISTS description_de text, + ADD COLUMN IF NOT EXISTS description_fr text, + ADD COLUMN IF NOT EXISTS seo_title_it text, + ADD COLUMN IF NOT EXISTS seo_title_en text, + ADD COLUMN IF NOT EXISTS seo_title_de text, + ADD COLUMN IF NOT EXISTS seo_title_fr text, + ADD COLUMN IF NOT EXISTS seo_description_it text, + ADD COLUMN IF NOT EXISTS seo_description_en text, + ADD COLUMN IF NOT EXISTS seo_description_de text, + ADD COLUMN IF NOT EXISTS seo_description_fr text; + +UPDATE shop_category +SET + name_it = COALESCE(NULLIF(btrim(name_it), ''), name), + name_en = COALESCE(NULLIF(btrim(name_en), ''), name), + name_de = COALESCE(NULLIF(btrim(name_de), ''), name), + name_fr = COALESCE(NULLIF(btrim(name_fr), ''), name), + description_it = COALESCE(NULLIF(btrim(description_it), ''), description), + description_en = COALESCE(NULLIF(btrim(description_en), ''), description), + description_de = COALESCE(NULLIF(btrim(description_de), ''), description), + description_fr = COALESCE(NULLIF(btrim(description_fr), ''), description), + seo_title_it = COALESCE(NULLIF(btrim(seo_title_it), ''), seo_title), + seo_title_en = COALESCE(NULLIF(btrim(seo_title_en), ''), seo_title), + seo_title_de = COALESCE(NULLIF(btrim(seo_title_de), ''), seo_title), + seo_title_fr = COALESCE(NULLIF(btrim(seo_title_fr), ''), seo_title), + seo_description_it = COALESCE(NULLIF(btrim(seo_description_it), ''), seo_description), + seo_description_en = COALESCE(NULLIF(btrim(seo_description_en), ''), seo_description), + seo_description_de = COALESCE(NULLIF(btrim(seo_description_de), ''), seo_description), + seo_description_fr = COALESCE(NULLIF(btrim(seo_description_fr), ''), seo_description) +WHERE + NULLIF(btrim(name_it), '') IS NULL + OR NULLIF(btrim(name_en), '') IS NULL + OR NULLIF(btrim(name_de), '') IS NULL + OR NULLIF(btrim(name_fr), '') IS NULL + OR (description IS NOT NULL AND ( + NULLIF(btrim(description_it), '') IS NULL + OR NULLIF(btrim(description_en), '') IS NULL + OR NULLIF(btrim(description_de), '') IS NULL + OR NULLIF(btrim(description_fr), '') IS NULL + )) + OR (seo_title IS NOT NULL AND ( + NULLIF(btrim(seo_title_it), '') IS NULL + OR NULLIF(btrim(seo_title_en), '') IS NULL + OR NULLIF(btrim(seo_title_de), '') IS NULL + OR NULLIF(btrim(seo_title_fr), '') IS NULL + )) + OR (seo_description IS NOT NULL AND ( + NULLIF(btrim(seo_description_it), '') IS NULL + OR NULLIF(btrim(seo_description_en), '') IS NULL + OR NULLIF(btrim(seo_description_de), '') IS NULL + OR NULLIF(btrim(seo_description_fr), '') IS NULL + )); + CREATE TABLE IF NOT EXISTS shop_product ( shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), @@ -1165,6 +1261,10 @@ CREATE TABLE IF NOT EXISTS shop_product_variant sku text UNIQUE, variant_label text NOT NULL, color_name text NOT NULL, + color_label_it text, + color_label_en text, + color_label_de text, + color_label_fr text, color_hex text, internal_material_code text NOT NULL, price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), @@ -1181,6 +1281,22 @@ CREATE INDEX IF NOT EXISTS ix_shop_product_variant_product_active_sort CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku ON shop_product_variant (sku); +ALTER TABLE shop_product_variant + ADD COLUMN IF NOT EXISTS color_label_it text, + ADD COLUMN IF NOT EXISTS color_label_en text, + ADD COLUMN IF NOT EXISTS color_label_de text, + ADD COLUMN IF NOT EXISTS color_label_fr text; + +UPDATE shop_product_variant +SET color_label_it = COALESCE(NULLIF(btrim(color_label_it), ''), color_name), + color_label_en = COALESCE(NULLIF(btrim(color_label_en), ''), color_name), + color_label_de = COALESCE(NULLIF(btrim(color_label_de), ''), color_name), + color_label_fr = COALESCE(NULLIF(btrim(color_label_fr), ''), color_name) +WHERE NULLIF(btrim(color_label_it), '') IS NULL + OR NULLIF(btrim(color_label_en), '') IS NULL + OR NULLIF(btrim(color_label_de), '') IS NULL + OR NULLIF(btrim(color_label_fr), '') IS NULL; + CREATE TABLE IF NOT EXISTS shop_product_model_asset ( shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), diff --git a/docker-compose.deploy.yml b/docker-compose.deploy.yml index 5186321..1086e1c 100644 --- a/docker-compose.deploy.yml +++ b/docker-compose.deploy.yml @@ -29,6 +29,12 @@ services: - ADMIN_PASSWORD=${ADMIN_PASSWORD} - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} - 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 - PROFILES_DIR=/app/profiles - MEDIA_STORAGE_ROOT=${MEDIA_STORAGE_ROOT:-/app/storage_media} diff --git a/frontend/angular.json b/frontend/angular.json index 3b01d4f..ca36cf2 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -61,13 +61,13 @@ "budgets": [ { "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" + "maximumWarning": "600kB", + "maximumError": "1.2MB" }, { "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" + "maximumWarning": "10kB", + "maximumError": "14kB" } ] }, diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 57614f9..6bfeebb 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/public/site.webmanifest b/frontend/public/site.webmanifest new file mode 100644 index 0000000..cfd690f --- /dev/null +++ b/frontend/public/site.webmanifest @@ -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" + } + ] +} diff --git a/frontend/public/sitemap-static.xml b/frontend/public/sitemap-static.xml index b7020d5..a7da08d 100644 --- a/frontend/public/sitemap-static.xml +++ b/frontend/public/sitemap-static.xml @@ -2,81 +2,81 @@ https://3d-fab.ch/it - - - - - + + + + + weekly 1.0 https://3d-fab.ch/en - - - - - + + + + + weekly 1.0 https://3d-fab.ch/de - - - - - + + + + + weekly 1.0 https://3d-fab.ch/fr - - - - - + + + + + weekly 1.0 https://3d-fab.ch/it/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/en/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/de/calculator/basic - - - - + + + + weekly 0.9 https://3d-fab.ch/fr/calculator/basic - - - - + + + + weekly 0.9 @@ -84,40 +84,40 @@ https://3d-fab.ch/it/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/en/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/de/calculator/advanced - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/calculator/advanced - - - - + + + + weekly 0.8 @@ -125,40 +125,40 @@ https://3d-fab.ch/it/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/en/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/de/shop - - - - + + + + weekly 0.8 https://3d-fab.ch/fr/shop - - - - + + + + weekly 0.8 @@ -166,40 +166,40 @@ https://3d-fab.ch/it/about - - - - + + + + monthly 0.7 https://3d-fab.ch/en/about - - - - + + + + monthly 0.7 https://3d-fab.ch/de/about - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/about - - - - + + + + monthly 0.7 @@ -207,40 +207,40 @@ https://3d-fab.ch/it/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/en/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/de/contact - - - - + + + + monthly 0.7 https://3d-fab.ch/fr/contact - - - - + + + + monthly 0.7 @@ -248,40 +248,40 @@ https://3d-fab.ch/it/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/en/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/de/privacy - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/privacy - - - - + + + + yearly 0.4 @@ -289,40 +289,40 @@ https://3d-fab.ch/it/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/en/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/de/terms - - - - + + + + yearly 0.4 https://3d-fab.ch/fr/terms - - - - + + + + yearly 0.4 diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 0680b43..b7df367 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -1 +1,14 @@ + +@if (siteIntroState() !== "hidden") { + +} diff --git a/frontend/src/app/app.component.scss b/frontend/src/app/app.component.scss index e69de29..137cdc6 100644 --- a/frontend/src/app/app.component.scss +++ b/frontend/src/app/app.component.scss @@ -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); +} diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 53a2fdb..a7d31cc 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -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 { SeoService } from './core/services/seo.service'; +import { BrandAnimationLogoComponent } from './shared/components/brand-animation-logo/brand-animation-logo.component'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, BrandAnimationLogoComponent], templateUrl: './app.component.html', styleUrl: './app.component.scss', }) export class AppComponent { 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); + }); + }); + } } diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 2e93470..a72432c 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -28,21 +28,12 @@ import { import { serverOriginInterceptor } from './core/interceptors/server-origin.interceptor'; import { catchError, firstValueFrom, of } from 'rxjs'; import { StaticTranslateLoader } from './core/i18n/static-translate.loader'; - -type SupportedLang = 'it' | 'en' | 'de' | 'fr'; -const SUPPORTED_LANGS: readonly SupportedLang[] = ['it', 'en', 'de', 'fr']; - -function resolveLangFromUrl(url: string): SupportedLang { - const firstSegment = (url || '/') - .split('?')[0] - .split('#')[0] - .split('/') - .filter(Boolean)[0] - ?.toLowerCase(); - return SUPPORTED_LANGS.includes(firstSegment as SupportedLang) - ? (firstSegment as SupportedLang) - : 'it'; -} +import { + getNavigatorLanguagePreferences, + parseAcceptLanguage, + resolveInitialLanguage, + SUPPORTED_LANGS, +} from './core/i18n/language-resolution'; export const appConfig: ApplicationConfig = { providers: [ @@ -52,7 +43,7 @@ export const appConfig: ApplicationConfig = { withComponentInputBinding(), withViewTransitions(), withInMemoryScrolling({ - scrollPositionRestoration: 'top', + scrollPositionRestoration: 'enabled', }), ), provideHttpClient( @@ -60,7 +51,7 @@ export const appConfig: ApplicationConfig = { ), importProvidersFrom( TranslateModule.forRoot({ - defaultLanguage: 'it', + fallbackLang: 'it', loader: { provide: TranslateLoader, useClass: StaticTranslateLoader, @@ -72,13 +63,21 @@ export const appConfig: ApplicationConfig = { const router = inject(Router); const request = inject(REQUEST, { optional: true }) as { url?: string; + headers?: Record; } | null; translate.addLangs([...SUPPORTED_LANGS]); - translate.setDefaultLang('it'); + translate.setFallbackLang('it'); const requestedUrl = (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( translate.use(lang).pipe( @@ -96,3 +95,21 @@ export const appConfig: ApplicationConfig = { provideClientHydration(withEventReplay()), ], }; + +function readRequestHeader( + request: { + headers?: Record; + } | 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; +} diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5fb79d4..ba77270 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,9 +15,8 @@ const appChildRoutes: Routes = [ loadComponent: () => import('./features/home/home.component').then((m) => m.HomeComponent), data: { - seoTitle: '3D fab | Stampa 3D su misura', - seoDescription: - 'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', + seoTitleKey: 'SEO.ROUTES.HOME.TITLE', + seoDescriptionKey: 'SEO.ROUTES.HOME.DESCRIPTION', }, }, { @@ -27,9 +26,8 @@ const appChildRoutes: Routes = [ (m) => m.CALCULATOR_ROUTES, ), data: { - seoTitle: 'Calcolatore preventivo stampa 3D | 3D fab', - seoDescription: - 'Carica il file 3D e ottieni prezzo e tempi in pochi secondi con slicing reale.', + seoTitleKey: 'SEO.ROUTES.CALCULATOR.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CALCULATOR.DESCRIPTION', }, }, { @@ -37,9 +35,8 @@ const appChildRoutes: Routes = [ loadChildren: () => import('./features/shop/shop.routes').then((m) => m.SHOP_ROUTES), data: { - seoTitle: 'Shop 3D fab', - seoDescription: - 'Catalogo prodotti stampati in 3D e soluzioni tecniche pronte all uso.', + seoTitleKey: 'SEO.ROUTES.SHOP.TITLE', + seoDescriptionKey: 'SEO.ROUTES.SHOP.DESCRIPTION', }, }, { @@ -47,19 +44,28 @@ const appChildRoutes: Routes = [ loadChildren: () => import('./features/about/about.routes').then((m) => m.ABOUT_ROUTES), data: { - seoTitle: 'Chi siamo | 3D fab', - seoDescription: - 'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.', + seoTitleKey: 'SEO.ROUTES.ABOUT.TITLE', + seoDescriptionKey: 'SEO.ROUTES.ABOUT.DESCRIPTION', }, }, + /* { + path: 'materials', + loadComponent: () => + import('./features/materials/materials-page.component').then( + (m) => m.MaterialsPageComponent, + ), + data: { + seoTitleKey: 'SEO.ROUTES.MATERIALS.TITLE', + seoDescriptionKey: 'SEO.ROUTES.MATERIALS.DESCRIPTION', + }, + },*/ { path: 'contact', loadChildren: () => import('./features/contact/contact.routes').then((m) => m.CONTACT_ROUTES), data: { - seoTitle: 'Contatti | 3D fab', - seoDescription: - 'Contatta 3D fab per preventivi, supporto tecnico e richieste personalizzate di stampa 3D.', + seoTitleKey: 'SEO.ROUTES.CONTACT.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CONTACT.DESCRIPTION', }, }, { @@ -69,7 +75,8 @@ const appChildRoutes: Routes = [ (m) => m.CheckoutComponent, ), data: { - seoTitle: 'Checkout | 3D fab', + seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION', seoRobots: 'noindex, nofollow', }, }, @@ -80,7 +87,8 @@ const appChildRoutes: Routes = [ (m) => m.CheckoutComponent, ), data: { - seoTitle: 'Checkout | 3D fab', + seoTitleKey: 'SEO.ROUTES.CHECKOUT.TITLE', + seoDescriptionKey: 'SEO.ROUTES.CHECKOUT.DESCRIPTION', seoRobots: 'noindex, nofollow', }, }, @@ -89,7 +97,8 @@ const appChildRoutes: Routes = [ loadComponent: () => import('./features/order/order.component').then((m) => m.OrderComponent), data: { - seoTitle: 'Ordine | 3D fab', + seoTitleKey: 'SEO.ROUTES.ORDER.TITLE', + seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION', seoRobots: 'noindex, nofollow', }, }, @@ -98,7 +107,8 @@ const appChildRoutes: Routes = [ loadComponent: () => import('./features/order/order.component').then((m) => m.OrderComponent), data: { - seoTitle: 'Ordine | 3D fab', + seoTitleKey: 'SEO.ROUTES.ORDER.TITLE', + seoDescriptionKey: 'SEO.ROUTES.ORDER.DESCRIPTION', seoRobots: 'noindex, nofollow', }, }, @@ -112,7 +122,8 @@ const appChildRoutes: Routes = [ loadChildren: () => import('./features/admin/admin.routes').then((m) => m.ADMIN_ROUTES), data: { - seoTitle: 'Admin | 3D fab', + seoTitleKey: 'SEO.ROUTES.ADMIN.TITLE', + seoDescriptionKey: 'SEO.ROUTES.ADMIN.DESCRIPTION', seoRobots: 'noindex, nofollow', }, }, @@ -123,6 +134,31 @@ const appChildRoutes: 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', canMatch: [langPrefixCanMatch], diff --git a/frontend/src/app/core/constants/colors.const.ts b/frontend/src/app/core/constants/colors.const.ts index 9a744c4..4a0911b 100644 --- a/frontend/src/app/core/constants/colors.const.ts +++ b/frontend/src/app/core/constants/colors.const.ts @@ -11,6 +11,8 @@ export interface ColorCategory { colors: ColorOption[]; } +const DEFAULT_BRAND_COLOR = '#facf0a'; + export const PRODUCT_COLORS: ColorCategory[] = [ { name: 'COLOR.CATEGORY_GLOSSY', @@ -38,10 +40,81 @@ export const PRODUCT_COLORS: ColorCategory[] = [ }, ]; -export function getColorHex(value: string): string { - for (const cat of PRODUCT_COLORS) { - const found = cat.colors.find((c) => c.value === value); - if (found) return found.hex; - } - return '#facf0a'; // Default Brand Color if not found +export function normalizeColorValue(value: string | null | undefined): string { + return String(value ?? '') + .trim() + .toLowerCase() + .replace(/ß/g, 'ss') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[_-]+/g, ' ') + .replace(/\s+/g, ' '); +} + +export function findColorHex(value: string | null | undefined): string | null { + const normalized = normalizeColorValue(value); + if (!normalized) { + return null; + } + + for (const category of PRODUCT_COLORS) { + const match = category.colors.find( + (color) => normalizeColorValue(color.value) === normalized, + ); + if (match) { + return match.hex; + } + } + + return null; +} + +export interface LocalizedColorLabelSet { + fallback?: string | null; + it?: string | null; + en?: string | null; + de?: string | null; + fr?: string | null; +} + +export function resolveLocalizedColorLabel( + language: string | null | undefined, + labels: LocalizedColorLabelSet, +): string | null { + const normalizedLanguage = String(language ?? '') + .trim() + .toLowerCase() + .split('-')[0]; + + const preferred = + normalizedLanguage === 'it' + ? labels.it + : normalizedLanguage === 'en' + ? labels.en + : normalizedLanguage === 'de' + ? labels.de + : normalizedLanguage === 'fr' + ? labels.fr + : null; + + return ( + firstNonBlank(preferred, labels.fallback) ?? + firstNonBlank(labels.it, labels.en, labels.de, labels.fr) + ); +} + +function firstNonBlank( + ...values: Array +): string | null { + for (const value of values) { + const normalized = String(value ?? '').trim(); + if (normalized) { + return normalized; + } + } + return null; +} + +export function getColorHex(value: string): string { + return findColorHex(value) ?? DEFAULT_BRAND_COLOR; } diff --git a/frontend/src/app/core/i18n/language-resolution.ts b/frontend/src/app/core/i18n/language-resolution.ts new file mode 100644 index 0000000..842e360 --- /dev/null +++ b/frontend/src/app/core/i18n/language-resolution.ts @@ -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; +} diff --git a/frontend/src/app/core/i18n/static-translate.loader.ts b/frontend/src/app/core/i18n/static-translate.loader.ts index 3821187..7e25c66 100644 --- a/frontend/src/app/core/i18n/static-translate.loader.ts +++ b/frontend/src/app/core/i18n/static-translate.loader.ts @@ -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 { Observable, of } 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'; +import { from, Observable } from 'rxjs'; -const TRANSLATIONS: Record = { - it: it as TranslationObject, - en: en as TranslationObject, - de: de as TranslationObject, - fr: fr as TranslationObject, +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; + +const FALLBACK_LANG: SupportedLang = 'it'; +const translationCache = new Map>(); + +const translationLoaders: Record< + SupportedLang, + () => Promise +> = { + 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() export class StaticTranslateLoader implements TranslateLoader { + private readonly platformId = inject(PLATFORM_ID); + private readonly transferState = inject(TransferState); + getTranslation(lang: string): Observable { - const normalized = String(lang || 'it').toLowerCase(); - return of(TRANSLATIONS[normalized] ?? TRANSLATIONS['it']); + const normalized = this.normalizeLanguage(lang); + 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 { + const transferStateKey = makeStateKey( + `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; } } diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts new file mode 100644 index 0000000..403fc8d --- /dev/null +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.spec.ts @@ -0,0 +1,77 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing'; +import { REQUEST } from '@angular/core'; +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { serverOriginInterceptor } from './server-origin.interceptor'; + +describe('serverOriginInterceptor', () => { + let http: HttpClient; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([serverOriginInterceptor])), + provideHttpClientTesting(), + { + provide: REQUEST, + useValue: { + protocol: 'https', + headers: { + host: 'dev.3d-fab.ch', + authorization: 'Basic dGVzdDp0ZXN0', + cookie: 'session=abc123', + 'accept-language': 'de-CH,de;q=0.9,en;q=0.8', + }, + }, + }, + ], + }); + + http = TestBed.inject(HttpClient); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('rewrites relative SSR URLs to the incoming origin and forwards auth headers', () => { + http.get('/api/shop/products/by-path/example?lang=de').subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products/by-path/example?lang=de', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Basic dGVzdDp0ZXN0', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + expect(request.request.headers.get('accept-language')).toBe( + 'de-CH,de;q=0.9,en;q=0.8', + ); + request.flush({}); + }); + + it('does not overwrite explicit request headers', () => { + http + .get('/api/shop/products', { + headers: { + authorization: 'Bearer explicit-token', + }, + }) + .subscribe(); + + const request = httpMock.expectOne( + 'https://dev.3d-fab.ch/api/shop/products', + ); + expect(request.request.headers.get('authorization')).toBe( + 'Bearer explicit-token', + ); + expect(request.request.headers.get('cookie')).toBe('session=abc123'); + request.flush({}); + }); +}); diff --git a/frontend/src/app/core/interceptors/server-origin.interceptor.ts b/frontend/src/app/core/interceptors/server-origin.interceptor.ts index 55fe6f8..836debb 100644 --- a/frontend/src/app/core/interceptors/server-origin.interceptor.ts +++ b/frontend/src/app/core/interceptors/server-origin.interceptor.ts @@ -1,63 +1,69 @@ import { HttpInterceptorFn } from '@angular/common/http'; import { inject, REQUEST } from '@angular/core'; +import { + RequestLike, + resolveRequestOrigin, +} from '../../../core/request-origin'; -type RequestLike = { - protocol?: string; - get?: (name: string) => string | undefined; - headers?: Record; -}; +const FORWARDED_REQUEST_HEADERS = [ + 'authorization', + 'cookie', + 'accept-language', +] as const; function isAbsoluteUrl(url: string): boolean { return /^[a-z][a-z\d+\-.]*:/i.test(url) || url.startsWith('//'); } -function firstHeaderValue(value: string | string[] | undefined): string | null { - if (Array.isArray(value)) { - return value[0] ?? null; - } - return typeof value === 'string' ? value : null; -} - -function resolveOrigin(request: RequestLike | null): string | null { - if (!request) { - return null; - } - - const host = - request.get?.('host') ?? - firstHeaderValue(request.headers?.['host']) ?? - firstHeaderValue(request.headers?.['x-forwarded-host']); - if (!host) { - return null; - } - - const forwardedProtoRaw = firstHeaderValue( - request.headers?.['x-forwarded-proto'], - ); - const forwardedProto = forwardedProtoRaw - ?.split(',') - .map((part) => part.trim().toLowerCase()) - .find(Boolean); - const protocol = forwardedProto || request.protocol || 'http'; - return `${protocol}://${host}`; -} - function normalizeRelativePath(url: string): string { const withoutDot = url.replace(/^\.\//, ''); return withoutDot.startsWith('/') ? withoutDot : `/${withoutDot}`; } +function readRequestHeader( + request: RequestLike | null, + name: (typeof FORWARDED_REQUEST_HEADERS)[number], +): string | null { + const normalizedName = name.toLowerCase(); + const headerValue = + request?.headers?.[normalizedName] ?? request?.get?.(normalizedName); + if (Array.isArray(headerValue)) { + return headerValue[0] ?? null; + } + + return typeof headerValue === 'string' ? headerValue : null; +} + export const serverOriginInterceptor: HttpInterceptorFn = (req, next) => { if (isAbsoluteUrl(req.url)) { return next(req); } const request = inject(REQUEST, { optional: true }) as RequestLike | null; - const origin = resolveOrigin(request); + const origin = resolveRequestOrigin(request); if (!origin) { return next(req); } const absoluteUrl = `${origin}${normalizeRelativePath(req.url)}`; - return next(req.clone({ url: absoluteUrl })); + const forwardedHeaders = FORWARDED_REQUEST_HEADERS.reduce< + Record + >((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, + }), + ); }; diff --git a/frontend/src/app/core/layout/footer.component.html b/frontend/src/app/core/layout/footer.component.html index 4092c69..164cf80 100644 --- a/frontend/src/app/core/layout/footer.component.html +++ b/frontend/src/app/core/layout/footer.component.html @@ -1,14 +1,24 @@