dev #45

Merged
JoeKung merged 7 commits from dev into main 2026-03-13 16:36:42 +01:00
60 changed files with 3022 additions and 405 deletions

View File

@@ -94,6 +94,10 @@ public class OptionsController {
v.getId(), v.getId(),
v.getVariantDisplayName(), v.getVariantDisplayName(),
v.getColorName(), v.getColorName(),
v.getColorLabelIt(),
v.getColorLabelEn(),
v.getColorLabelDe(),
v.getColorLabelFr(),
resolveHexColor(v), resolveHexColor(v),
v.getFinishType() != null ? v.getFinishType() : "GLOSSY", v.getFinishType() != null ? v.getFinishType() : "GLOSSY",
v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d, v.getStockSpools() != null ? v.getStockSpools().doubleValue() : 0d,

View File

@@ -12,6 +12,10 @@ public class AdminFilamentVariantDto {
private String materialTechnicalTypeLabel; private String materialTechnicalTypeLabel;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -89,6 +93,38 @@ public class AdminFilamentVariantDto {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -10,9 +10,25 @@ public class AdminShopCategoryDto {
private String parentCategoryName; private String parentCategoryName;
private String slug; private String slug;
private String name; private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description; private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -69,6 +85,38 @@ public class AdminShopCategoryDto {
this.name = name; 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() { public String getDescription() {
return description; return description;
} }
@@ -77,6 +125,38 @@ public class AdminShopCategoryDto {
this.description = description; 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() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -85,6 +165,38 @@ public class AdminShopCategoryDto {
this.seoTitle = seoTitle; 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() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -93,6 +205,38 @@ public class AdminShopCategoryDto {
this.seoDescription = seoDescription; 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() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -9,6 +9,10 @@ public class AdminShopProductVariantDto {
private String sku; private String sku;
private String variantLabel; private String variantLabel;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String internalMaterialCode; private String internalMaterialCode;
private BigDecimal priceChf; private BigDecimal priceChf;
@@ -50,6 +54,38 @@ public class AdminShopProductVariantDto {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -6,6 +6,10 @@ public class AdminUpsertFilamentVariantRequest {
private Long materialTypeId; private Long materialTypeId;
private String variantDisplayName; private String variantDisplayName;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String finishType; private String finishType;
private String brand; private String brand;
@@ -40,6 +44,38 @@ public class AdminUpsertFilamentVariantRequest {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -6,9 +6,25 @@ public class AdminUpsertShopCategoryRequest {
private UUID parentCategoryId; private UUID parentCategoryId;
private String slug; private String slug;
private String name; private String name;
private String nameIt;
private String nameEn;
private String nameDe;
private String nameFr;
private String description; private String description;
private String descriptionIt;
private String descriptionEn;
private String descriptionDe;
private String descriptionFr;
private String seoTitle; private String seoTitle;
private String seoTitleIt;
private String seoTitleEn;
private String seoTitleDe;
private String seoTitleFr;
private String seoDescription; private String seoDescription;
private String seoDescriptionIt;
private String seoDescriptionEn;
private String seoDescriptionDe;
private String seoDescriptionFr;
private String ogTitle; private String ogTitle;
private String ogDescription; private String ogDescription;
private Boolean indexable; private Boolean indexable;
@@ -39,6 +55,38 @@ public class AdminUpsertShopCategoryRequest {
this.name = name; 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() { public String getDescription() {
return description; return description;
} }
@@ -47,6 +95,38 @@ public class AdminUpsertShopCategoryRequest {
this.description = description; 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() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -55,6 +135,38 @@ public class AdminUpsertShopCategoryRequest {
this.seoTitle = seoTitle; 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() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -63,6 +175,38 @@ public class AdminUpsertShopCategoryRequest {
this.seoDescription = seoDescription; 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() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }

View File

@@ -8,6 +8,10 @@ public class AdminUpsertShopProductVariantRequest {
private String sku; private String sku;
private String variantLabel; private String variantLabel;
private String colorName; private String colorName;
private String colorLabelIt;
private String colorLabelEn;
private String colorLabelDe;
private String colorLabelFr;
private String colorHex; private String colorHex;
private String internalMaterialCode; private String internalMaterialCode;
private BigDecimal priceChf; private BigDecimal priceChf;
@@ -47,6 +51,38 @@ public class AdminUpsertShopProductVariantRequest {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }

View File

@@ -15,6 +15,10 @@ public record OptionsResponse(
Long id, Long id,
String name, String name,
String colorName, String colorName,
String colorLabelIt,
String colorLabelEn,
String colorLabelDe,
String colorLabelFr,
String hexColor, String hexColor,
String finishType, String finishType,
Double stockSpools, Double stockSpools,

View File

@@ -17,9 +17,17 @@ public class OrderItemDto {
private String shopProductName; private String shopProductName;
private String shopVariantLabel; private String shopVariantLabel;
private String shopVariantColorName; private String shopVariantColorName;
private String shopVariantColorLabelIt;
private String shopVariantColorLabelEn;
private String shopVariantColorLabelDe;
private String shopVariantColorLabelFr;
private String shopVariantColorHex; private String shopVariantColorHex;
private String filamentVariantDisplayName; private String filamentVariantDisplayName;
private String filamentColorName; private String filamentColorName;
private String filamentColorLabelIt;
private String filamentColorLabelEn;
private String filamentColorLabelDe;
private String filamentColorLabelFr;
private String filamentColorHex; private String filamentColorHex;
private String quality; private String quality;
private BigDecimal nozzleDiameterMm; private BigDecimal nozzleDiameterMm;
@@ -73,6 +81,18 @@ public class OrderItemDto {
public String getShopVariantColorName() { return shopVariantColorName; } public String getShopVariantColorName() { return shopVariantColorName; }
public void setShopVariantColorName(String shopVariantColorName) { this.shopVariantColorName = 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 String getShopVariantColorHex() { return shopVariantColorHex; }
public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; } public void setShopVariantColorHex(String shopVariantColorHex) { this.shopVariantColorHex = shopVariantColorHex; }
@@ -82,6 +102,18 @@ public class OrderItemDto {
public String getFilamentColorName() { return filamentColorName; } public String getFilamentColorName() { return filamentColorName; }
public void setFilamentColorName(String filamentColorName) { this.filamentColorName = 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 String getFilamentColorHex() { return filamentColorHex; }
public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; } public void setFilamentColorHex(String filamentColorHex) { this.filamentColorHex = filamentColorHex; }

View File

@@ -8,6 +8,7 @@ public record ShopProductVariantOptionDto(
String sku, String sku,
String variantLabel, String variantLabel,
String colorName, String colorName,
String colorLabel,
String colorHex, String colorHex,
BigDecimal priceChf, BigDecimal priceChf,
Boolean isDefault Boolean isDefault

View File

@@ -24,6 +24,18 @@ public class FilamentVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; 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) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -93,6 +105,38 @@ public class FilamentVariant {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -173,4 +217,60 @@ public class FilamentVariant {
this.createdAt = createdAt; 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;
}
} }

View File

@@ -15,6 +15,7 @@ import jakarta.persistence.Table;
import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.ColumnDefault;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Entity @Entity
@@ -23,6 +24,8 @@ import java.util.UUID;
@Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order") @Index(name = "ix_shop_category_active_sort", columnList = "is_active, sort_order")
}) })
public class ShopCategory { public class ShopCategory {
public static final List<String> SUPPORTED_LANGUAGES = List.of("it", "en", "de", "fr");
@Id @Id
@GeneratedValue(strategy = GenerationType.AUTO) @GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "shop_category_id", nullable = false) @Column(name = "shop_category_id", nullable = false)
@@ -38,15 +41,63 @@ public class ShopCategory {
@Column(name = "name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "name", nullable = false, length = Integer.MAX_VALUE)
private String name; 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) @Column(name = "description", length = Integer.MAX_VALUE)
private String description; 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) @Column(name = "seo_title", length = Integer.MAX_VALUE)
private String seoTitle; 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) @Column(name = "seo_description", length = Integer.MAX_VALUE)
private String seoDescription; 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) @Column(name = "og_title", length = Integer.MAX_VALUE)
private String ogTitle; private String ogTitle;
@@ -139,6 +190,38 @@ public class ShopCategory {
this.name = name; 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() { public String getDescription() {
return description; return description;
} }
@@ -147,6 +230,38 @@ public class ShopCategory {
this.description = description; 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() { public String getSeoTitle() {
return seoTitle; return seoTitle;
} }
@@ -155,6 +270,38 @@ public class ShopCategory {
this.seoTitle = seoTitle; 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() { public String getSeoDescription() {
return seoDescription; return seoDescription;
} }
@@ -163,6 +310,38 @@ public class ShopCategory {
this.seoDescription = seoDescription; 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() { public String getOgTitle() {
return ogTitle; return ogTitle;
} }
@@ -218,4 +397,109 @@ public class ShopCategory {
public void setUpdatedAt(OffsetDateTime updatedAt) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = 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;
}
} }

View File

@@ -42,6 +42,18 @@ public class ShopProductVariant {
@Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE) @Column(name = "color_name", nullable = false, length = Integer.MAX_VALUE)
private String colorName; 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) @Column(name = "color_hex", length = Integer.MAX_VALUE)
private String colorHex; private String colorHex;
@@ -152,6 +164,38 @@ public class ShopProductVariant {
this.colorName = colorName; 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() { public String getColorHex() {
return colorHex; return colorHex;
} }
@@ -215,4 +259,60 @@ public class ShopProductVariant {
public void setUpdatedAt(OffsetDateTime updatedAt) { public void setUpdatedAt(OffsetDateTime updatedAt) {
this.updatedAt = 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;
}
} }

View File

@@ -29,6 +29,7 @@ import java.util.*;
@Service @Service
public class OrderService { public class OrderService {
private static final Path QUOTE_STORAGE_ROOT = Paths.get("storage_quotes").toAbsolutePath().normalize(); 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 OrderRepository orderRepo;
private final OrderItemRepository orderItemRepo; private final OrderItemRepository orderItemRepo;
@@ -235,19 +236,21 @@ public class OrderService {
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
String relativePath = "orders/" + order.getId() + "/3d-files/" + oItem.getId() + "/" + storedFilename;
oItem.setStoredRelativePath(relativePath);
Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId()); Path sourcePath = resolveStoredQuotePath(qItem.getStoredPath(), session.getId());
if (sourcePath == null || !Files.exists(sourcePath)) { if (sourcePath == null || !Files.exists(sourcePath)) {
if (requiresStoredSourceFile(qItem)) {
throw new IllegalStateException("Source file not available for quote line item " + qItem.getId()); 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 { try {
storageService.store(sourcePath, Paths.get(relativePath)); storageService.store(sourcePath, Paths.get(relativePath));
oItem.setFileSizeBytes(Files.size(sourcePath)); oItem.setFileSizeBytes(Files.size(sourcePath));
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e); throw new RuntimeException("Failed to copy quote file for line item " + qItem.getId(), e);
} }
}
oItem = orderItemRepo.save(oItem); oItem = orderItemRepo.save(oItem);
savedItems.add(oItem); savedItems.add(oItem);
@@ -318,6 +321,12 @@ public class OrderService {
return "stl"; 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) { private Path resolveStoredQuotePath(String storedPath, UUID expectedSessionId) {
if (storedPath == null || storedPath.isBlank()) { if (storedPath == null || storedPath.isBlank()) {
return null; return null;

View File

@@ -161,10 +161,21 @@ public class AdminFilamentControllerService {
String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex()); String normalizedColorHex = normalizeAndValidateColorHex(payload.getColorHex());
String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte()); String normalizedFinishType = normalizeAndValidateFinishType(payload.getFinishType(), payload.getIsMatte());
String normalizedBrand = normalizeOptional(payload.getBrand()); 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.setFilamentMaterialType(material);
variant.setVariantDisplayName(normalizedDisplayName); variant.setVariantDisplayName(normalizedDisplayName);
variant.setColorName(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(normalizedColorHex); variant.setColorHex(normalizedColorHex);
variant.setFinishType(normalizedFinishType); variant.setFinishType(normalizedFinishType);
variant.setBrand(normalizedBrand); variant.setBrand(normalizedBrand);
@@ -226,6 +237,18 @@ public class AdminFilamentControllerService {
return normalized.isBlank() ? null : normalized; 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) { private FilamentMaterialType validateAndResolveMaterial(AdminUpsertFilamentVariantRequest payload) {
if (payload == null || payload.getMaterialTypeId() == null) { if (payload == null || payload.getMaterialTypeId() == null) {
throw new ResponseStatusException(BAD_REQUEST, "Material type id is required"); throw new ResponseStatusException(BAD_REQUEST, "Material type id is required");
@@ -306,6 +329,10 @@ public class AdminFilamentControllerService {
dto.setVariantDisplayName(variant.getVariantDisplayName()); dto.setVariantDisplayName(variant.getVariantDisplayName());
dto.setColorName(variant.getColorName()); 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.setColorHex(variant.getColorHex());
dto.setFinishType(variant.getFinishType()); dto.setFinishType(variant.getFinishType());
dto.setBrand(variant.getBrand()); dto.setBrand(variant.getBrand());

View File

@@ -67,13 +67,13 @@ public class AdminShopCategoryControllerService {
@Transactional @Transactional
public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) { public AdminShopCategoryDto createCategory(AdminUpsertShopCategoryRequest payload) {
ensurePayload(payload); ensurePayload(payload);
String normalizedName = normalizeRequiredName(payload.getName()); LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, null); ensureSlugAvailable(normalizedSlug, null);
ShopCategory category = new ShopCategory(); ShopCategory category = new ShopCategory();
category.setCreatedAt(OffsetDateTime.now()); category.setCreatedAt(OffsetDateTime.now());
applyPayload(category, payload, normalizedName, normalizedSlug, null); applyPayload(category, payload, localizedContent, normalizedSlug, null);
ShopCategory saved = shopCategoryRepository.save(category); ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId()); return getCategory(saved.getId());
@@ -86,11 +86,11 @@ public class AdminShopCategoryControllerService {
ShopCategory category = shopCategoryRepository.findById(categoryId) ShopCategory category = shopCategoryRepository.findById(categoryId)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found")); .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Shop category not found"));
String normalizedName = normalizeRequiredName(payload.getName()); LocalizedCategoryContent localizedContent = normalizeLocalizedCategoryContent(payload);
String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), normalizedName); String normalizedSlug = normalizeAndValidateSlug(payload.getSlug(), localizedContent.defaultName());
ensureSlugAvailable(normalizedSlug, category.getId()); ensureSlugAvailable(normalizedSlug, category.getId());
applyPayload(category, payload, normalizedName, normalizedSlug, category.getId()); applyPayload(category, payload, localizedContent, normalizedSlug, category.getId());
ShopCategory saved = shopCategoryRepository.save(category); ShopCategory saved = shopCategoryRepository.save(category);
return getCategory(saved.getId()); return getCategory(saved.getId());
} }
@@ -112,17 +112,33 @@ public class AdminShopCategoryControllerService {
private void applyPayload(ShopCategory category, private void applyPayload(ShopCategory category,
AdminUpsertShopCategoryRequest payload, AdminUpsertShopCategoryRequest payload,
String normalizedName, LocalizedCategoryContent localizedContent,
String normalizedSlug, String normalizedSlug,
UUID currentCategoryId) { UUID currentCategoryId) {
ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId); ShopCategory parentCategory = resolveParentCategory(payload.getParentCategoryId(), currentCategoryId);
category.setParentCategory(parentCategory); category.setParentCategory(parentCategory);
category.setSlug(normalizedSlug); category.setSlug(normalizedSlug);
category.setName(normalizedName); category.setName(localizedContent.defaultName());
category.setDescription(normalizeOptional(payload.getDescription())); category.setNameIt(localizedContent.names().get("it"));
category.setSeoTitle(normalizeOptional(payload.getSeoTitle())); category.setNameEn(localizedContent.names().get("en"));
category.setSeoDescription(normalizeOptional(payload.getSeoDescription())); 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.setOgTitle(normalizeOptional(payload.getOgTitle()));
category.setOgDescription(normalizeOptional(payload.getOgDescription())); category.setOgDescription(normalizeOptional(payload.getOgDescription()));
category.setIndexable(payload.getIndexable() == null || payload.getIndexable()); 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) { private String normalizeAndValidateSlug(String slug, String fallbackName) {
String source = normalizeOptional(slug); String source = normalizeOptional(slug);
if (source == null) { if (source == null) {
@@ -203,6 +211,103 @@ public class AdminShopCategoryControllerService {
return normalized.isBlank() ? null : normalized; 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<String, String> 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<String, String> 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<String, String> 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<String, String> 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() { private CategoryContext buildContext() {
List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc(); List<ShopCategory> categories = shopCategoryRepository.findAllByOrderBySortOrderAscNameAsc();
List<ShopProduct> products = shopProductRepository.findAll(); List<ShopProduct> products = shopProductRepository.findAll();
@@ -278,9 +383,25 @@ public class AdminShopCategoryControllerService {
dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null); dto.setParentCategoryName(category.getParentCategory() != null ? category.getParentCategory().getName() : null);
dto.setSlug(category.getSlug()); dto.setSlug(category.getSlug());
dto.setName(category.getName()); 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.setDescription(category.getDescription());
dto.setDescriptionIt(category.getDescriptionIt());
dto.setDescriptionEn(category.getDescriptionEn());
dto.setDescriptionDe(category.getDescriptionDe());
dto.setDescriptionFr(category.getDescriptionFr());
dto.setSeoTitle(category.getSeoTitle()); 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.setSeoDescription(category.getSeoDescription());
dto.setSeoDescriptionIt(category.getSeoDescriptionIt());
dto.setSeoDescriptionEn(category.getSeoDescriptionEn());
dto.setSeoDescriptionDe(category.getSeoDescriptionDe());
dto.setSeoDescriptionFr(category.getSeoDescriptionFr());
dto.setOgTitle(category.getOgTitle()); dto.setOgTitle(category.getOgTitle());
dto.setOgDescription(category.getOgDescription()); dto.setOgDescription(category.getOgDescription());
dto.setIndexable(category.getIndexable()); dto.setIndexable(category.getIndexable());
@@ -331,4 +452,16 @@ public class AdminShopCategoryControllerService {
Map<UUID, Integer> descendantProductCounts Map<UUID, Integer> descendantProductCounts
) { ) {
} }
private record LocalizedCategoryContent(
String defaultName,
String defaultDescription,
String defaultSeoTitle,
String defaultSeoDescription,
Map<String, String> names,
Map<String, String> descriptions,
Map<String, String> seoTitles,
Map<String, String> seoDescriptions
) {
}
} }

View File

@@ -353,6 +353,13 @@ public class AdminShopProductControllerService {
String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required"); String normalizedColorName = normalizeRequired(payload.getColorName(), "Variant colorName is required");
String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel()); String normalizedVariantLabel = normalizeOptional(payload.getVariantLabel());
String normalizedSku = normalizeOptional(payload.getSku()); String normalizedSku = normalizeOptional(payload.getSku());
String fallbackColorLabel = firstNonBlank(
normalizeOptional(payload.getColorLabelIt()),
normalizeOptional(payload.getColorLabelEn()),
normalizeOptional(payload.getColorLabelDe()),
normalizeOptional(payload.getColorLabelFr()),
normalizedColorName
);
String normalizedMaterialCode = normalizeRequired( String normalizedMaterialCode = normalizeRequired(
payload.getInternalMaterialCode(), payload.getInternalMaterialCode(),
"Variant internalMaterialCode is required" "Variant internalMaterialCode is required"
@@ -380,6 +387,10 @@ public class AdminShopProductControllerService {
variant.setSku(normalizedSku); variant.setSku(normalizedSku);
variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName); variant.setVariantLabel(normalizedVariantLabel != null ? normalizedVariantLabel : normalizedColorName);
variant.setColorName(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.setColorHex(normalizeColorHex(payload.getColorHex()));
variant.setInternalMaterialCode(normalizedMaterialCode); variant.setInternalMaterialCode(normalizedMaterialCode);
variant.setPriceChf(price); variant.setPriceChf(price);
@@ -531,6 +542,10 @@ public class AdminShopProductControllerService {
dto.setSku(variant.getSku()); dto.setSku(variant.getSku());
dto.setVariantLabel(variant.getVariantLabel()); dto.setVariantLabel(variant.getVariantLabel());
dto.setColorName(variant.getColorName()); 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.setColorHex(variant.getColorHex());
dto.setInternalMaterialCode(variant.getInternalMaterialCode()); dto.setInternalMaterialCode(variant.getInternalMaterialCode());
dto.setPriceChf(variant.getPriceChf()); dto.setPriceChf(variant.getPriceChf());

View File

@@ -280,11 +280,19 @@ public class AdminOrderControllerService {
itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName()); 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()); itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); 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.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -334,11 +334,19 @@ public class OrderControllerService {
itemDto.setShopProductName(item.getShopProductName()); itemDto.setShopProductName(item.getShopProductName());
itemDto.setShopVariantLabel(item.getShopVariantLabel()); itemDto.setShopVariantLabel(item.getShopVariantLabel());
itemDto.setShopVariantColorName(item.getShopVariantColorName()); 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()); itemDto.setShopVariantColorHex(item.getShopVariantColorHex());
if (item.getFilamentVariant() != null) { if (item.getFilamentVariant() != null) {
itemDto.setFilamentVariantId(item.getFilamentVariant().getId()); itemDto.setFilamentVariantId(item.getFilamentVariant().getId());
itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName()); itemDto.setFilamentVariantDisplayName(item.getFilamentVariant().getVariantDisplayName());
itemDto.setFilamentColorName(item.getFilamentVariant().getColorName()); 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.setFilamentColorHex(item.getFilamentVariant().getColorHex());
} }
itemDto.setQuality(item.getQuality()); itemDto.setQuality(item.getQuality());

View File

@@ -81,7 +81,15 @@ public class QuoteSessionResponseAssembler {
dto.put("shopProductName", item.getShopProductName()); dto.put("shopProductName", item.getShopProductName());
dto.put("shopVariantLabel", item.getShopVariantLabel()); dto.put("shopVariantLabel", item.getShopVariantLabel());
dto.put("shopVariantColorName", item.getShopVariantColorName()); 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("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("materialCode", item.getMaterialCode());
dto.put("quality", item.getQuality()); dto.put("quality", item.getQuality());
dto.put("nozzleDiameterMm", item.getNozzleDiameterMm()); dto.put("nozzleDiameterMm", item.getNozzleDiameterMm());

View File

@@ -71,7 +71,7 @@ public class PublicShopCatalogService {
public List<ShopCategoryTreeDto> getCategories(String language) { public List<ShopCategoryTreeDto> getCategories(String language) {
CategoryContext categoryContext = loadCategoryContext(language); CategoryContext categoryContext = loadCategoryContext(language);
return buildCategoryTree(null, categoryContext); return buildCategoryTree(null, categoryContext, language);
} }
public ShopCategoryDetailDto getCategory(String slug, String language) { public ShopCategoryDetailDto getCategory(String slug, String language) {
@@ -83,7 +83,7 @@ public class PublicShopCatalogService {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Category not found"); 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) { public ShopProductCatalogResponseDto getProductCatalog(String categorySlug, Boolean featuredOnly, String language) {
@@ -114,7 +114,7 @@ public class PublicShopCatalogService {
.toList(); .toList();
ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null ShopCategoryDetailDto selectedCategoryDetail = selectedCategory != null
? buildCategoryDetail(selectedCategory, categoryContext) ? buildCategoryDetail(selectedCategory, categoryContext, language)
: null; : null;
return new ShopProductCatalogResponseDto( return new ShopProductCatalogResponseDto(
@@ -316,53 +316,63 @@ public class PublicShopCatalogService {
return total; return total;
} }
private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId, CategoryContext categoryContext) { private List<ShopCategoryTreeDto> buildCategoryTree(UUID parentId,
CategoryContext categoryContext,
String language) {
return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream() return categoryContext.childrenByParentId().getOrDefault(parentId, List.of()).stream()
.map(category -> new ShopCategoryTreeDto( .map(category -> new ShopCategoryTreeDto(
category.getId(), category.getId(),
category.getParentCategory() != null ? category.getParentCategory().getId() : null, category.getParentCategory() != null ? category.getParentCategory().getId() : null,
category.getSlug(), category.getSlug(),
category.getName(), category.getNameForLanguage(language),
category.getDescription(), category.getDescriptionForLanguage(language),
category.getSeoTitle(), category.getSeoTitleForLanguage(language),
category.getSeoDescription(), category.getSeoDescriptionForLanguage(language),
category.getOgTitle(), category.getOgTitle(),
category.getOgDescription(), category.getOgDescription(),
category.getIndexable(), category.getIndexable(),
category.getSortOrder(), category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))), selectPrimaryMedia(categoryContext.categoryMediaBySlug().get(categoryMediaUsageKey(category))),
buildCategoryTree(category.getId(), categoryContext) buildCategoryTree(category.getId(), categoryContext, language)
)) ))
.toList(); .toList();
} }
private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category, CategoryContext categoryContext) { private ShopCategoryDetailDto buildCategoryDetail(ShopCategory category,
CategoryContext categoryContext,
String language) {
List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of()); List<PublicMediaUsageDto> images = categoryContext.categoryMediaBySlug().getOrDefault(categoryMediaUsageKey(category), List.of());
String localizedSeoTitle = category.getSeoTitleForLanguage(language);
String localizedSeoDescription = category.getSeoDescriptionForLanguage(language);
return new ShopCategoryDetailDto( return new ShopCategoryDetailDto(
category.getId(), category.getId(),
category.getSlug(), category.getSlug(),
category.getName(), category.getNameForLanguage(language),
category.getDescription(), category.getDescriptionForLanguage(language),
category.getSeoTitle(), localizedSeoTitle,
category.getSeoDescription(), localizedSeoDescription,
category.getOgTitle(), category.getOgTitle(),
category.getOgDescription(), category.getOgDescription(),
category.getIndexable(), category.getIndexable(),
category.getSortOrder(), category.getSortOrder(),
categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0), categoryContext.descendantProductCounts().getOrDefault(category.getId(), 0),
buildCategoryBreadcrumbs(category), buildCategoryBreadcrumbs(category, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
buildCategoryTree(category.getId(), categoryContext) buildCategoryTree(category.getId(), categoryContext, language)
); );
} }
private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category) { private List<ShopCategoryRefDto> buildCategoryBreadcrumbs(ShopCategory category, String language) {
List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>(); List<ShopCategoryRefDto> breadcrumbs = new ArrayList<>();
ShopCategory current = category; ShopCategory current = category;
while (current != null) { 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(); current = current.getParentCategory();
} }
java.util.Collections.reverse(breadcrumbs); java.util.Collections.reverse(breadcrumbs);
@@ -399,11 +409,11 @@ public class PublicShopCatalogService {
new ShopCategoryRefDto( new ShopCategoryRefDto(
entry.product().getCategory().getId(), entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(), entry.product().getCategory().getSlug(),
entry.product().getCategory().getName() entry.product().getCategory().getNameForLanguage(language)
), ),
resolvePriceFrom(entry.variants()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
selectPrimaryMedia(images), selectPrimaryMedia(images),
toProductModelDto(entry) toProductModelDto(entry)
); );
@@ -432,14 +442,14 @@ public class PublicShopCatalogService {
new ShopCategoryRefDto( new ShopCategoryRefDto(
entry.product().getCategory().getId(), entry.product().getCategory().getId(),
entry.product().getCategory().getSlug(), 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()), resolvePriceFrom(entry.variants()),
resolvePriceTo(entry.variants()), resolvePriceTo(entry.variants()),
toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor), toVariantDto(entry.defaultVariant(), entry.defaultVariant(), variantColorHexByMaterialAndColor, language),
entry.variants().stream() entry.variants().stream()
.map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor)) .map(variant -> toVariantDto(variant, entry.defaultVariant(), variantColorHexByMaterialAndColor, language))
.toList(), .toList(),
selectPrimaryMedia(images), selectPrimaryMedia(images),
images, images,
@@ -449,7 +459,8 @@ public class PublicShopCatalogService {
private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant, private ShopProductVariantOptionDto toVariantDto(ShopProductVariant variant,
ShopProductVariant defaultVariant, ShopProductVariant defaultVariant,
Map<String, String> variantColorHexByMaterialAndColor) { Map<String, String> variantColorHexByMaterialAndColor,
String language) {
if (variant == null) { if (variant == null) {
return null; return null;
} }
@@ -463,6 +474,7 @@ public class PublicShopCatalogService {
variant.getSku(), variant.getSku(),
variant.getVariantLabel(), variant.getVariantLabel(),
variant.getColorName(), variant.getColorName(),
variant.getColorLabelForLanguage(language),
colorHex, colorHex,
variant.getPriceChf(), variant.getPriceChf(),
defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId()) defaultVariant != null && Objects.equals(defaultVariant.getId(), variant.getId())

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -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"));
}
}

View File

@@ -40,10 +40,13 @@ import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals; 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.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -217,6 +220,210 @@ class OrderServiceTest {
verify(eventPublisher).publishEvent(any(OrderCreatedEvent.class)); 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("<svg/>".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<OrderItem> 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() { private CreateOrderRequest buildRequest() {
CustomerDto customer = new CustomerDto(); CustomerDto customer = new CustomerDto();
customer.setEmail("buyer@example.com"); customer.setEmail("buyer@example.com");

116
db.sql
View File

@@ -44,6 +44,10 @@ create table filament_variant
variant_display_name text not null, -- es: "PLA Nero Opaco BrandX" variant_display_name text not null, -- es: "PLA Nero Opaco BrandX"
color_name text not null, -- Nero, Bianco, ecc. 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, color_hex text,
finish_type text not null default 'GLOSSY' finish_type text not null default 'GLOSSY'
check (finish_type in ('GLOSSY', 'MATTE', 'MARBLE', 'SILK', 'TRANSLUCENT', 'SPECIAL')), 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 (stock_spools * spool_net_kg) as stock_kg
from filament_variant; 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 create table printer_machine_profile
( (
printer_machine_profile_id bigserial primary key, 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, parent_category_id uuid REFERENCES shop_category (shop_category_id) ON DELETE SET NULL,
slug text NOT NULL UNIQUE, slug text NOT NULL UNIQUE,
name text NOT NULL, name text NOT NULL,
name_it text,
name_en text,
name_de text,
name_fr text,
description text, description text,
description_it text,
description_en text,
description_de text,
description_fr text,
seo_title 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 text,
seo_description_it text,
seo_description_en text,
seo_description_de text,
seo_description_fr text,
og_title text, og_title text,
og_description text, og_description text,
indexable boolean NOT NULL DEFAULT true, 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 CREATE INDEX IF NOT EXISTS ix_shop_category_active_sort
ON shop_category (is_active, sort_order, created_at DESC); 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 CREATE TABLE IF NOT EXISTS shop_product
( (
shop_product_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), 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, sku text UNIQUE,
variant_label text NOT NULL, variant_label text NOT NULL,
color_name 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, color_hex text,
internal_material_code text NOT NULL, internal_material_code text NOT NULL,
price_chf numeric(12, 2) NOT NULL DEFAULT 0.00 CHECK (price_chf >= 0), 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 CREATE INDEX IF NOT EXISTS ix_shop_product_variant_sku
ON 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 CREATE TABLE IF NOT EXISTS shop_product_model_asset
( (
shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), shop_product_model_asset_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),

View File

@@ -61,13 +61,13 @@
"budgets": [ "budgets": [
{ {
"type": "initial", "type": "initial",
"maximumWarning": "500kB", "maximumWarning": "600kB",
"maximumError": "1MB" "maximumError": "1.2MB"
}, },
{ {
"type": "anyComponentStyle", "type": "anyComponentStyle",
"maximumWarning": "4kB", "maximumWarning": "10kB",
"maximumError": "8kB" "maximumError": "14kB"
} }
] ]
}, },

View File

@@ -15,9 +15,18 @@ const appChildRoutes: Routes = [
loadComponent: () => loadComponent: () =>
import('./features/home/home.component').then((m) => m.HomeComponent), import('./features/home/home.component').then((m) => m.HomeComponent),
data: { data: {
seoTitle: '3D fab | Stampa 3D su misura', seoTitleByLang: {
seoDescription: it: 'Stampa 3D su misura in Ticino | Prototipi, ricambi e piccole serie - 3D Fab',
'Servizio di stampa 3D con preventivo online immediato per prototipi, piccole serie e pezzi personalizzati.', 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.', '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', path: 'contact',
loadChildren: () => loadChildren: () =>

View File

@@ -11,6 +11,8 @@ export interface ColorCategory {
colors: ColorOption[]; colors: ColorOption[];
} }
const DEFAULT_BRAND_COLOR = '#facf0a';
export const PRODUCT_COLORS: ColorCategory[] = [ export const PRODUCT_COLORS: ColorCategory[] = [
{ {
name: 'COLOR.CATEGORY_GLOSSY', name: 'COLOR.CATEGORY_GLOSSY',
@@ -38,10 +40,81 @@ export const PRODUCT_COLORS: ColorCategory[] = [
}, },
]; ];
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 | undefined>
): string | null {
for (const value of values) {
const normalized = String(value ?? '').trim();
if (normalized) {
return normalized;
}
}
return null;
}
export function getColorHex(value: string): string { export function getColorHex(value: string): string {
for (const cat of PRODUCT_COLORS) { return findColorHex(value) ?? DEFAULT_BRAND_COLOR;
const found = cat.colors.find((c) => c.value === value);
if (found) return found.hex;
}
return '#facf0a'; // Default Brand Color if not found
} }

View File

@@ -130,7 +130,9 @@
<div class="cart-line-copy"> <div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong> <strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) { @if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant }}</span> <span class="cart-line-meta">{{
variant | translate
}}</span>
} }
@if (cartItemColor(item); as color) { @if (cartItemColor(item); as color) {
<span class="cart-line-color"> <span class="cart-line-color">
@@ -138,7 +140,7 @@
class="color-dot" class="color-dot"
[style.background-color]="cartItemColorHex(item)" [style.background-color]="cartItemColorHex(item)"
></span> ></span>
<span>{{ color }}</span> <span>{{ color | translate }}</span>
</span> </span>
} }
</div> </div>

View File

@@ -15,6 +15,10 @@ import {
ShopService, ShopService,
} from '../../features/shop/services/shop.service'; } from '../../features/shop/services/shop.service';
import { finalize } from 'rxjs'; import { finalize } from 'rxjs';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../constants/colors.const';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
@@ -143,15 +147,30 @@ export class NavbarComponent {
} }
cartItemVariant(item: ShopCartItem): string | null { cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null; return item.shopVariantLabel || this.cartItemColor(item);
} }
cartItemColor(item: ShopCartItem): string | null { 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 { cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6'; return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
} }
trackByCartItem(_index: number, item: ShopCartItem): string { trackByCartItem(_index: number, item: ShopCartItem): string {

View File

@@ -12,14 +12,31 @@ export interface PageSeoOverride {
ogDescription?: string | null; ogDescription?: string | null;
} }
type SupportedLang = 'it' | 'en' | 'de' | 'fr';
type SeoMap = Partial<Record<SupportedLang, string>>;
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class SeoService { export class SeoService {
private readonly defaultTitle = '3D fab | Stampa 3D su misura'; private readonly defaultTitleByLang: Record<SupportedLang, string> = {
private readonly defaultDescription = it: '3D fab | Stampa 3D su misura',
'Stampa 3D su misura con preventivo online immediato. Carica il file, scegli materiale e qualità, ricevi prezzo e tempi in pochi secondi.'; en: '3D fab | Custom 3D Printing',
private readonly supportedLangs = new Set(['it', 'en', 'de', 'fr']); de: '3D fab | 3D-Druck nach Maß',
fr: '3D fab | Impression 3D sur mesure',
};
private readonly defaultDescriptionByLang: Record<SupportedLang, string> = {
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<SupportedLang>([
'it',
'en',
'de',
'fr',
]);
constructor( constructor(
private router: Router, private router: Router,
@@ -40,9 +57,13 @@ export class SeoService {
} }
applyPageSeo(override: PageSeoOverride): void { 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 = const description =
this.asString(override.description) ?? this.defaultDescription; this.asString(override.description) ??
this.defaultDescriptionByLang[lang];
const robots = this.asString(override.robots) ?? 'index, follow'; const robots = this.asString(override.robots) ?? 'index, follow';
const ogTitle = this.asString(override.ogTitle) ?? title; const ogTitle = this.asString(override.ogTitle) ?? title;
const ogDescription = this.asString(override.ogDescription) ?? description; const ogDescription = this.asString(override.ogDescription) ?? description;
@@ -52,13 +73,18 @@ export class SeoService {
private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void { private applyRouteSeo(rootSnapshot: ActivatedRouteSnapshot): void {
const mergedData = this.getMergedRouteData(rootSnapshot); 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 = 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 robots = this.asString(mergedData['seoRobots']) ?? 'index, follow';
const ogTitle = this.asString(mergedData['ogTitle']) ?? title; const ogTitle = this.resolveSeoText(mergedData, 'ogTitle', lang) ?? title;
const ogDescription = const ogDescription =
this.asString(mergedData['ogDescription']) ?? description; this.resolveSeoText(mergedData, 'ogDescription', lang) ?? description;
this.applySeoValues(title, description, robots, ogTitle, ogDescription); this.applySeoValues(title, description, robots, ogTitle, ogDescription);
} }
@@ -104,11 +130,43 @@ export class SeoService {
return typeof value === 'string' ? value : undefined; return typeof value === 'string' ? value : undefined;
} }
private resolveSeoText(
routeData: Record<string, unknown>,
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 { private getCleanPath(url: string): string {
const path = (url || '/').split('?')[0].split('#')[0]; const path = (url || '/').split('?')[0].split('#')[0];
return path || '/'; 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 { private updateCanonicalTag(url: string): void {
let link = this.document.head.querySelector( let link = this.document.head.querySelector(
'link[rel="canonical"]', 'link[rel="canonical"]',
@@ -124,10 +182,9 @@ export class SeoService {
private updateLangAndAlternates(path: string): void { private updateLangAndAlternates(path: string): void {
const segments = path.split('/').filter(Boolean); const segments = path.split('/').filter(Boolean);
const firstSegment = segments[0]?.toLowerCase(); const firstSegment = segments[0]?.toLowerCase();
const hasLang = Boolean( const maybeLang = firstSegment as SupportedLang | undefined;
firstSegment && this.supportedLangs.has(firstSegment), const hasLang = Boolean(maybeLang && this.supportedLangs.has(maybeLang));
); const lang: SupportedLang = hasLang && maybeLang ? maybeLang : 'it';
const lang = hasLang ? firstSegment : 'it';
const suffixSegments = hasLang ? segments.slice(1) : segments; const suffixSegments = hasLang ? segments.slice(1) : segments;
const suffix = const suffix =
suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : ''; suffixSegments.length > 0 ? `/${suffixSegments.join('/')}` : '';

View File

@@ -101,6 +101,22 @@
placeholder="Nero, Bianco..." placeholder="Nero, Bianco..."
/> />
</label> </label>
<label class="form-field">
<span>Label IT</span>
<input type="text" [(ngModel)]="newVariant.colorLabelIt" />
</label>
<label class="form-field">
<span>Label EN</span>
<input type="text" [(ngModel)]="newVariant.colorLabelEn" />
</label>
<label class="form-field">
<span>Label DE</span>
<input type="text" [(ngModel)]="newVariant.colorLabelDe" />
</label>
<label class="form-field">
<span>Label FR</span>
<input type="text" [(ngModel)]="newVariant.colorLabelFr" />
</label>
<label class="form-field"> <label class="form-field">
<span>Hex colore</span> <span>Hex colore</span>
<input <input
@@ -229,7 +245,7 @@
class="color-dot" class="color-dot"
[style.background-color]="getVariantColorHex(variant)" [style.background-color]="getVariantColorHex(variant)"
></span> ></span>
{{ variant.colorName || "N/D" }} {{ variant.colorLabelIt || variant.colorName || "N/D" }}
</span> </span>
<span <span
>Stock spools: >Stock spools:
@@ -290,6 +306,22 @@
<span>Colore</span> <span>Colore</span>
<input type="text" [(ngModel)]="variant.colorName" /> <input type="text" [(ngModel)]="variant.colorName" />
</label> </label>
<label class="form-field">
<span>Label IT</span>
<input type="text" [(ngModel)]="variant.colorLabelIt" />
</label>
<label class="form-field">
<span>Label EN</span>
<input type="text" [(ngModel)]="variant.colorLabelEn" />
</label>
<label class="form-field">
<span>Label DE</span>
<input type="text" [(ngModel)]="variant.colorLabelDe" />
</label>
<label class="form-field">
<span>Label FR</span>
<input type="text" [(ngModel)]="variant.colorLabelFr" />
</label>
<label class="form-field"> <label class="form-field">
<span>Hex colore</span> <span>Hex colore</span>
<input type="text" [(ngModel)]="variant.colorHex" /> <input type="text" [(ngModel)]="variant.colorHex" />

View File

@@ -47,6 +47,10 @@ export class AdminFilamentStockComponent implements OnInit {
materialTypeId: 0, materialTypeId: 0,
variantDisplayName: '', variantDisplayName: '',
colorName: '', colorName: '',
colorLabelIt: '',
colorLabelEn: '',
colorLabelDe: '',
colorLabelFr: '',
colorHex: '', colorHex: '',
finishType: 'GLOSSY', finishType: 'GLOSSY',
brand: '', brand: '',
@@ -206,6 +210,10 @@ export class AdminFilamentStockComponent implements OnInit {
this.newVariant.materialTypeId || this.materials[0]?.id || 0, this.newVariant.materialTypeId || this.materials[0]?.id || 0,
variantDisplayName: '', variantDisplayName: '',
colorName: '', colorName: '',
colorLabelIt: '',
colorLabelEn: '',
colorLabelDe: '',
colorLabelFr: '',
colorHex: '', colorHex: '',
finishType: 'GLOSSY', finishType: 'GLOSSY',
brand: '', brand: '',
@@ -359,6 +367,10 @@ export class AdminFilamentStockComponent implements OnInit {
materialTypeId: Number(source.materialTypeId), materialTypeId: Number(source.materialTypeId),
variantDisplayName: (source.variantDisplayName || '').trim(), variantDisplayName: (source.variantDisplayName || '').trim(),
colorName: (source.colorName || '').trim(), colorName: (source.colorName || '').trim(),
colorLabelIt: (source.colorLabelIt || '').trim() || undefined,
colorLabelEn: (source.colorLabelEn || '').trim() || undefined,
colorLabelDe: (source.colorLabelDe || '').trim() || undefined,
colorLabelFr: (source.colorLabelFr || '').trim() || undefined,
colorHex: (source.colorHex || '').trim() || undefined, colorHex: (source.colorHex || '').trim() || undefined,
finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(), finishType: (source.finishType || 'GLOSSY').trim().toUpperCase(),
brand: (source.brand || '').trim() || undefined, brand: (source.brand || '').trim() || undefined,

View File

@@ -206,17 +206,6 @@
/> />
</label> </label>
<label class="ui-form-field">
<span class="ui-form-caption">Nome categoria</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.name"
name="categoryName"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">Slug</span> <span class="ui-form-caption">Slug</span>
<div class="input-with-action"> <div class="input-with-action">
@@ -237,36 +226,6 @@
</div> </div>
</label> </label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">Descrizione</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.description"
name="categoryDescription"
rows="3"
></textarea>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO title</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitle"
name="categorySeoTitle"
/>
</label>
<label class="ui-form-field">
<span class="ui-form-caption">SEO description</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoDescription"
name="categorySeoDescription"
/>
</label>
<label class="ui-form-field"> <label class="ui-form-field">
<span class="ui-form-caption">OG title</span> <span class="ui-form-caption">OG title</span>
<input <input
@@ -288,6 +247,141 @@
</label> </label>
</div> </div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua contenuti categoria</span>
<p>IT / EN / DE / FR</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategoryContentLanguageComplete(language)"
[class.incomplete]="
isCategoryContentLanguageIncomplete(language)
"
[class.empty]="!isCategoryContentLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategoryContentLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
Nome categoria {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.names[activeContentLanguage]"
[name]="'category-name-' + activeContentLanguage"
placeholder="Desk accessories"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
Descrizione {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control textarea-control"
[(ngModel)]="categoryForm.descriptions[activeContentLanguage]"
[name]="'category-description-' + activeContentLanguage"
rows="3"
></textarea>
</label>
</div>
<div class="ui-language-toolbar">
<div class="ui-language-toolbar__copy">
<span>Lingua SEO categoria</span>
<p>Stessa lingua attiva dell'editor</p>
</div>
<div class="ui-language-toolbar__toggle">
<button
*ngFor="let language of shopLanguages"
type="button"
class="ui-language-toolbar__button image-language-button"
[class.active]="activeContentLanguage === language"
[class.complete]="isCategorySeoLanguageComplete(language)"
[class.incomplete]="isCategorySeoLanguageIncomplete(language)"
[class.empty]="!isCategorySeoLanguageStarted(language)"
(click)="setActiveContentLanguage(language)"
>
<span class="image-language-button__label">
{{ languageLabels[language] }}
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageComplete(language)"
>
OK
</span>
<span
class="image-language-button__state"
*ngIf="isCategorySeoLanguageIncomplete(language)"
>
...
</span>
</button>
</div>
</div>
<div class="ui-form-grid ui-form-grid--two">
<label class="ui-form-field">
<span class="ui-form-caption">
SEO title {{ languageLabels[activeContentLanguage] }}
</span>
<input
class="ui-form-control"
type="text"
[(ngModel)]="categoryForm.seoTitles[activeContentLanguage]"
[name]="'category-seo-title-' + activeContentLanguage"
/>
</label>
<label class="ui-form-field form-field--wide">
<span class="ui-form-caption">
SEO description {{ languageLabels[activeContentLanguage] }}
</span>
<textarea
class="ui-form-control"
[(ngModel)]="
categoryForm.seoDescriptions[activeContentLanguage]
"
[name]="'category-seo-description-' + activeContentLanguage"
rows="3"
></textarea>
<span
class="seo-counter"
[class.seo-counter--danger]="
categorySeoDescriptionLength(activeContentLanguage) > 160
"
>
{{ categorySeoDescriptionLength(activeContentLanguage) }}/160
</span>
</label>
</div>
<div class="toggle-row"> <div class="toggle-row">
<label class="ui-checkbox"> <label class="ui-checkbox">
<input <input

View File

@@ -41,10 +41,10 @@ interface CategoryFormState {
id: string | null; id: string | null;
parentCategoryId: string | null; parentCategoryId: string | null;
slug: string; slug: string;
name: string; names: Record<ShopLanguage, string>;
description: string; descriptions: Record<ShopLanguage, string>;
seoTitle: string; seoTitles: Record<ShopLanguage, string>;
seoDescription: string; seoDescriptions: Record<ShopLanguage, string>;
ogTitle: string; ogTitle: string;
ogDescription: string; ogDescription: string;
indexable: boolean; indexable: boolean;
@@ -554,7 +554,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
slugifyCategoryFromName(): void { slugifyCategoryFromName(): void {
this.categoryForm.slug = this.slugify(this.categoryForm.name); const source =
this.categoryForm.names[this.activeContentLanguage] ||
this.categoryForm.names['it'];
this.categoryForm.slug = this.slugify(source);
} }
setActiveContentLanguage(language: ShopLanguage): void { setActiveContentLanguage(language: ShopLanguage): void {
@@ -603,6 +606,45 @@ export class AdminShopComponent implements OnInit, OnDestroy {
); );
} }
isCategoryContentLanguageComplete(language: ShopLanguage): boolean {
return !!this.categoryForm.names[language].trim();
}
isCategoryContentLanguageStarted(language: ShopLanguage): boolean {
return (
!!this.categoryForm.names[language].trim() ||
!!this.categoryForm.descriptions[language].trim()
);
}
isCategoryContentLanguageIncomplete(language: ShopLanguage): boolean {
return (
this.isCategoryContentLanguageStarted(language) &&
!this.isCategoryContentLanguageComplete(language)
);
}
isCategorySeoLanguageComplete(language: ShopLanguage): boolean {
return (
!!this.categoryForm.seoTitles[language].trim() &&
!!this.categoryForm.seoDescriptions[language].trim()
);
}
isCategorySeoLanguageStarted(language: ShopLanguage): boolean {
return (
!!this.categoryForm.seoTitles[language].trim() ||
!!this.categoryForm.seoDescriptions[language].trim()
);
}
isCategorySeoLanguageIncomplete(language: ShopLanguage): boolean {
return (
this.isCategorySeoLanguageStarted(language) &&
!this.isCategorySeoLanguageComplete(language)
);
}
preventRichTextToolbarMouseDown(event: MouseEvent): void { preventRichTextToolbarMouseDown(event: MouseEvent): void {
event.preventDefault(); event.preventDefault();
} }
@@ -1228,10 +1270,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
id: null, id: null,
parentCategoryId: null, parentCategoryId: null,
slug: '', slug: '',
name: '', names: this.createEmptyLocalizedTextRecord(),
description: '', descriptions: this.createEmptyLocalizedTextRecord(),
seoTitle: '', seoTitles: this.createEmptyLocalizedTextRecord(),
seoDescription: '', seoDescriptions: this.createEmptyLocalizedTextRecord(),
ogTitle: '', ogTitle: '',
ogDescription: '', ogDescription: '',
indexable: true, indexable: true,
@@ -1241,6 +1283,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
private resetCategoryForm(): void { private resetCategoryForm(): void {
this.activeContentLanguage = 'it';
Object.assign(this.categoryForm, this.createEmptyCategoryForm()); Object.assign(this.categoryForm, this.createEmptyCategoryForm());
} }
@@ -1249,10 +1292,30 @@ export class AdminShopComponent implements OnInit, OnDestroy {
id: category.id, id: category.id,
parentCategoryId: category.parentCategoryId, parentCategoryId: category.parentCategoryId,
slug: category.slug ?? '', slug: category.slug ?? '',
name: category.name ?? '', names: {
description: category.description ?? '', it: category.nameIt ?? category.name ?? '',
seoTitle: category.seoTitle ?? '', en: category.nameEn ?? category.name ?? '',
seoDescription: category.seoDescription ?? '', de: category.nameDe ?? category.name ?? '',
fr: category.nameFr ?? category.name ?? '',
},
descriptions: {
it: category.descriptionIt ?? category.description ?? '',
en: category.descriptionEn ?? category.description ?? '',
de: category.descriptionDe ?? category.description ?? '',
fr: category.descriptionFr ?? category.description ?? '',
},
seoTitles: {
it: category.seoTitleIt ?? category.seoTitle ?? '',
en: category.seoTitleEn ?? category.seoTitle ?? '',
de: category.seoTitleDe ?? category.seoTitle ?? '',
fr: category.seoTitleFr ?? category.seoTitle ?? '',
},
seoDescriptions: {
it: category.seoDescriptionIt ?? category.seoDescription ?? '',
en: category.seoDescriptionEn ?? category.seoDescription ?? '',
de: category.seoDescriptionDe ?? category.seoDescription ?? '',
fr: category.seoDescriptionFr ?? category.seoDescription ?? '',
},
ogTitle: category.ogTitle ?? '', ogTitle: category.ogTitle ?? '',
ogDescription: category.ogDescription ?? '', ogDescription: category.ogDescription ?? '',
indexable: category.indexable, indexable: category.indexable,
@@ -1265,10 +1328,36 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return { return {
parentCategoryId: this.categoryForm.parentCategoryId || null, parentCategoryId: this.categoryForm.parentCategoryId || null,
slug: this.categoryForm.slug.trim(), slug: this.categoryForm.slug.trim(),
name: this.categoryForm.name.trim(), name: this.categoryForm.names['it'].trim(),
description: this.categoryForm.description.trim(), nameIt: this.categoryForm.names['it'].trim(),
seoTitle: this.categoryForm.seoTitle.trim(), nameEn: this.categoryForm.names['en'].trim(),
seoDescription: this.categoryForm.seoDescription.trim(), nameDe: this.categoryForm.names['de'].trim(),
nameFr: this.categoryForm.names['fr'].trim(),
description: this.optionalValue(this.categoryForm.descriptions['it']),
descriptionIt: this.optionalValue(this.categoryForm.descriptions['it']),
descriptionEn: this.optionalValue(this.categoryForm.descriptions['en']),
descriptionDe: this.optionalValue(this.categoryForm.descriptions['de']),
descriptionFr: this.optionalValue(this.categoryForm.descriptions['fr']),
seoTitle: this.optionalValue(this.categoryForm.seoTitles['it']),
seoTitleIt: this.optionalValue(this.categoryForm.seoTitles['it']),
seoTitleEn: this.optionalValue(this.categoryForm.seoTitles['en']),
seoTitleDe: this.optionalValue(this.categoryForm.seoTitles['de']),
seoTitleFr: this.optionalValue(this.categoryForm.seoTitles['fr']),
seoDescription: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionIt: this.optionalValue(
this.categoryForm.seoDescriptions['it'],
),
seoDescriptionEn: this.optionalValue(
this.categoryForm.seoDescriptions['en'],
),
seoDescriptionDe: this.optionalValue(
this.categoryForm.seoDescriptions['de'],
),
seoDescriptionFr: this.optionalValue(
this.categoryForm.seoDescriptions['fr'],
),
ogTitle: this.categoryForm.ogTitle.trim(), ogTitle: this.categoryForm.ogTitle.trim(),
ogDescription: this.categoryForm.ogDescription.trim(), ogDescription: this.categoryForm.ogDescription.trim(),
indexable: this.categoryForm.indexable, indexable: this.categoryForm.indexable,
@@ -1278,12 +1367,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
private validateCategoryForm(): string | null { private validateCategoryForm(): string | null {
if (!this.categoryForm.name.trim()) { for (const language of this.shopLanguages) {
return 'Il nome categoria è obbligatorio.'; if (!this.categoryForm.names[language].trim()) {
return `Il nome categoria ${this.languageLabels[language]} è obbligatorio.`;
}
} }
if (!this.categoryForm.slug.trim()) { if (!this.categoryForm.slug.trim()) {
return 'Lo slug categoria è obbligatorio.'; return 'Lo slug categoria è obbligatorio.';
} }
for (const language of this.shopLanguages) {
if (this.categoryForm.seoDescriptions[language].trim().length > 160) {
return `La SEO description categoria ${this.languageLabels[language]} deve avere massimo 160 caratteri.`;
}
}
return null; return null;
} }
@@ -1616,6 +1712,10 @@ export class AdminShopComponent implements OnInit, OnDestroy {
sku: this.optionalValue(existingVariant?.sku ?? ''), sku: this.optionalValue(existingVariant?.sku ?? ''),
variantLabel: materialCode, variantLabel: materialCode,
colorName: stockVariant.colorName.trim(), colorName: stockVariant.colorName.trim(),
colorLabelIt: this.optionalValue(stockVariant.colorLabelIt ?? ''),
colorLabelEn: this.optionalValue(stockVariant.colorLabelEn ?? ''),
colorLabelDe: this.optionalValue(stockVariant.colorLabelDe ?? ''),
colorLabelFr: this.optionalValue(stockVariant.colorLabelFr ?? ''),
colorHex: this.optionalValue( colorHex: this.optionalValue(
stockVariant.colorHex ?? '', stockVariant.colorHex ?? '',
)?.toUpperCase(), )?.toUpperCase(),
@@ -1714,7 +1814,7 @@ export class AdminShopComponent implements OnInit, OnDestroy {
} }
private stockVariantLabel(variant: AdminFilamentVariant): string { private stockVariantLabel(variant: AdminFilamentVariant): string {
const colorName = variant.colorName.trim(); const colorName = (variant.colorLabelIt || variant.colorName).trim();
const variantDisplayName = variant.variantDisplayName.trim(); const variantDisplayName = variant.variantDisplayName.trim();
if ( if (
variantDisplayName && variantDisplayName &&
@@ -2193,6 +2293,19 @@ export class AdminShopComponent implements OnInit, OnDestroy {
return this.productForm.seoDescriptions[language].trim().length; return this.productForm.seoDescriptions[language].trim().length;
} }
categorySeoDescriptionLength(language: ShopLanguage): number {
return this.categoryForm.seoDescriptions[language].trim().length;
}
private createEmptyLocalizedTextRecord(): Record<ShopLanguage, string> {
return {
it: '',
en: '',
de: '',
fr: '',
};
}
private slugify(source: string): string { private slugify(source: string): string {
return source return source
.normalize('NFD') .normalize('NFD')

View File

@@ -32,6 +32,10 @@ export interface AdminFilamentVariant {
materialTechnicalTypeLabel?: string; materialTechnicalTypeLabel?: string;
variantDisplayName: string; variantDisplayName: string;
colorName: string; colorName: string;
colorLabelIt: string;
colorLabelEn: string;
colorLabelDe: string;
colorLabelFr: string;
colorHex?: string; colorHex?: string;
finishType?: string; finishType?: string;
brand?: string; brand?: string;
@@ -57,6 +61,10 @@ export interface AdminUpsertFilamentVariantPayload {
materialTypeId: number; materialTypeId: number;
variantDisplayName: string; variantDisplayName: string;
colorName: string; colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
colorHex?: string; colorHex?: string;
finishType?: string; finishType?: string;
brand?: string; brand?: string;

View File

@@ -30,9 +30,25 @@ export interface AdminShopCategory {
parentCategoryName: string | null; parentCategoryName: string | null;
slug: string; slug: string;
name: string; name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description: string | null; description: string | null;
descriptionIt: string | null;
descriptionEn: string | null;
descriptionDe: string | null;
descriptionFr: string | null;
seoTitle: string | null; seoTitle: string | null;
seoTitleIt: string | null;
seoTitleEn: string | null;
seoTitleDe: string | null;
seoTitleFr: string | null;
seoDescription: string | null; seoDescription: string | null;
seoDescriptionIt: string | null;
seoDescriptionEn: string | null;
seoDescriptionDe: string | null;
seoDescriptionFr: string | null;
ogTitle: string | null; ogTitle: string | null;
ogDescription: string | null; ogDescription: string | null;
indexable: boolean; indexable: boolean;
@@ -54,9 +70,25 @@ export interface AdminUpsertShopCategoryPayload {
parentCategoryId?: string | null; parentCategoryId?: string | null;
slug: string; slug: string;
name: string; name: string;
nameIt: string;
nameEn: string;
nameDe: string;
nameFr: string;
description?: string; description?: string;
descriptionIt?: string;
descriptionEn?: string;
descriptionDe?: string;
descriptionFr?: string;
seoTitle?: string; seoTitle?: string;
seoTitleIt?: string;
seoTitleEn?: string;
seoTitleDe?: string;
seoTitleFr?: string;
seoDescription?: string; seoDescription?: string;
seoDescriptionIt?: string;
seoDescriptionEn?: string;
seoDescriptionDe?: string;
seoDescriptionFr?: string;
ogTitle?: string; ogTitle?: string;
ogDescription?: string; ogDescription?: string;
indexable: boolean; indexable: boolean;
@@ -69,6 +101,10 @@ export interface AdminShopProductVariant {
sku: string | null; sku: string | null;
variantLabel: string; variantLabel: string;
colorName: string; colorName: string;
colorLabelIt: string;
colorLabelEn: string;
colorLabelDe: string;
colorLabelFr: string;
colorHex: string | null; colorHex: string | null;
internalMaterialCode: string; internalMaterialCode: string;
priceChf: number; priceChf: number;
@@ -170,6 +206,10 @@ export interface AdminUpsertShopProductVariantPayload {
sku?: string; sku?: string;
variantLabel?: string; variantLabel?: string;
colorName: string; colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
colorHex?: string; colorHex?: string;
internalMaterialCode: string; internalMaterialCode: string;
priceChf: number; priceChf: number;

View File

@@ -75,6 +75,10 @@ export interface VariantOption {
id: number; id: number;
name: string; name: string;
colorName: string; colorName: string;
colorLabelIt?: string;
colorLabelEn?: string;
colorLabelDe?: string;
colorLabelFr?: string;
hexColor: string; hexColor: string;
finishType: string; finishType: string;
stockSpools: number; stockSpools: number;

View File

@@ -1,5 +1,5 @@
<div class="checkout-page"> <div class="checkout-page">
<div class="container ui-page-hero"> <div class="container ui-page-hero ui-page-hero--spacious checkout-hero">
<h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1> <h1 class="ui-page-title">{{ "CHECKOUT.TITLE" | translate }}</h1>
<p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()"> <p class="ui-page-subtitle cad-subtitle" *ngIf="isCadSession()">
Servizio CAD Servizio CAD
@@ -265,14 +265,16 @@
</span> </span>
<span *ngIf="itemVariantLabel(item) as variantLabel"> <span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ "SHOP.VARIANT" | translate }}:
{{ variantLabel }} {{ variantLabel | translate }}
</span> </span>
<span class="item-color" *ngIf="itemColorLabel(item) !== '-'"> <span class="item-color" *ngIf="itemColorLabel(item) !== '-'">
<span <span
class="color-dot" class="color-dot"
[style.background-color]="itemColorSwatch(item)" [style.background-color]="itemColorSwatch(item)"
></span> ></span>
<span class="color-name">{{ itemColorLabel(item) }}</span> <span class="color-name">{{
itemColorLabel(item) | translate
}}</span>
</span> </span>
</div> </div>
<div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)"> <div class="item-specs-sub" *ngIf="showItemPrintMetrics(item)">

View File

@@ -1,3 +1,7 @@
.checkout-hero {
padding-top: calc(var(--space-12) + var(--space-4));
}
.cad-subtitle { .cad-subtitle {
margin: 0; margin: 0;
} }
@@ -273,3 +277,9 @@ app-toggle-selector.user-type-selector-compact {
.mb-6 { .mb-6 {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
@media (max-width: 640px) {
.checkout-hero {
padding-top: calc(var(--space-8) + var(--space-4));
}
}

View File

@@ -22,7 +22,12 @@ import {
} from '../../shared/components/price-breakdown/price-breakdown.component'; } from '../../shared/components/price-breakdown/price-breakdown.component';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
import { getColorHex } from '../../core/constants/colors.const'; import {
findColorHex,
getColorHex,
normalizeColorValue,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
@Component({ @Component({
selector: 'app-checkout', selector: 'app-checkout',
@@ -252,8 +257,7 @@ export class CheckoutComponent implements OnInit {
if (variantLabel) { if (variantLabel) {
return variantLabel; return variantLabel;
} }
const colorName = String(item?.shopVariantColorName ?? '').trim(); return this.localizedShopColorLabel(item);
return colorName || null;
} }
showItemMaterial(item: any): boolean { showItemMaterial(item: any): boolean {
@@ -282,12 +286,7 @@ export class CheckoutComponent implements OnInit {
} }
itemColorLabel(item: any): string { itemColorLabel(item: any): string {
const shopColor = String(item?.shopVariantColorName ?? '').trim(); return this.localizedShopColorLabel(item) || String(item?.colorCode ?? '-');
if (shopColor) {
return shopColor;
}
const raw = String(item?.colorCode ?? '').trim();
return raw || '-';
} }
itemColorSwatch(item: any): string { itemColorSwatch(item: any): string {
@@ -310,12 +309,12 @@ export class CheckoutComponent implements OnInit {
return raw; return raw;
} }
const byName = this.variantHexByColorName.get(raw.toLowerCase()); const byName = this.variantHexByColorName.get(normalizeColorValue(raw));
if (byName) { if (byName) {
return byName; return byName;
} }
const fallback = getColorHex(raw); const fallback = findColorHex(raw) ?? getColorHex(raw);
if (fallback && fallback !== '#facf0a') { if (fallback && fallback !== '#facf0a') {
return fallback; return fallback;
} }
@@ -331,6 +330,16 @@ export class CheckoutComponent implements OnInit {
return !!this.previewLoading()[id]; return !!this.previewLoading()[id];
} }
private localizedShopColorLabel(item: any): string | null {
return resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: item?.shopVariantColorName ?? item?.colorCode,
it: item?.shopVariantColorLabelIt,
en: item?.shopVariantColorLabelEn,
de: item?.shopVariantColorLabelDe,
fr: item?.shopVariantColorLabelFr,
});
}
hasPreviewError(item: any): boolean { hasPreviewError(item: any): boolean {
const id = String(item?.id ?? ''); const id = String(item?.id ?? '');
if (!id) { if (!id) {
@@ -373,7 +382,10 @@ export class CheckoutComponent implements OnInit {
this.variantHexById.set(variantId, colorHex); this.variantHexById.set(variantId, colorHex);
} }
if (colorName && colorHex) { if (colorName && colorHex) {
this.variantHexByColorName.set(colorName.toLowerCase(), colorHex); this.variantHexByColorName.set(
normalizeColorValue(colorName),
colorHex,
);
} }
} }
} }

View File

@@ -1,6 +1,8 @@
<main class="home-page"> <main class="home-page">
<section class="hero"> <section class="hero">
<div class="container hero-grid ui-content-grid ui-content-grid--spacious"> <div
class="container hero-grid ui-content-grid ui-content-grid--spacious ui-content-grid--split"
>
<div class="hero-copy"> <div class="hero-copy">
<p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p> <p class="eyebrow ui-eyebrow">{{ "HOME.HERO_EYEBROW" | translate }}</p>
<h1 <h1
@@ -25,6 +27,30 @@
}}</app-button> }}</app-button>
</div> </div>
</div> </div>
<aside class="hero-swiss-card">
<div class="hero-swiss-head">
<span class="hero-swiss-emblem" aria-hidden="true">
<span class="hero-swiss-cross"></span>
</span>
<p class="hero-swiss-kicker ui-eyebrow ui-eyebrow--compact">
{{ "HOME.HERO_SWISS_TITLE" | translate }}
</p>
</div>
<p class="hero-swiss-copy">
{{ "HOME.HERO_SWISS_COPY" | translate }}
</p>
<div class="hero-swiss-locations">
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_1" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_2" | translate
}}</span>
<span class="hero-swiss-chip">{{
"HOME.HERO_SWISS_LOCATION_3" | translate
}}</span>
</div>
</aside>
</div> </div>
</section> </section>

View File

@@ -45,6 +45,99 @@
animation: fadeUp 0.8s ease both; animation: fadeUp 0.8s ease both;
} }
.hero-grid {
align-items: start;
}
.hero-swiss-card {
--swiss-red: #d52b1e;
align-self: center;
justify-self: center;
width: min(100%, 340px);
padding: 1rem 1.1rem;
border: 1px solid var(--color-border);
border-left: 4px solid var(--swiss-red);
border-radius: 12px;
background: #fff;
animation: fadeUp 0.85s ease both;
}
.hero-swiss-head {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
}
.hero-swiss-emblem {
width: 1.3rem;
height: 1.3rem;
border-radius: 4px;
background: var(--swiss-red);
display: inline-grid;
place-items: center;
}
.hero-swiss-cross {
position: relative;
width: 0.86rem;
height: 0.86rem;
display: block;
}
.hero-swiss-cross::before,
.hero-swiss-cross::after {
content: "";
position: absolute;
background: #fff;
border-radius: 1px;
}
.hero-swiss-cross::before {
width: 0.28rem;
height: 100%;
left: calc(50% - 0.14rem);
top: 0;
}
.hero-swiss-cross::after {
width: 100%;
height: 0.28rem;
left: 0;
top: calc(50% - 0.14rem);
}
.hero-swiss-kicker {
margin: 0;
color: var(--color-text);
}
.hero-swiss-copy {
margin: 0 0 0.7rem;
color: var(--color-text);
font-weight: 500;
line-height: 1.4;
}
.hero-swiss-locations {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.hero-swiss-chip {
display: inline-flex;
align-items: center;
min-height: 1.75rem;
padding: 0.2rem 0.58rem;
border-radius: 999px;
border: 1px solid rgba(14, 24, 38, 0.14);
background: #fff;
font-size: 0.84rem;
font-weight: 600;
color: #2a2f36;
}
.capabilities { .capabilities {
position: relative; position: relative;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@@ -165,6 +258,13 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.hero-swiss-card {
align-self: start;
justify-self: center;
width: min(100%, 340px);
margin-top: 1rem;
}
.shop-gallery { .shop-gallery {
width: 100%; width: 100%;
max-width: none; max-width: none;

View File

@@ -68,9 +68,12 @@
</div> </div>
</app-card> </app-card>
<div class="payment-layout ui-two-column-layout"> <div
<div class="payment-main"> class="payment-layout ui-two-column-layout"
<app-card class="mb-6" *ngIf="o.status === 'PENDING_PAYMENT'"> [class.payment-layout--summary-only]="o.status !== 'PENDING_PAYMENT'"
>
<div class="payment-main" *ngIf="o.status === 'PENDING_PAYMENT'">
<app-card class="mb-6">
<div class="ui-card-header"> <div class="ui-card-header">
<h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3> <h3 class="ui-card-title">{{ "PAYMENT.METHOD" | translate }}</h3>
</div> </div>
@@ -174,69 +177,6 @@
</app-button> </app-button>
</div> </div>
</app-card> </app-card>
<app-card class="mb-6">
<div class="ui-card-header">
<h3 class="ui-card-title">{{ "ORDER.ITEMS_TITLE" | translate }}</h3>
<p class="ui-card-subtitle">
{{ orderKindLabel(o) }}
</p>
</div>
<div class="order-items">
<div class="order-item" *ngFor="let item of o.items || []">
<div class="order-item-copy">
<div class="order-item-name-row">
<strong class="order-item-name">{{
itemDisplayName(item)
}}</strong>
<span
class="order-item-kind"
[class.order-item-kind-shop]="isShopItem(item)"
>
{{
isShopItem(item)
? ("ORDER.TYPE_SHOP" | translate)
: ("ORDER.TYPE_CALCULATOR" | translate)
}}
</span>
</div>
<div class="order-item-meta">
<span
>{{ "CHECKOUT.QTY" | translate }}: {{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
}}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}: {{ variantLabel }}
</span>
<span class="item-color-chip">
<span
class="color-swatch"
*ngIf="itemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ itemColorLabel(item) }}</span>
</span>
</div>
<div class="order-item-tech" *ngIf="showItemPrintMetrics(item)">
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
{{ item.materialGrams || 0 | number: "1.0-0" }}g
</div>
</div>
<strong class="order-item-total">
{{ item.lineTotalChf || 0 | currency: "CHF" }}
</strong>
</div>
</div>
</app-card>
</div> </div>
<div class="payment-summary"> <div class="payment-summary">
@@ -271,6 +211,72 @@
[currency]="'CHF'" [currency]="'CHF'"
[totalLabelKey]="'PAYMENT.TOTAL'" [totalLabelKey]="'PAYMENT.TOTAL'"
></app-price-breakdown> ></app-price-breakdown>
<div class="summary-items-section" *ngIf="(o.items || []).length > 0">
<div class="summary-items-head">
<h4>{{ "ORDER.ITEMS_TITLE" | translate }}</h4>
<span>{{ (o.items || []).length }}</span>
</div>
<div class="order-items">
<div class="order-item" *ngFor="let item of o.items || []">
<div class="order-item-copy">
<div class="order-item-name-row">
<strong class="order-item-name">{{
itemDisplayName(item)
}}</strong>
<span
class="order-item-kind"
[class.order-item-kind-shop]="isShopItem(item)"
>
{{
isShopItem(item)
? ("ORDER.TYPE_SHOP" | translate)
: ("ORDER.TYPE_CALCULATOR" | translate)
}}
</span>
</div>
<div class="order-item-meta">
<span
>{{ "CHECKOUT.QTY" | translate }}:
{{ item.quantity }}</span
>
<span *ngIf="showItemMaterial(item)">
{{ "CHECKOUT.MATERIAL" | translate }}:
{{
item.materialCode || ("ORDER.NOT_AVAILABLE" | translate)
}}
</span>
<span *ngIf="itemVariantLabel(item) as variantLabel">
{{ "SHOP.VARIANT" | translate }}:
{{ variantLabel | translate }}
</span>
<span class="item-color-chip">
<span
class="color-swatch"
*ngIf="itemColorHex(item) as colorHex"
[style.background-color]="colorHex"
></span>
<span>{{ itemColorLabel(item) | translate }}</span>
</span>
</div>
<div
class="order-item-tech"
*ngIf="showItemPrintMetrics(item)"
>
{{ item.printTimeSeconds || 0 | number: "1.0-0" }}s |
{{ item.materialGrams || 0 | number: "1.0-0" }}g
</div>
</div>
<strong class="order-item-total">
{{ item.lineTotalChf || 0 | currency: "CHF" }}
</strong>
</div>
</div>
</div>
</app-card> </app-card>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,11 @@
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
} }
.payment-layout--summary-only {
grid-template-columns: minmax(0, 440px);
justify-content: center;
}
.payment-details { .payment-details {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
@@ -119,9 +124,52 @@
top: var(--space-6); top: var(--space-6);
} }
.payment-summary {
display: grid;
gap: var(--space-6);
}
.summary-items-section {
margin-top: var(--space-6);
padding-top: var(--space-5);
border-top: 1px solid var(--color-border);
}
.summary-items-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-4);
h4 {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
span {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0 0.45rem;
border-radius: 999px;
background: rgba(16, 24, 32, 0.06);
color: var(--color-text);
font-size: 0.82rem;
font-weight: 700;
}
}
.order-items { .order-items {
display: grid; display: grid;
gap: var(--space-3); gap: var(--space-2);
max-height: 420px;
overflow-y: auto;
padding-right: var(--space-1);
scrollbar-width: thin;
} }
.order-item { .order-item {
@@ -129,7 +177,7 @@
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: var(--space-3); gap: var(--space-3);
padding: var(--space-3); padding: 0.85rem 0.9rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-md); border-radius: var(--radius-md);
background: var(--color-bg-card); background: var(--color-bg-card);
@@ -149,7 +197,7 @@
} }
.order-item-name { .order-item-name {
font-size: 1rem; font-size: 0.96rem;
line-height: 1.35; line-height: 1.35;
} }
@@ -176,7 +224,7 @@
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem 0.9rem; gap: 0.5rem 0.9rem;
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.92rem; font-size: 0.88rem;
} }
.item-color-chip { .item-color-chip {
@@ -194,13 +242,13 @@
} }
.order-item-tech { .order-item-tech {
font-size: 0.86rem; font-size: 0.82rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.order-item-total { .order-item-total {
white-space: nowrap; white-space: nowrap;
font-size: 1rem; font-size: 0.96rem;
} }
.order-summary-meta { .order-summary-meta {
@@ -325,6 +373,10 @@
padding-top: calc(var(--space-8) + var(--space-4)); padding-top: calc(var(--space-8) + var(--space-4));
} }
.payment-layout--summary-only {
grid-template-columns: 1fr;
}
.status-timeline { .status-timeline {
margin-top: var(--space-4); margin-top: var(--space-4);
margin-bottom: var(--space-8); margin-bottom: var(--space-8);
@@ -362,4 +414,10 @@
.order-summary-meta { .order-summary-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.order-items {
max-height: none;
overflow: visible;
padding-right: 0;
}
} }

View File

@@ -6,6 +6,10 @@ import { AppCardComponent } from '../../shared/components/app-card/app-card.comp
import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service'; import { QuoteEstimatorService } from '../calculator/services/quote-estimator.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
import { import {
PriceBreakdownComponent, PriceBreakdownComponent,
PriceBreakdownRow, PriceBreakdownRow,
@@ -25,9 +29,17 @@ interface PublicOrderItem {
shopProductName?: string; shopProductName?: string;
shopVariantLabel?: string; shopVariantLabel?: string;
shopVariantColorName?: string; shopVariantColorName?: string;
shopVariantColorLabelIt?: string;
shopVariantColorLabelEn?: string;
shopVariantColorLabelDe?: string;
shopVariantColorLabelFr?: string;
shopVariantColorHex?: string; shopVariantColorHex?: string;
filamentVariantDisplayName?: string; filamentVariantDisplayName?: string;
filamentColorName?: string; filamentColorName?: string;
filamentColorLabelIt?: string;
filamentColorLabelEn?: string;
filamentColorLabelDe?: string;
filamentColorLabelFr?: string;
filamentColorHex?: string; filamentColorHex?: string;
quality?: string; quality?: string;
nozzleDiameterMm?: number; nozzleDiameterMm?: number;
@@ -278,23 +290,16 @@ export class OrderComponent implements OnInit {
return variantLabel; return variantLabel;
} }
const colorName = String(item?.shopVariantColorName ?? '').trim(); return this.localizedColorLabel(item, 'shop');
return colorName || null;
} }
itemColorLabel(item: PublicOrderItem): string { itemColorLabel(item: PublicOrderItem): string {
const shopColor = String(item?.shopVariantColorName ?? '').trim(); return (
if (shopColor) { this.localizedColorLabel(item, 'shop') ||
return shopColor; this.localizedColorLabel(item, 'filament') ||
} String(item?.colorCode ?? '').trim() ||
this.translate.instant('ORDER.NOT_AVAILABLE')
const filamentColor = String(item?.filamentColorName ?? '').trim(); );
if (filamentColor) {
return filamentColor;
}
const rawColor = String(item?.colorCode ?? '').trim();
return rawColor || this.translate.instant('ORDER.NOT_AVAILABLE');
} }
itemColorHex(item: PublicOrderItem): string | null { itemColorHex(item: PublicOrderItem): string | null {
@@ -313,7 +318,7 @@ export class OrderComponent implements OnInit {
return rawColor; return rawColor;
} }
return null; return findColorHex(rawColor);
} }
showItemMaterial(item: PublicOrderItem): boolean { showItemMaterial(item: PublicOrderItem): boolean {
@@ -324,6 +329,29 @@ export class OrderComponent implements OnInit {
return !this.isShopItem(item); return !this.isShopItem(item);
} }
private localizedColorLabel(
item: PublicOrderItem,
source: 'shop' | 'filament',
): string | null {
if (source === 'shop') {
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.shopVariantColorName,
it: item.shopVariantColorLabelIt,
en: item.shopVariantColorLabelEn,
de: item.shopVariantColorLabelDe,
fr: item.shopVariantColorLabelFr,
});
}
return resolveLocalizedColorLabel(this.translate.currentLang, {
fallback: item.filamentColorName ?? item.colorCode,
it: item.filamentColorLabelIt,
en: item.filamentColorLabelEn,
de: item.filamentColorLabelDe,
fr: item.filamentColorLabelFr,
});
}
orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' { orderKind(order: PublicOrder | null): 'SHOP' | 'CALCULATOR' | 'MIXED' {
const items = order?.items ?? []; const items = order?.items ?? [];
const hasShop = items.some((item) => this.isShopItem(item)); const hasShop = items.some((item) => this.isShopItem(item));

View File

@@ -21,7 +21,7 @@
.media { .media {
position: relative; position: relative;
display: block; display: block;
aspect-ratio: 1 / 1; aspect-ratio: 4 / 3;
background: #f2eee5; background: #f2eee5;
overflow: hidden; overflow: hidden;
} }

View File

@@ -15,12 +15,16 @@
} @else { } @else {
@if (product(); as p) { @if (product(); as p) {
<nav class="breadcrumbs"> <nav class="breadcrumbs">
<a [routerLink]="shopRootLink()">{{ <a class="breadcrumbs__item" [routerLink]="shopRootLink()">{{
"SHOP.BREADCRUMB_ROOT" | translate "SHOP.BREADCRUMB_ROOT" | translate
}}</a> }}</a>
@for (crumb of p.breadcrumbs; track crumb.id) { @for (crumb of p.breadcrumbs; track crumb.id) {
<span>/</span> <span class="breadcrumbs__separator">/</span>
<a [routerLink]="categoryLink(crumb.slug)">{{ crumb.name }}</a> <a
class="breadcrumbs__item"
[routerLink]="categoryLink(crumb.slug)"
>{{ crumb.name }}</a
>
} }
</nav> </nav>
@@ -129,13 +133,32 @@
<app-card class="purchase-shell"> <app-card class="purchase-shell">
<div class="purchase-card"> <div class="purchase-card">
<div class="price-row"> <div class="offer-header">
<div> <div class="offer-price">
<p class="panel-kicker"> <p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }} {{ "SHOP.PRICE_LABEL" | translate }}
</p> </p>
<h3>{{ priceLabel() | currency: "CHF" }}</h3> <h3>{{ priceLabel() | currency: "CHF" }}</h3>
@if (selectedVariant(); as activeVariant) {
<p class="offer-caption">
@if (selectedMaterial()?.label) {
<span>{{ selectedMaterial()?.label }}</span>
}
@if (
colorLabel(activeVariant) !==
selectedMaterial()?.label
) {
@if (selectedMaterial()?.label) {
<span aria-hidden="true">·</span>
}
<span>{{
colorLabel(activeVariant) | translate
}}</span>
}
</p>
}
</div> </div>
@if (selectedVariantCartQuantity() > 0) { @if (selectedVariantCartQuantity() > 0) {
<span class="cart-pill"> <span class="cart-pill">
{{ {{
@@ -148,8 +171,18 @@
</div> </div>
@if (materialOptions().length > 1) { @if (materialOptions().length > 1) {
<div class="material-section">
<div class="selector-head">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
</p>
</div>
<div class="material-grid"> <div class="material-grid">
@for (material of materialOptions(); track material.key) { @for (
material of materialOptions();
track material.key
) {
<button <button
type="button" type="button"
class="material-option" class="material-option"
@@ -174,6 +207,25 @@
</button> </button>
} }
</div> </div>
</div>
} @else {
@if (selectedMaterial(); as material) {
<div class="material-summary">
<div class="material-summary__copy">
<p class="panel-kicker">
{{ "SHOP.SELECT_MATERIAL" | translate }}
</p>
<strong>{{ material.label }}</strong>
<small>
{{
"SHOP.MATERIAL_COLOR_COUNT"
| translate
: { count: materialColorCount(material) }
}}
</small>
</div>
</div>
}
} }
@if ( @if (
@@ -196,7 +248,8 @@
</div> </div>
} }
<div class="color-selector-block"> <div class="selector-layout">
<div class="selector-card color-selector-block">
<div class="selector-head"> <div class="selector-head">
<p class="panel-kicker"> <p class="panel-kicker">
{{ "SHOP.SELECT_COLOR" | translate }} {{ "SHOP.SELECT_COLOR" | translate }}
@@ -218,7 +271,9 @@
</span> </span>
<span class="color-trigger__copy"> <span class="color-trigger__copy">
<strong>{{ colorLabel(activeVariant) }}</strong> <strong>{{
colorLabel(activeVariant) | translate
}}</strong>
<small>{{ selectedMaterial()?.label }}</small> <small>{{ selectedMaterial()?.label }}</small>
</span> </span>
</button> </button>
@@ -255,7 +310,7 @@
</span> </span>
<span class="color-popup__name">{{ <span class="color-popup__name">{{
colorLabel(variant) colorLabel(variant) | translate
}}</span> }}</span>
</button> </button>
} }
@@ -264,8 +319,10 @@
} }
</div> </div>
<div class="quantity-row"> <div class="selector-card quantity-card">
<span>{{ "SHOP.QUANTITY" | translate }}</span> <p class="panel-kicker">
{{ "SHOP.QUANTITY" | translate }}
</p>
<div class="qty-control"> <div class="qty-control">
<button type="button" (click)="decreaseQuantity()"> <button type="button" (click)="decreaseQuantity()">
- -
@@ -276,10 +333,12 @@
</button> </button>
</div> </div>
</div> </div>
</div>
<div class="actions"> <div class="actions">
<app-button <app-button
variant="primary" variant="primary"
[fullWidth]="true"
[disabled]="isAddingToCart()" [disabled]="isAddingToCart()"
(click)="addToCart()" (click)="addToCart()"
> >
@@ -290,7 +349,11 @@
</app-button> </app-button>
@if (shopService.cartItemCount() > 0) { @if (shopService.cartItemCount() > 0) {
<app-button variant="outline" (click)="goToCheckout()"> <app-button
variant="outline"
[fullWidth]="true"
(click)="goToCheckout()"
>
{{ "SHOP.GO_TO_CHECKOUT" | translate }} {{ "SHOP.GO_TO_CHECKOUT" | translate }}
</app-button> </app-button>
} }

View File

@@ -18,15 +18,51 @@
border: 0; border: 0;
background: none; background: none;
font: inherit; font: inherit;
font-size: 1rem;
font-weight: 600;
line-height: 1.3;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
} }
.back-link:hover {
color: var(--color-text);
}
.breadcrumbs { .breadcrumbs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.45rem; align-items: center;
font-size: 0.9rem; gap: 0.35rem;
font-size: 0.82rem;
}
.breadcrumbs__item {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.26rem 0.7rem;
border-radius: 999px;
border: 1px solid rgba(16, 24, 32, 0.1);
background: rgba(255, 255, 255, 0.92);
color: var(--color-secondary-600);
font-weight: 600;
transition:
border-color 0.18s ease,
background 0.18s ease,
color 0.18s ease;
}
.breadcrumbs__item:hover {
color: var(--color-text);
border-color: rgba(16, 24, 32, 0.18);
background: #fff;
text-decoration: none;
}
.breadcrumbs__separator {
color: rgba(81, 77, 67, 0.64);
font-weight: 700;
} }
.detail-grid { .detail-grid {
@@ -53,9 +89,8 @@
.hero-media { .hero-media {
position: relative; position: relative;
aspect-ratio: 1 / 1; width: 100%;
min-height: 420px; aspect-ratio: 4 / 3;
max-height: 620px;
overflow: hidden; overflow: hidden;
border-radius: 1.25rem; border-radius: 1.25rem;
border: 1px solid rgba(16, 24, 32, 0.12); border: 1px solid rgba(16, 24, 32, 0.12);
@@ -67,14 +102,14 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
object-fit: contain; object-fit: cover;
object-position: center;
background: #f2eee5; background: #f2eee5;
} }
.image-fallback { .image-fallback {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 420px;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
padding: var(--space-6); padding: var(--space-6);
@@ -111,8 +146,8 @@
} }
.thumb { .thumb {
flex: 0 0 92px; flex: 0 0 96px;
height: 92px; aspect-ratio: 4 / 3;
overflow: hidden; overflow: hidden;
border-radius: 0.85rem; border-radius: 0.85rem;
border: 1px solid rgba(16, 24, 32, 0.12); border: 1px solid rgba(16, 24, 32, 0.12);
@@ -226,15 +261,34 @@ h1 {
.purchase-card { .purchase-card {
display: grid; display: grid;
gap: 0.78rem; gap: 1rem;
} }
.price-row, .offer-header {
.quantity-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
align-items: start;
}
.offer-price {
display: grid;
gap: 0.12rem;
}
.offer-price h3 {
font-size: clamp(1.9rem, 1.5vw + 1.15rem, 2.5rem);
line-height: 1;
}
.offer-caption {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 0.38rem;
color: var(--color-text-muted);
font-size: 0.9rem;
} }
.cart-pill { .cart-pill {
@@ -249,6 +303,11 @@ h1 {
font-weight: 600; font-weight: 600;
} }
.material-section {
display: grid;
gap: 0.65rem;
}
.material-grid { .material-grid {
display: grid; display: grid;
gap: 0.55rem; gap: 0.55rem;
@@ -301,6 +360,31 @@ h1 {
font-size: 1.04rem; font-size: 1.04rem;
} }
.material-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: 0.9rem 1rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: #fff;
}
.material-summary__copy {
display: grid;
gap: 0.16rem;
}
.material-summary__copy strong {
font-size: 1rem;
}
.material-summary__copy small {
color: var(--color-text-muted);
font-size: 0.84rem;
}
.property-grid { .property-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -340,11 +424,30 @@ h1 {
border-left: 3px solid rgba(245, 158, 11, 0.7); border-left: 3px solid rgba(245, 158, 11, 0.7);
} }
.selector-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.8fr);
gap: 0.75rem;
align-items: stretch;
}
.selector-card {
position: relative;
display: grid;
gap: 0.5rem;
padding: 0.82rem 0.9rem;
border-radius: 1rem;
border: 1px solid rgba(16, 24, 32, 0.12);
background: rgba(255, 255, 255, 0.9);
height: 100%;
}
.qty-control { .qty-control {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.45rem; gap: 0.45rem;
padding: 0.2rem; padding: 0.2rem;
min-height: 3.2rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.82);
@@ -366,10 +469,20 @@ h1 {
font-weight: 700; font-weight: 700;
} }
.quantity-card {
justify-items: start;
align-content: start;
grid-template-rows: auto 1fr;
}
.quantity-card .qty-control {
align-self: center;
}
.actions { .actions {
display: flex; display: grid;
flex-wrap: wrap; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-3); gap: 0.75rem;
} }
.success-note { .success-note {
@@ -459,6 +572,10 @@ h1 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.selector-layout {
grid-template-columns: 1fr;
}
.property-grid { .property-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
@@ -469,15 +586,14 @@ h1 {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.price-row, .offer-header,
.quantity-row { .material-summary {
flex-direction: column; flex-direction: column;
align-items: start; align-items: start;
} }
.hero-media, .hero-media--portrait .hero-image {
.image-fallback { object-fit: cover;
min-height: 300px;
} }
.thumb-strip { .thumb-strip {
@@ -485,8 +601,7 @@ h1 {
} }
.thumb { .thumb {
flex-basis: 78px; flex-basis: 84px;
height: 78px;
} }
.model-launch-row { .model-launch-row {
@@ -514,6 +629,10 @@ h1 {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.selector-card {
padding: 0.74rem 0.78rem;
}
:host ::ng-deep app-card.purchase-shell .card-body { :host ::ng-deep app-card.purchase-shell .card-body {
padding: 0.82rem 0.82rem; padding: 0.82rem 0.82rem;
} }

View File

@@ -15,7 +15,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs'; import { catchError, combineLatest, finalize, of, switchMap, tap } from 'rxjs';
import { SeoService } from '../../core/services/seo.service'; import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import { getColorHex } from '../../core/constants/colors.const'; import { findColorHex, getColorHex } from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component'; import { StlViewerComponent } from '../../shared/components/stl-viewer/stl-viewer.component';
@@ -230,7 +230,7 @@ export class ProductDetailComponent {
catchError((error) => { catchError((error) => {
this.product.set(null); this.product.set(null);
this.selectedVariantId.set(null); this.selectedVariantId.set(null);
this.selectedImageAssetId.set(null); this.setSelectedImageAssetId(null);
this.modelFile.set(null); this.modelFile.set(null);
this.error.set( this.error.set(
error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR', error?.status === 404 ? 'SHOP.NOT_FOUND' : 'SHOP.LOAD_ERROR',
@@ -257,7 +257,7 @@ export class ProductDetailComponent {
product.defaultVariant ?? product.variants[0] ?? null, product.defaultVariant ?? product.variants[0] ?? null,
), ),
); );
this.selectedImageAssetId.set( this.setSelectedImageAssetId(
product.primaryImage?.mediaAssetId ?? product.primaryImage?.mediaAssetId ??
product.images[0]?.mediaAssetId ?? product.images[0]?.mediaAssetId ??
null, null,
@@ -283,7 +283,7 @@ export class ProductDetailComponent {
} }
selectImage(mediaAssetId: string): void { selectImage(mediaAssetId: string): void {
this.selectedImageAssetId.set(mediaAssetId); this.setSelectedImageAssetId(mediaAssetId);
} }
showPreviousImage(): void { showPreviousImage(): void {
@@ -293,7 +293,7 @@ export class ProductDetailComponent {
} }
const nextIndex = const nextIndex =
(this.selectedImageIndex() - 1 + images.length) % images.length; (this.selectedImageIndex() - 1 + images.length) % images.length;
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
} }
showNextImage(): void { showNextImage(): void {
@@ -302,7 +302,7 @@ export class ProductDetailComponent {
return; return;
} }
const nextIndex = (this.selectedImageIndex() + 1) % images.length; const nextIndex = (this.selectedImageIndex() + 1) % images.length;
this.selectedImageAssetId.set(images[nextIndex].mediaAssetId); this.setSelectedImageAssetId(images[nextIndex].mediaAssetId);
} }
selectVariant(variant: ShopProductVariantOption): void { selectVariant(variant: ShopProductVariantOption): void {
@@ -378,7 +378,9 @@ export class ProductDetailComponent {
} }
colorLabel(variant: ShopProductVariantOption): string { colorLabel(variant: ShopProductVariantOption): string {
return variant.colorName || variant.variantLabel || '-'; return (
variant.colorLabel || variant.colorName || variant.variantLabel || '-'
);
} }
colorHex(variant: ShopProductVariantOption | null | undefined): string { colorHex(variant: ShopProductVariantOption | null | undefined): string {
@@ -479,6 +481,10 @@ export class ProductDetailComponent {
}); });
} }
private setSelectedImageAssetId(mediaAssetId: string | null): void {
this.selectedImageAssetId.set(mediaAssetId);
}
private normalizeHexColor(value: string | null | undefined): string | null { private normalizeHexColor(value: string | null | undefined): string | null {
const raw = String(value ?? '').trim(); const raw = String(value ?? '').trim();
if (!raw) { if (!raw) {
@@ -494,17 +500,7 @@ export class ProductDetailComponent {
} }
private colorHexFromName(value: string | null | undefined): string | null { private colorHexFromName(value: string | null | undefined): string | null {
const colorName = String(value ?? '').trim(); return findColorHex(value);
if (!colorName) {
return null;
}
const fallback = getColorHex(colorName);
if (!fallback || fallback === '#facf0a') {
return null;
}
return fallback;
} }
private applySeo(product: ShopProductDetail): void { private applySeo(product: ShopProductDetail): void {

View File

@@ -55,6 +55,7 @@ export interface ShopProductVariantOption {
sku: string | null; sku: string | null;
variantLabel: string | null; variantLabel: string | null;
colorName: string | null; colorName: string | null;
colorLabel: string | null;
colorHex: string | null; colorHex: string | null;
priceChf: number; priceChf: number;
isDefault: boolean; isDefault: boolean;
@@ -138,6 +139,10 @@ export interface ShopCartItem {
shopProductName: string | null; shopProductName: string | null;
shopVariantLabel: string | null; shopVariantLabel: string | null;
shopVariantColorName: string | null; shopVariantColorName: string | null;
shopVariantColorLabelIt?: string | null;
shopVariantColorLabelEn?: string | null;
shopVariantColorLabelDe?: string | null;
shopVariantColorLabelFr?: string | null;
shopVariantColorHex: string | null; shopVariantColorHex: string | null;
materialCode: string | null; materialCode: string | null;
quality: string | null; quality: string | null;

View File

@@ -84,7 +84,9 @@
<div class="cart-line-copy"> <div class="cart-line-copy">
<strong>{{ cartItemName(item) }}</strong> <strong>{{ cartItemName(item) }}</strong>
@if (cartItemVariant(item); as variant) { @if (cartItemVariant(item); as variant) {
<span class="cart-line-meta">{{ variant }}</span> <span class="cart-line-meta">{{
variant | translate
}}</span>
} }
@if (cartItemColor(item); as color) { @if (cartItemColor(item); as color) {
<span class="cart-line-color"> <span class="cart-line-color">
@@ -92,7 +94,7 @@
class="color-dot" class="color-dot"
[style.background-color]="cartItemColorHex(item)" [style.background-color]="cartItemColorHex(item)"
></span> ></span>
<span>{{ color }}</span> <span>{{ color | translate }}</span>
</span> </span>
} }
</div> </div>

View File

@@ -22,6 +22,10 @@ import {
} from 'rxjs'; } from 'rxjs';
import { SeoService } from '../../core/services/seo.service'; import { SeoService } from '../../core/services/seo.service';
import { LanguageService } from '../../core/services/language.service'; import { LanguageService } from '../../core/services/language.service';
import {
findColorHex,
resolveLocalizedColorLabel,
} from '../../core/constants/colors.const';
import { AppButtonComponent } from '../../shared/components/app-button/app-button.component'; import { AppButtonComponent } from '../../shared/components/app-button/app-button.component';
import { AppCardComponent } from '../../shared/components/app-card/app-card.component'; import { AppCardComponent } from '../../shared/components/app-card/app-card.component';
import { ProductCardComponent } from './components/product-card/product-card.component'; import { ProductCardComponent } from './components/product-card/product-card.component';
@@ -157,15 +161,30 @@ export class ShopPageComponent {
} }
cartItemVariant(item: ShopCartItem): string | null { cartItemVariant(item: ShopCartItem): string | null {
return item.shopVariantLabel || item.shopVariantColorName || null; return item.shopVariantLabel || this.cartItemColor(item);
} }
cartItemColor(item: ShopCartItem): string | null { cartItemColor(item: ShopCartItem): string | null {
return item.shopVariantColorName || item.colorCode || null; return (
resolveLocalizedColorLabel(this.languageService.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 { cartItemColorHex(item: ShopCartItem): string {
return item.shopVariantColorHex || '#c9ced6'; return (
item.shopVariantColorHex ||
findColorHex(item.shopVariantColorName) ||
findColorHex(item.colorCode) ||
'#c9ced6'
);
} }
navigateToCategory(slug?: string | null): void { navigateToCategory(slug?: string | null): void {

View File

@@ -1,4 +1,11 @@
import { Component, input, output, signal, computed } from '@angular/core'; import {
Component,
input,
output,
signal,
computed,
inject,
} from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TranslateModule } from '@ngx-translate/core'; import { TranslateModule } from '@ngx-translate/core';
import { import {
@@ -6,8 +13,10 @@ import {
getColorHex, getColorHex,
ColorCategory, ColorCategory,
ColorOption, ColorOption,
resolveLocalizedColorLabel,
} from '../../../core/constants/colors.const'; } from '../../../core/constants/colors.const';
import { VariantOption } from '../../../features/calculator/services/quote-estimator.service'; import { VariantOption } from '../../../features/calculator/services/quote-estimator.service';
import { LanguageService } from '../../../core/services/language.service';
@Component({ @Component({
selector: 'app-color-selector', selector: 'app-color-selector',
@@ -17,6 +26,7 @@ import { VariantOption } from '../../../features/calculator/services/quote-estim
styleUrl: './color-selector.component.scss', styleUrl: './color-selector.component.scss',
}) })
export class ColorSelectorComponent { export class ColorSelectorComponent {
private readonly languageService = inject(LanguageService);
selectedColor = input<string>('Black'); selectedColor = input<string>('Black');
selectedVariantId = input<number | null>(null); selectedVariantId = input<number | null>(null);
variants = input<VariantOption[]>([]); variants = input<VariantOption[]>([]);
@@ -32,7 +42,14 @@ export class ColorSelectorComponent {
const finish = v.finishType || 'AVAILABLE_COLORS'; const finish = v.finishType || 'AVAILABLE_COLORS';
const bucket = byFinish.get(finish) || []; const bucket = byFinish.get(finish) || [];
bucket.push({ bucket.push({
label: v.colorName, label:
resolveLocalizedColorLabel(this.languageService.selectedLang(), {
fallback: v.colorName,
it: v.colorLabelIt,
en: v.colorLabelEn,
de: v.colorLabelDe,
fr: v.colorLabelFr,
}) ?? v.colorName,
value: v.colorName, value: v.colorName,
hex: v.hexColor, hex: v.hexColor,
variantId: v.id, variantId: v.id,

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Rechner", "CALCULATOR": "Rechner",
"SHOP": "Shop", "SHOP": "Shop",
"ABOUT": "Über uns", "ABOUT": "Über uns",
"MATERIALS": "Qualität & Materialien",
"CONTACT": "Kontakt", "CONTACT": "Kontakt",
"LANGUAGE_SELECTOR": "Sprachauswahl" "LANGUAGE_SELECTOR": "Sprachauswahl"
}, },
@@ -119,6 +120,7 @@
"MODEL_CLOSE": "3D-Ansicht schließen", "MODEL_CLOSE": "3D-Ansicht schließen",
"PREVIOUS_IMAGE": "Vorheriges Bild", "PREVIOUS_IMAGE": "Vorheriges Bild",
"NEXT_IMAGE": "Nächstes Bild", "NEXT_IMAGE": "Nächstes Bild",
"PRICE_LABEL": "Preis",
"SELECT_MATERIAL": "Material", "SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Farbe", "SELECT_COLOR": "Farbe",
"MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar", "MATERIAL_COLOR_COUNT": "{{count}} Farben verfügbar",
@@ -499,6 +501,13 @@
"HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.", "HERO_TITLE": "3D-Druckservice.<br>Von der Datei zum fertigen Teil.",
"HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.", "HERO_LEAD": "Mit dem fortschrittlichsten Rechner für Ihre 3D-Drucke: absolute Präzision und keine Überraschungen.",
"HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!", "HERO_SUBTITLE": "Wir bieten auch CAD-Services für individuelle Teile an!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_COPY": "Produktion und Support in der Schweiz.",
"HERO_SWISS_LOCATIONS_LABEL": "Standorte",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "In der ganzen Schweiz aktiv.",
"BTN_CALCULATE": "Angebot berechnen", "BTN_CALCULATE": "Angebot berechnen",
"BTN_SHOP": "Zum Shop", "BTN_SHOP": "Zum Shop",
"BTN_CONTACT": "Mit uns sprechen", "BTN_CONTACT": "Mit uns sprechen",
@@ -560,6 +569,13 @@
"BLUE": "Blau", "BLUE": "Blau",
"GREEN": "Grün", "GREEN": "Grün",
"YELLOW": "Gelb", "YELLOW": "Gelb",
"ORANGE": "Orange",
"GRAY": "Grau",
"LIGHT_GRAY": "Hellgrau",
"DARK_GRAY": "Dunkelgrau",
"PURPLE": "Lila",
"BEIGE": "Beige",
"SAND_BEIGE": "Sandbeige",
"MATTE_BLACK": "Matt Schwarz", "MATTE_BLACK": "Matt Schwarz",
"MATTE_WHITE": "Matt Weiß", "MATTE_WHITE": "Matt Weiß",
"MATTE_GRAY": "Matt Grau" "MATTE_GRAY": "Matt Grau"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calculator", "CALCULATOR": "Calculator",
"SHOP": "Shop", "SHOP": "Shop",
"ABOUT": "About Us", "ABOUT": "About Us",
"MATERIALS": "Quality & Materials",
"CONTACT": "Contact Us", "CONTACT": "Contact Us",
"LANGUAGE_SELECTOR": "Language selector" "LANGUAGE_SELECTOR": "Language selector"
}, },
@@ -119,6 +120,7 @@
"MODEL_CLOSE": "Close 3D view", "MODEL_CLOSE": "Close 3D view",
"PREVIOUS_IMAGE": "Previous image", "PREVIOUS_IMAGE": "Previous image",
"NEXT_IMAGE": "Next image", "NEXT_IMAGE": "Next image",
"PRICE_LABEL": "Price",
"SELECT_MATERIAL": "Material", "SELECT_MATERIAL": "Material",
"SELECT_COLOR": "Color", "SELECT_COLOR": "Color",
"MATERIAL_COLOR_COUNT": "{{count}} colors available", "MATERIAL_COLOR_COUNT": "{{count}} colors available",
@@ -499,6 +501,13 @@
"HERO_TITLE": "3D printing service.<br>From file to finished part.", "HERO_TITLE": "3D printing service.<br>From file to finished part.",
"HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.", "HERO_LEAD": "With the most advanced calculator for your 3D prints: absolute precision and zero surprises.",
"HERO_SUBTITLE": "We also offer CAD services for custom parts!", "HERO_SUBTITLE": "We also offer CAD services for custom parts!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_COPY": "Swiss production and support.",
"HERO_SWISS_LOCATIONS_LABEL": "Locations",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Serving customers across Switzerland.",
"BTN_CALCULATE": "Calculate Quote", "BTN_CALCULATE": "Calculate Quote",
"BTN_SHOP": "Go to shop", "BTN_SHOP": "Go to shop",
"BTN_CONTACT": "Talk to us", "BTN_CONTACT": "Talk to us",
@@ -560,6 +569,13 @@
"BLUE": "Blue", "BLUE": "Blue",
"GREEN": "Green", "GREEN": "Green",
"YELLOW": "Yellow", "YELLOW": "Yellow",
"ORANGE": "Orange",
"GRAY": "Gray",
"LIGHT_GRAY": "Light Gray",
"DARK_GRAY": "Dark Gray",
"PURPLE": "Purple",
"BEIGE": "Beige",
"SAND_BEIGE": "Sand Beige",
"MATTE_BLACK": "Matte Black", "MATTE_BLACK": "Matte Black",
"MATTE_WHITE": "Matte White", "MATTE_WHITE": "Matte White",
"MATTE_GRAY": "Matte Gray" "MATTE_GRAY": "Matte Gray"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calculateur", "CALCULATOR": "Calculateur",
"SHOP": "Boutique", "SHOP": "Boutique",
"ABOUT": "Qui sommes-nous", "ABOUT": "Qui sommes-nous",
"MATERIALS": "Qualité & matériaux",
"CONTACT": "Contactez-nous", "CONTACT": "Contactez-nous",
"LANGUAGE_SELECTOR": "Sélecteur de langue" "LANGUAGE_SELECTOR": "Sélecteur de langue"
}, },
@@ -17,6 +18,13 @@
"HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.", "HERO_TITLE": "Service d'impression 3D.<br>Du fichier à la pièce finie.",
"HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.", "HERO_LEAD": "Avec le calculateur le plus avancé pour vos impressions 3D : précision absolue et zéro surprise.",
"HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !", "HERO_SUBTITLE": "Nous proposons aussi des services CAD pour des pièces personnalisées !",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_COPY": "Production et support en Suisse.",
"HERO_SWISS_LOCATIONS_LABEL": "Sites",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Actifs dans toute la Suisse.",
"BTN_CALCULATE": "Calculer un devis", "BTN_CALCULATE": "Calculer un devis",
"BTN_SHOP": "Aller à la boutique", "BTN_SHOP": "Aller à la boutique",
"BTN_CONTACT": "Parlez avec nous", "BTN_CONTACT": "Parlez avec nous",
@@ -176,6 +184,7 @@
"MODEL_CLOSE": "Fermer la vue 3D", "MODEL_CLOSE": "Fermer la vue 3D",
"PREVIOUS_IMAGE": "Image précédente", "PREVIOUS_IMAGE": "Image précédente",
"NEXT_IMAGE": "Image suivante", "NEXT_IMAGE": "Image suivante",
"PRICE_LABEL": "Prix",
"SELECT_MATERIAL": "Matériau", "SELECT_MATERIAL": "Matériau",
"SELECT_COLOR": "Couleur", "SELECT_COLOR": "Couleur",
"MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles", "MATERIAL_COLOR_COUNT": "{{count}} couleurs disponibles",
@@ -566,6 +575,13 @@
"BLUE": "Bleu", "BLUE": "Bleu",
"GREEN": "Vert", "GREEN": "Vert",
"YELLOW": "Jaune", "YELLOW": "Jaune",
"ORANGE": "Orange",
"GRAY": "Gris",
"LIGHT_GRAY": "Gris clair",
"DARK_GRAY": "Gris foncé",
"PURPLE": "Violet",
"BEIGE": "Beige",
"SAND_BEIGE": "Beige sable",
"MATTE_BLACK": "Noir mat", "MATTE_BLACK": "Noir mat",
"MATTE_WHITE": "Blanc mat", "MATTE_WHITE": "Blanc mat",
"MATTE_GRAY": "Gris mat" "MATTE_GRAY": "Gris mat"

View File

@@ -4,6 +4,7 @@
"CALCULATOR": "Calcolatore", "CALCULATOR": "Calcolatore",
"SHOP": "Shop", "SHOP": "Shop",
"ABOUT": "Chi Siamo", "ABOUT": "Chi Siamo",
"MATERIALS": "Qualita e Materiali",
"CONTACT": "Contattaci", "CONTACT": "Contattaci",
"LANGUAGE_SELECTOR": "Selettore lingua" "LANGUAGE_SELECTOR": "Selettore lingua"
}, },
@@ -17,6 +18,13 @@
"HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.", "HERO_TITLE": "Servizio di stampa 3D.<br>Dal file al pezzo finito.",
"HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.", "HERO_LEAD": "Con il calcolatore più avanzato per le tue stampe 3D: precisione assoluta e zero sorprese.",
"HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!", "HERO_SUBTITLE": "Offriamo anche servizi di CAD per pezzi personalizzati!",
"HERO_SWISS_TITLE": "Based in Switzerland",
"HERO_SWISS_COPY": "Produzione e supporto in Svizzera",
"HERO_SWISS_LOCATIONS_LABEL": "Sedi",
"HERO_SWISS_LOCATION_1": "Ticino",
"HERO_SWISS_LOCATION_2": "Zurich",
"HERO_SWISS_LOCATION_3": "Biel/Bienne",
"HERO_SWISS_NOTE": "Operativi in tutta la Svizzera.",
"BTN_CALCULATE": "Calcola Preventivo", "BTN_CALCULATE": "Calcola Preventivo",
"BTN_SHOP": "Vai allo shop", "BTN_SHOP": "Vai allo shop",
"BTN_CONTACT": "Parla con noi", "BTN_CONTACT": "Parla con noi",
@@ -193,6 +201,7 @@
"HIGHLIGHT_CART": "Nel carrello", "HIGHLIGHT_CART": "Nel carrello",
"HIGHLIGHT_READY": "Preview", "HIGHLIGHT_READY": "Preview",
"PRICE_FROM": "Prezzo da", "PRICE_FROM": "Prezzo da",
"PRICE_LABEL": "Prezzo",
"EXCERPT_FALLBACK": "Scheda prodotto in preparazione.", "EXCERPT_FALLBACK": "Scheda prodotto in preparazione.",
"MODEL_3D": "3D preview", "MODEL_3D": "3D preview",
"MODEL_TITLE": "Anteprima del modello", "MODEL_TITLE": "Anteprima del modello",
@@ -615,6 +624,13 @@
"BLUE": "Blu", "BLUE": "Blu",
"GREEN": "Verde", "GREEN": "Verde",
"YELLOW": "Giallo", "YELLOW": "Giallo",
"ORANGE": "Arancione",
"GRAY": "Grigio",
"LIGHT_GRAY": "Grigio chiaro",
"DARK_GRAY": "Grigio scuro",
"PURPLE": "Viola",
"BEIGE": "Beige",
"SAND_BEIGE": "Beige sabbia",
"MATTE_BLACK": "Nero opaco", "MATTE_BLACK": "Nero opaco",
"MATTE_WHITE": "Bianco opaco", "MATTE_WHITE": "Bianco opaco",
"MATTE_GRAY": "Grigio opaco" "MATTE_GRAY": "Grigio opaco"

View File

@@ -107,10 +107,6 @@ app-product-detail {
position: relative; position: relative;
display: grid; display: grid;
gap: 0.4rem; gap: 0.4rem;
padding: 0;
border-radius: 1rem;
border: 0;
background: transparent;
} }
.selector-head { .selector-head {
@@ -119,7 +115,8 @@ app-product-detail {
.color-trigger { .color-trigger {
width: 100%; width: 100%;
max-width: 230px; max-width: none;
min-height: 3.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;