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/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/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/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/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/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/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/shop/PublicShopCatalogService.java b/backend/src/main/java/com/printcalculator/service/shop/PublicShopCatalogService.java index e37c450..62636a1 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( @@ -316,53 +316,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); @@ -399,11 +409,11 @@ 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) ); @@ -432,14 +442,14 @@ 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, @@ -449,7 +459,8 @@ public class PublicShopCatalogService { private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, ShopProductVariant defaultVariant, - Map variantColorHexByMaterialAndColor) { + Map variantColorHexByMaterialAndColor, + String language) { if (variant == null) { return null; } @@ -463,6 +474,7 @@ public class PublicShopCatalogService { variant.getSku(), variant.getVariantLabel(), variant.getColorName(), + variant.getColorLabelForLanguage(language), colorHex, variant.getPriceChf(), defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) 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/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/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/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 5fb79d4..3f246d2 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -15,9 +15,18 @@ 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.', + seoTitleByLang: { + it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab', + en: 'Custom 3D Printing in Switzerland | Prototypes, Spare Parts & Short Runs - 3D Fab', + de: '3D-Druck in Zürich | Prototypen, Ersatzteile und Kleinserien - 3D Fab', + fr: 'Impression 3D à Bienne | Prototypes, pièces et petites séries - 3D Fab', + }, + seoDescriptionByLang: { + it: 'Servizio di stampa 3D in Ticino per prototipi, pezzi di ricambio e piccole serie. Shop tecnico e supporto CAD, con preventivo rapido da file STL.', + en: 'Swiss-based 3D printing service for prototypes, spare parts and short production runs. Technical shop and CAD support, with fast quotes from STL files.', + de: '3D-Druckservice in Zürich für Prototypen, Ersatzteile und Kleinserien. Technischer Shop und CAD-Service, mit schneller Angebotsanfrage aus STL-Dateien.', + fr: "Service d'impression 3D à Bienne pour prototypes, pièces de rechange et petites séries. Boutique technique et support CAD, avec devis rapide depuis un fichier STL.", + }, }, }, { @@ -52,6 +61,18 @@ const appChildRoutes: Routes = [ 'Scopri il team 3D fab e il laboratorio di stampa 3D con sedi in Ticino e Bienne.', }, }, + /* { + path: 'materials', + loadComponent: () => + import('./features/materials/materials-page.component').then( + (m) => m.MaterialsPageComponent, + ), + data: { + seoTitle: 'Qualita e Materiali | 3D fab', + seoDescription: + 'Confronta materiali di stampa 3D con radar chart interattivo, proprieta tecniche e fonti citate.', + }, + },*/ { path: 'contact', loadChildren: () => 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/layout/navbar.component.html b/frontend/src/app/core/layout/navbar.component.html index 46d2bd8..944ca86 100644 --- a/frontend/src/app/core/layout/navbar.component.html +++ b/frontend/src/app/core/layout/navbar.component.html @@ -130,7 +130,9 @@
{{ cartItemName(item) }} @if (cartItemVariant(item); as variant) { - {{ variant }} + {{ + variant | translate + }} } @if (cartItemColor(item); as color) { @@ -138,7 +140,7 @@ class="color-dot" [style.background-color]="cartItemColorHex(item)" > - {{ color }} + {{ color | translate }} }
diff --git a/frontend/src/app/core/layout/navbar.component.ts b/frontend/src/app/core/layout/navbar.component.ts index 5a7c91a..cbd17e1 100644 --- a/frontend/src/app/core/layout/navbar.component.ts +++ b/frontend/src/app/core/layout/navbar.component.ts @@ -15,6 +15,10 @@ import { ShopService, } from '../../features/shop/services/shop.service'; import { finalize } from 'rxjs'; +import { + findColorHex, + resolveLocalizedColorLabel, +} from '../constants/colors.const'; @Component({ selector: 'app-navbar', @@ -143,15 +147,30 @@ export class NavbarComponent { } cartItemVariant(item: ShopCartItem): string | null { - return item.shopVariantLabel || item.shopVariantColorName || null; + return item.shopVariantLabel || this.cartItemColor(item); } cartItemColor(item: ShopCartItem): string | null { - return item.shopVariantColorName || item.colorCode || null; + return ( + resolveLocalizedColorLabel(this.langService.selectedLang(), { + fallback: item.shopVariantColorName ?? item.colorCode, + it: item.shopVariantColorLabelIt, + en: item.shopVariantColorLabelEn, + de: item.shopVariantColorLabelDe, + fr: item.shopVariantColorLabelFr, + }) ?? + item.shopVariantColorName ?? + item.colorCode + ); } cartItemColorHex(item: ShopCartItem): string { - return item.shopVariantColorHex || '#c9ced6'; + return ( + item.shopVariantColorHex || + findColorHex(item.shopVariantColorName) || + findColorHex(item.colorCode) || + '#c9ced6' + ); } trackByCartItem(_index: number, item: ShopCartItem): string { diff --git a/frontend/src/app/core/services/seo.service.ts b/frontend/src/app/core/services/seo.service.ts index 2637ecc..426e632 100644 --- a/frontend/src/app/core/services/seo.service.ts +++ b/frontend/src/app/core/services/seo.service.ts @@ -12,14 +12,31 @@ export interface PageSeoOverride { ogDescription?: string | null; } +type SupportedLang = 'it' | 'en' | 'de' | 'fr'; +type SeoMap = Partial>; + @Injectable({ providedIn: 'root', }) export class SeoService { - private readonly defaultTitle = '3D fab | Stampa 3D su misura'; - private readonly defaultDescription = - 'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; - private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); + private readonly defaultTitleByLang: Record = { + it: '3D fab | Stampa 3D su misura', + en: '3D fab | Custom 3D Printing', + de: '3D fab | 3D-Druck nach Maß', + fr: '3D fab | Impression 3D sur mesure', + }; + private readonly defaultDescriptionByLang: Record = { + it: 'Servizio di stampa 3D su misura, shop tecnico e supporto CAD per prototipi, ricambi e piccole serie.', + en: 'Custom 3D printing service, technical shop and CAD support for prototypes, spare parts and short runs.', + de: '3D-Druckservice nach Maß, technischer Shop und CAD-Support für Prototypen, Ersatzteile und Kleinserien.', + fr: "Service d'impression 3D sur mesure, boutique technique et support CAD pour prototypes, pièces et petites séries.", + }; + private readonly supportedLangs = new Set([ + 'it', + 'en', + 'de', + 'fr', + ]); constructor( private router: Router, @@ -40,9 +57,13 @@ export class SeoService { } applyPageSeo(override: PageSeoOverride): void { - const title = this.asString(override.title) ?? this.defaultTitle; + const cleanPath = this.getCleanPath(this.router.url); + const lang = this.resolveLangFromPath(cleanPath); + const title = + this.asString(override.title) ?? this.defaultTitleByLang[lang]; const description = - this.asString(override.description) ?? this.defaultDescription; + this.asString(override.description) ?? + this.defaultDescriptionByLang[lang]; const robots = this.asString(override.robots) ?? 'index, follow'; const ogTitle = this.asString(override.ogTitle) ?? title; const ogDescription = this.asString(override.ogDescription) ?? description; @@ -52,13 +73,18 @@ export class SeoService { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { const mergedData = this.getMergedRouteData(rootSnapshot); - const title = this.asString(mergedData['seoTitle']) ?? this.defaultTitle; + const cleanPath = this.getCleanPath(this.router.url); + const lang = this.resolveLangFromPath(cleanPath); + const title = + this.resolveSeoText(mergedData, 'seoTitle', lang) ?? + this.defaultTitleByLang[lang]; const description = - this.asString(mergedData['seoDescription']) ?? this.defaultDescription; + this.resolveSeoText(mergedData, 'seoDescription', lang) ?? + this.defaultDescriptionByLang[lang]; const robots = this.asString(mergedData['seoRobots']) ?? 'index, follow'; - const ogTitle = this.asString(mergedData['ogTitle']) ?? title; + const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title; const ogDescription = - this.asString(mergedData['ogDescription']) ?? description; + this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description; this.applySeoValues(title, description, robots, ogTitle, ogDescription); } @@ -104,11 +130,43 @@ export class SeoService { return typeof value === 'string' ? value : undefined; } + private resolveSeoText( + routeData: Record, + key: 'seoTitle' | 'seoDescription' | 'ogTitle' | 'ogDescription', + lang: SupportedLang, + ): string | undefined { + const mapKey = `${key}ByLang`; + const localized = routeData[mapKey]; + if ( + localized && + typeof localized === 'object' && + !Array.isArray(localized) + ) { + const mapped = localized as SeoMap; + const byLang = this.asString(mapped[lang]); + if (byLang) { + return byLang; + } + } + return this.asString(routeData[key]); + } + private getCleanPath(url: string): string { const path = (url || '/').split('?')[0].split('#')[0]; return path || '/'; } + private resolveLangFromPath(path: string): SupportedLang { + const firstSegment = path.split('/').filter(Boolean)[0]?.toLowerCase(); + if ( + firstSegment && + this.supportedLangs.has(firstSegment as SupportedLang) + ) { + return firstSegment as SupportedLang; + } + return 'it'; + } + private updateCanonicalTag(url: string): void { let link = this.document.head.querySelector( 'link[rel="canonical"]', @@ -124,10 +182,9 @@ export class SeoService { private updateLangAndAlternates(path: string): void { const segments = path.split('/').filter(Boolean); const firstSegment = segments[0]?.toLowerCase(); - const hasLang = Boolean( - firstSegment && this.supportedLangs.has(firstSegment), - ); - const lang = hasLang ? firstSegment : 'it'; + const maybeLang = firstSegment as SupportedLang | undefined; + const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang)); + const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it'; const suffixSegments = hasLang ? segments.slice(1) : segments; const suffix = suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; diff --git a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html index 98ed4dd..63a6d7e 100644 --- a/frontend/src/app/features/admin/pages/admin-filament-stock.component.html +++ b/frontend/src/app/features/admin/pages/admin-filament-stock.component.html @@ -101,6 +101,22 @@ placeholder="Nero, Bianco..." /> + + + + + + + + - - - - - - - -